name: CI

on:
    push:
        branches:
            - main
    pull_request:
    merge_group:
        types:
            - checks_requested

permissions:
    contents: read
    pull-requests: read

concurrency:
    group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}
    cancel-in-progress: true

jobs:
    changes:
        name: Classify PR changes
        runs-on: ubuntu-latest
        outputs:
            docs_only: ${{ steps.classify.outputs.docs_only }}
            desktop_changed: ${{ steps.classify.outputs.desktop_changed }}
            site_changed: ${{ steps.classify.outputs.site_changed }}
        steps:
            - id: classify
              name: Detect docs-only pull requests
              uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
              with:
                  script: |
                      if (context.eventName !== 'pull_request' && context.eventName !== 'push') {
                        core.setOutput('docs_only', 'false');
                        core.setOutput('desktop_changed', 'true');
                        core.setOutput('site_changed', 'true');
                        return;
                      }

                      const docsOnlyPatterns = [
                        /^docs\//,
                        /^[^/]+\.md$/,
                        /^COPYING$/,
                        /^\.github\/ISSUE_TEMPLATE\//,
                        /^\.github\/dependabot\.yml$/,
                        /^\.github\/labeler\.yml$/,
                        /^\.github\/labels\.yml$/,
                        /^\.github\/pull_request_template\.md$/,
                        /^CLAUDE\.md$/,
                        /^CODE_OF_CONDUCT\.md$/,
                        /^CONTRIBUTING\.md$/,
                        /^README\.en\.md$/,
                        /^README\.md$/,
                        /^SECURITY\.md$/,
                        /^SUPPORT\.md$/,
                      ];
                      const desktopPatterns = [
                        /^apps\/desktop\//,
                        /^packages\//,
                        /^package\.json$/,
                        /^pnpm-lock\.yaml$/,
                        /^pnpm-workspace\.yaml$/,
                        /^eslint\.config\.js$/,
                        /^\.prettierrc$/,
                        /^\.prettierignore$/,
                        /^\.github\/workflows\/ci\.yml$/,
                      ];
                      const sitePatterns = [
                        /^apps\/site\//,
                        /^packages\//,
                        /^package\.json$/,
                        /^pnpm-lock\.yaml$/,
                        /^pnpm-workspace\.yaml$/,
                        /^\.prettierrc$/,
                        /^\.prettierignore$/,
                        /^\.github\/workflows\/ci\.yml$/,
                      ];

                      let files = [];

                      if (context.eventName === 'pull_request') {
                        files = await github.paginate(github.rest.pulls.listFiles, {
                          ...context.repo,
                          pull_number: context.issue.number,
                          per_page: 100,
                        });
                      } else if (context.eventName === 'push') {
                        const before = context.payload.before;
                        const after = context.payload.after;

                        if (!before || !after || /^0+$/.test(before)) {
                          core.setOutput('docs_only', 'false');
                          core.setOutput('desktop_changed', 'true');
                          core.setOutput('site_changed', 'true');
                          return;
                        }

                        const comparison = await github.rest.repos.compareCommits({
                          ...context.repo,
                          base: before,
                          head: after,
                        });

                        files = comparison.data.files ?? [];

                        if (files.length >= 300) {
                          core.setOutput('docs_only', 'false');
                          core.setOutput('desktop_changed', 'true');
                          core.setOutput('site_changed', 'true');
                          return;
                        }
                      }

                      const docsOnly =
                        files.length > 0 &&
                        files.every((file) => docsOnlyPatterns.some((pattern) => pattern.test(file.filename)));
                      const filenames = files.map((file) => file.filename);
                      const desktopChanged =
                        filenames.length === 0 ||
                        filenames.some((filename) => desktopPatterns.some((pattern) => pattern.test(filename)));
                      const siteChanged =
                        filenames.length === 0 ||
                        filenames.some((filename) => sitePatterns.some((pattern) => pattern.test(filename)));

                      core.setOutput('docs_only', docsOnly ? 'true' : 'false');
                      core.setOutput('desktop_changed', desktopChanged ? 'true' : 'false');
                      core.setOutput('site_changed', siteChanged ? 'true' : 'false');

            - name: Report classification
              run: |
                  echo "docs_only=${{ steps.classify.outputs.docs_only }}"
                  echo "desktop_changed=${{ steps.classify.outputs.desktop_changed }}"
                  echo "site_changed=${{ steps.classify.outputs.site_changed }}"

    conventional-commits:
        name: Conventional Commits
        if: github.event_name == 'pull_request'
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
              with:
                  fetch-depth: 0

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              with:
                  node-version: 22
                  cache: pnpm

            - name: Install dependencies
              run: pnpm install --frozen-lockfile

            - name: Validate pull request title
              env:
                  PR_TITLE: ${{ github.event.pull_request.title }}
              run: |
                  printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --verbose

            - name: Validate commit messages
              run: |
                  pnpm exec commitlint \
                    --from "${{ github.event.pull_request.base.sha }}" \
                    --to "${{ github.event.pull_request.head.sha }}" \
                    --verbose

    dependency-review:
        name: Dependency Review
        if: github.event_name == 'pull_request'
        needs: changes
        runs-on: ubuntu-latest
        steps:
            - name: Skip dependency review for docs-only PRs
              if: needs.changes.outputs.docs_only == 'true'
              run: echo 'Dependency review is not required for docs-only pull requests.'

            - name: Dependency review
              if: needs.changes.outputs.docs_only != 'true'
              uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
              with:
                  # Tauri/Wry currently pulls the vulnerable GTK3 glib 0.18 chain on Linux,
                  # and upstream has no non-vulnerable GTK3 release path yet.
                  # Tauri's HTML/CSS parsing chain currently pulls rand 0.7.x through
                  # phf/kuchikiki, which still has no direct non-vulnerable Tauri path.
                  allow-ghsas: GHSA-wrw7-89jp-8q8g, GHSA-cq8v-f236-94qc

    frontend-quality:
        name: Frontend Quality
        needs: changes
        if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.desktop_changed == 'true'
        runs-on: ubuntu-latest
        steps:
            - name: Skip frontend quality checks for docs-only PRs
              if: needs.changes.outputs.docs_only == 'true'
              run: echo 'Frontend quality checks are skipped for docs-only changes.'

            - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
              if: needs.changes.outputs.docs_only != 'true'

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
              if: needs.changes.outputs.docs_only != 'true'

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              if: needs.changes.outputs.docs_only != 'true'
              with:
                  node-version: 22
                  cache: pnpm

            - name: Install dependencies
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm install --frozen-lockfile

            - name: Type check
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm type:check

            - name: Type check tests
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm test:typecheck

            - name: Lint
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm lint:check

            - name: Format check
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm format:check

    frontend-tests:
        name: Frontend Tests
        needs: changes
        if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.desktop_changed == 'true'
        runs-on: ubuntu-latest
        steps:
            - name: Skip frontend tests for docs-only PRs
              if: needs.changes.outputs.docs_only == 'true'
              run: echo 'Frontend tests are skipped for docs-only changes.'

            - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
              if: needs.changes.outputs.docs_only != 'true'

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
              if: needs.changes.outputs.docs_only != 'true'

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              if: needs.changes.outputs.docs_only != 'true'
              with:
                  node-version: 22
                  cache: pnpm

            - name: Install dependencies
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm install --frozen-lockfile

            - name: Run frontend tests with coverage
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm test:coverage

            - name: Summarize coverage
              if: needs.changes.outputs.docs_only != 'true'
              run: |
                  node -e "const fs = require('node:fs'); const summary = JSON.parse(fs.readFileSync('apps/desktop/.coverage/unit/coverage-summary.json', 'utf8')).total; fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, ['## Frontend Coverage', '', '| Metric | Percent |', '| --- | ---: |', `| Statements | ${summary.statements.pct}% |`, `| Branches | ${summary.branches.pct}% |`, `| Functions | ${summary.functions.pct}% |`, `| Lines | ${summary.lines.pct}% |`, ''].join('\n'))"

            - name: Upload coverage artifact
              if: needs.changes.outputs.docs_only != 'true'
              uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
              with:
                  name: frontend-coverage-report
                  path: apps/desktop/.coverage/unit
                  if-no-files-found: error

    site-build:
        name: Site Build
        needs: changes
        if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.site_changed == 'true'
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              with:
                  node-version: 22
                  cache: pnpm

            - name: Install dependencies
              run: pnpm install --frozen-lockfile

            - name: Build site
              run: pnpm site:build

    rust-checks:
        name: Rust Checks
        needs: changes
        if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.desktop_changed == 'true'
        runs-on: ubuntu-latest
        steps:
            - name: Skip Rust checks for docs-only PRs
              if: needs.changes.outputs.docs_only == 'true'
              run: echo 'Rust checks are skipped for docs-only changes.'

            - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
              if: needs.changes.outputs.docs_only != 'true'

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
              if: needs.changes.outputs.docs_only != 'true'

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              if: needs.changes.outputs.docs_only != 'true'
              with:
                  node-version: 22
                  package-manager-cache: false

            - name: Install Linux system dependencies
              if: needs.changes.outputs.docs_only != 'true'
              run: |
                  sudo apt update
                  sudo apt install -y \
                    build-essential \
                    curl \
                    file \
                    libayatana-appindicator3-dev \
                    libglib2.0-dev \
                    librsvg2-dev \
                    libssl-dev \
                    libwebkit2gtk-4.1-dev \
                    libxdo-dev \
                    pkg-config \
                    wget

            - name: Install Rust stable
              if: needs.changes.outputs.docs_only != 'true'
              uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

            - name: Rust cache
              if: needs.changes.outputs.docs_only != 'true'
              uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
              with:
                  workspaces: apps/desktop/src-tauri -> ../../../rust-target/ci-check

            - name: Check Rust formatting
              if: needs.changes.outputs.docs_only != 'true'
              run: cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --all -- --check

            - name: Check Rust targets
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm check:rust

            - name: Run Rust tests
              if: needs.changes.outputs.docs_only != 'true'
              run: pnpm test:rust

    build-windows:
        name: Build (Windows)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.desktop_changed == 'true'
        needs:
            - changes
            - frontend-quality
            - frontend-tests
            - rust-checks
        runs-on: windows-latest
        steps:
            - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

            - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6

            - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
              with:
                  node-version: 22
                  cache: pnpm

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

            - name: Rust cache
              uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
              with:
                  workspaces: apps/desktop/src-tauri -> target

            - name: Get short commit SHA
              id: sha
              run: echo "short=$(git rev-parse --short HEAD)" >> $env:GITHUB_OUTPUT

            - name: Install frontend dependencies
              run: pnpm install --frozen-lockfile

            - name: Build Tauri app
              uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
              with:
                  projectPath: apps/desktop
              env:
                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

            - name: Upload Windows installer
              uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
              with:
                  name: touchai-installer-windows-${{ steps.sha.outputs.short }}
                  path: apps/desktop/src-tauri/target/release/bundle/msi/*.msi
                  if-no-files-found: warn

    ci-required:
        name: CI Required
        if: always()
        needs:
            - changes
            - conventional-commits
            - dependency-review
            - frontend-quality
            - frontend-tests
            - site-build
            - rust-checks
            - build-windows
        runs-on: ubuntu-latest
        steps:
            - name: Verify required CI jobs
              env:
                  EVENT_NAME: ${{ github.event_name }}
                  DOCS_ONLY: ${{ needs.changes.outputs.docs_only }}
                  DESKTOP_CHANGED: ${{ needs.changes.outputs.desktop_changed }}
                  SITE_CHANGED: ${{ needs.changes.outputs.site_changed }}
                  CHANGES_RESULT: ${{ needs.changes.result }}
                  CONVENTIONAL_COMMITS_RESULT: ${{ needs['conventional-commits'].result }}
                  DEPENDENCY_REVIEW_RESULT: ${{ needs['dependency-review'].result }}
                  FRONTEND_QUALITY_RESULT: ${{ needs['frontend-quality'].result }}
                  FRONTEND_TESTS_RESULT: ${{ needs['frontend-tests'].result }}
                  SITE_BUILD_RESULT: ${{ needs['site-build'].result }}
                  RUST_CHECKS_RESULT: ${{ needs['rust-checks'].result }}
                  BUILD_WINDOWS_RESULT: ${{ needs['build-windows'].result }}
              run: |
                  required=()

                  required+=("changes:$CHANGES_RESULT")

                  if [ "$EVENT_NAME" = "pull_request" ]; then
                    required+=("conventional-commits:$CONVENTIONAL_COMMITS_RESULT")
                  fi

                  if [ "$DOCS_ONLY" != "true" ]; then
                    if [ "$EVENT_NAME" = "pull_request" ]; then
                      required+=("dependency-review:$DEPENDENCY_REVIEW_RESULT")
                    fi

                    if [ "$DESKTOP_CHANGED" = "true" ]; then
                      required+=("frontend-quality:$FRONTEND_QUALITY_RESULT")
                      required+=("frontend-tests:$FRONTEND_TESTS_RESULT")
                      required+=("rust-checks:$RUST_CHECKS_RESULT")
                    fi

                    if [ "$SITE_CHANGED" = "true" ]; then
                      required+=("site-build:$SITE_BUILD_RESULT")
                    fi

                    if [ "$EVENT_NAME" = "push" ] && [ "$DESKTOP_CHANGED" = "true" ]; then
                      required+=("build-windows:$BUILD_WINDOWS_RESULT")
                    fi
                  fi

                  failed=0
                  observed=(
                    "changes:$CHANGES_RESULT"
                    "conventional-commits:$CONVENTIONAL_COMMITS_RESULT"
                    "dependency-review:$DEPENDENCY_REVIEW_RESULT"
                    "frontend-quality:$FRONTEND_QUALITY_RESULT"
                    "frontend-tests:$FRONTEND_TESTS_RESULT"
                    "site-build:$SITE_BUILD_RESULT"
                    "rust-checks:$RUST_CHECKS_RESULT"
                    "build-windows:$BUILD_WINDOWS_RESULT"
                  )

                  for check in "${observed[@]}"; do
                    name="${check%%:*}"
                    result="${check#*:}"

                    if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
                      echo "$name unexpectedly ended with $result."
                      failed=1
                    fi
                  done

                  for check in "${required[@]}"; do
                    name="${check%%:*}"
                    result="${check#*:}"
                    echo "$name -> $result"

                    if [ "$result" != "success" ]; then
                      failed=1
                    fi
                  done

                  if [ "$failed" -ne 0 ]; then
                    echo "One or more required CI jobs did not pass."
                    exit 1
                  fi
