name: Python CI

on:
  push:
    branches: [ "master", "main" ]
  pull_request:
    branches: [ "master", "main" ]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  quality:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
    - uses: actions/checkout@v7
    - name: Set up Python 3.13
      uses: actions/setup-python@v6
      with:
        python-version: "3.13"
        cache: 'pip'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt
        pip install -e .
    - name: Lint with Ruff
      run: ruff check .
    - name: Type check with Mypy
      run: mypy src scripts
    - name: Security check with Bandit
      run: bandit -r src

  # Fast gate (~12 s harness, runtime deps only, no pytest): the committed
  # docs/CONFORMANCE.md must equal a fresh `make conformance` run.
  conformance:
    name: Conformance report up to date
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
    - uses: actions/checkout@v7
    - name: Set up Python 3.13
      uses: actions/setup-python@v6
      with:
        python-version: "3.13"
        cache: 'pip'
    - name: Install runtime dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -e .
    - name: Regenerate docs/CONFORMANCE.md
      run: make conformance
    - name: Fail if the committed report is stale
      run: |
        if ! git diff --exit-code docs/CONFORMANCE.md; then
          echo "::error file=docs/CONFORMANCE.md::docs/CONFORMANCE.md is out of date - run 'make conformance' and commit the result."
          exit 1
        fi

  tests:
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
      # Required for the OIDC-based Codecov upload (no token secret needed).
      id-token: write
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.13", "3.14"]
    steps:
    - uses: actions/checkout@v7
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v6
      with:
        python-version: ${{ matrix.python-version }}
        cache: 'pip'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt
        pip install -e .
    - name: Run tests
      run: |
        pytest --junitxml=test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=src --cov-report=xml
    - name: Upload Test Results
      uses: actions/upload-artifact@v7
      with:
        name: test-results-${{ matrix.os }}-${{ matrix.python-version }}
        path: |
          test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml
          coverage.xml
      if: always()
    - name: Upload coverage to Codecov
      if: matrix.os == 'ubuntu-latest'
      uses: codecov/codecov-action@v7
      with:
        files: coverage.xml
        use_oidc: true
        fail_ci_if_error: false

  # The main matrix runs against the latest numpy with the pure-Python
  # impulse kernel; this job is the only one exercising the numba-jitted
  # path (numba pins numpy, so it lives in its own environment).
  tests-perf:
    name: tests (numba-jitted kernel)
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
    - uses: actions/checkout@v7
      with:
        persist-credentials: false
    - name: Set up Python 3.13
      uses: actions/setup-python@v6
      with:
        python-version: "3.13"
        cache: 'pip'
    - name: Install dependencies (with numba)
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements-dev.txt
        pip install -e ".[perf,plot]"
    - name: Run tests (jitted kernel)
      env:
        # conftest.py disables JIT by default (coverage); override it here —
        # this is the only job exercising the numba-compiled kernel.
        NUMBA_DISABLE_JIT: "0"
      run: pytest -q

  sonar:
    needs: tests
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main'
    steps:
    - uses: actions/checkout@v7
      with:
        fetch-depth: 0
    - name: Download coverage report
      uses: actions/download-artifact@v8
      with:
        name: test-results-ubuntu-latest-3.13
    - name: SonarCloud Scan
      uses: SonarSource/sonarqube-scan-action@v8
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  pr-comment:
    needs: [quality, tests]
    runs-on: ubuntu-latest
    if: always() && github.event_name == 'pull_request'
    permissions:
      contents: read
      pull-requests: write
      issues: write
    steps:
    - uses: actions/checkout@v7
    
    - name: Set up Python 3.13
      uses: actions/setup-python@v6
      with:
        python-version: "3.13"
        
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -e .
        
    - name: Generate numerical conformance report
      # Never block the comment: if a check regresses the harness exits
      # non-zero, but we still want the report (with the failing rows) posted.
      run: python scripts/conformance_report.py || true

    - name: Download Test Results
      uses: actions/download-artifact@v8
      with:
        pattern: test-results-*
        path: test-results
      continue-on-error: true
    
    - name: Generate Comment Body
      run: python .github/scripts/comment_pr.py
      env:
        GITHUB_REPOSITORY: ${{ github.repository }}
        GITHUB_RUN_ID: ${{ github.run_id }}

    - name: Post PR Comment
      uses: actions/github-script@v9
      with:
        script: |
          const fs = require('fs');
          const body = fs.readFileSync('pr_comment_body.md', 'utf8');
          const marker = '<!-- phonometry-ci-conformance -->';

          // Update the single sticky conformance comment instead of posting a
          // new one every run: find the existing marked comment and edit it.
          const { data: comments } = await github.rest.issues.listComments({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            per_page: 100,
          });
          const existing = comments.find(
            (c) => c.body && c.body.includes(marker)
          );
          if (existing) {
            await github.rest.issues.updateComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              comment_id: existing.id,
              body: body,
            });
          } else {
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body,
            });
          }