# Run the project's test suite on every PR push and on push to main.
#
# The suite uses stdlib `unittest` discovery — no external Python
# dependencies are required to execute it. Tests are split by directory:
#
#   tests/unit/         — pure unit tests; always run
#   tests/integration/  — need a reachable backend; skip cleanly when
#                         the backend isn't available on the runner
#   tests/canaries/     — upstream regression canaries; run on a separate
#                         schedule (see canaries.yml), not here
#
# Each test job runs once under coverage and uploads a small .coverage.*
# artifact. The `coverage` job combines them — no test reruns, no KVM
# dependency on that job. For main-branch pushes only, the tested rootfs
# and matching dropbear are uploaded so `publish-infra` can publish the
# byte-identical artifact that was tested. PRs avoid the ~194 MB rootfs
# transfer entirely.

name: test

on:
  push:
    branches:
      - main
    paths:
      - '**.py'
      - '.gitea/workflows/**.yml'
      - 'scripts/**'
      - 'README.md'
      # Dockerfiles and pyproject.toml are baked into the infra rootfs; a
      # change here alters what the integration/coverage jobs build locally.
      - 'Dockerfile*'
      - 'pyproject.toml'
  pull_request:
    paths:
      - '**.py'
      - '.gitea/workflows/**.yml'
      - 'scripts/**'
      - 'README.md'
      - 'Dockerfile*'
      - 'pyproject.toml'
  workflow_dispatch:

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # No actions/setup-python: the runner image already ships Python 3.12,
      # and older act_runner engines mishandle setup-python's PATH (coverage
      # lands in one interpreter, `python3` resolves to another). Install
      # straight into the ephemeral job container's system Python —
      # --break-system-packages is safe because the container is disposable.
      - name: Install dev requirements
        run: python3 -m pip install --break-system-packages -r requirements-dev.txt

      - name: Run unit tests with coverage
        env:
          COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
        run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v

      - name: Report unit coverage
        env:
          COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
        run: python3 -m coverage report -m

      # upload-artifact@v3's glob skips dotfiles, so a bare `.coverage.unit`
      # silently uploads nothing ("No files were found"). Stage it under a
      # non-dot name; the coverage job renames it back before `coverage
      # combine`. `cp` also fails loudly if coverage never wrote the file.
      - name: Stage unit coverage for upload
        run: cp .coverage.unit coverage-unit.dat

      - name: Upload unit coverage artifact
        uses: actions/upload-artifact@v3
        with:
          name: coverage-unit
          path: coverage-unit.dat

  integration-docker:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # No actions/setup-python (see the note in the `unit` job); the
      # container's system Python 3.12 runs the stdlib test suite directly.
      - name: Install coverage
        run: python3 -m pip install --break-system-packages coverage

      - name: Show environment
        run: |
          python3 --version
          if command -v docker >/dev/null 2>&1; then
            docker version || true
          else
            echo "docker not on PATH — integration tests will skip"
          fi

      - name: Run integration tests (docker) with coverage
        env:
          BOT_BOTTLE_BACKEND: docker
          COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
        run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v

      # Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
      - name: Stage docker coverage for upload
        run: cp .coverage.docker coverage-docker.dat

      - name: Upload docker coverage artifact
        uses: actions/upload-artifact@v3
        with:
          name: coverage-docker
          path: coverage-docker.dat

  # Integration tests against the Firecracker backend. Runs on a self-hosted
  # KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
  #
  # Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
  # PRs don't execute untrusted code on the privileged runner.
  #
  # Runner prerequisites (provision once; see README "Firecracker on Linux"):
  # `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
  # static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
  # persistent systemd unit.
  #
  # The infra candidate is built here directly (no artifact download) to
  # eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
  # the old build-infra → integration-firecracker + coverage chain incurred.
  # For main-branch pushes the tested rootfs and matching dropbear are
  # uploaded so publish-infra can publish the byte-identical artifact; PRs
  # skip those uploads entirely.
  integration-firecracker:
    runs-on: [self-hosted, kvm]
    if: >-
      github.event_name == 'push' ||
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'pull_request' &&
       github.event.pull_request.head.repo.full_name == github.repository)
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Preflight — Firecracker host is ready
        run: |
          command -v firecracker >/dev/null || {
            echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
          test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
          # `backend status` exits non-zero unless the TAP pool is up + no
          # range overlap; it prints the exact `backend setup` fix.
          python3 cli.py backend status --backend=firecracker

      - name: Build infra candidate from this checkout
        env:
          BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
        run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate --reuse-published

      - name: Replace the persistent infra VM with the candidate
        run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'

      # No dev-requirements install: `coverage` is already provided by the
      # self-hosted runner's Nix python env, and that env has no `pip`
      # module to install into anyway.
      - name: Run integration tests (firecracker) with coverage
        env:
          BOT_BOTTLE_BACKEND: firecracker
          BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
          COVERAGE_FILE: ${{ github.workspace }}/.coverage.firecracker
        run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v

      # Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
      - name: Stage firecracker coverage for upload
        run: cp .coverage.firecracker coverage-firecracker.dat

      - name: Upload firecracker coverage artifact
        uses: actions/upload-artifact@v3
        with:
          name: coverage-firecracker
          path: coverage-firecracker.dat

      # Only upload the large rootfs artifact on main-branch pushes;
      # PRs avoid the ~194 MB transfer. publish-infra only runs on main
      # and downloads these to publish the byte-identical tested rootfs.
      - name: Upload tested rootfs (main branch only)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v3
        with:
          name: infra-candidate
          path: infra-candidate/

      - name: Upload dropbear for publish verification (main branch only)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v3
        with:
          name: firecracker-inputs
          path: /var/cache/bot-bottle-fc/dropbear

  # Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
  # test job, then runs the diff-coverage gate (new/changed lines >= 90%).
  #
  # Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
  # relative_files = True (.coveragerc) so they combine cleanly across runners.
  # Each test job sets COVERAGE_FILE to an absolute path so coverage.py writes
  # to a known location that upload-artifact can find regardless of runner env.
  #
  # Restricted to the same events as integration-firecracker: it depends on
  # that job's coverage artifact and skips for fork PRs alongside it.
  coverage:
    needs: [unit, integration-docker, integration-firecracker]
    timeout-minutes: 15
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'push' ||
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'pull_request' &&
       github.event.pull_request.head.repo.full_name == github.repository)
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install coverage
        run: python3 -m pip install --break-system-packages coverage

      - name: Download unit coverage artifact
        uses: actions/download-artifact@v3
        with:
          name: coverage-unit
          path: ${{ github.workspace }}

      - name: Download docker coverage artifact
        uses: actions/download-artifact@v3
        with:
          name: coverage-docker
          path: ${{ github.workspace }}

      - name: Download firecracker coverage artifact
        uses: actions/download-artifact@v3
        with:
          name: coverage-firecracker
          path: ${{ github.workspace }}

      # Rename the non-dot upload names back to the .coverage.* files that
      # `coverage combine` discovers (see the staging steps in each test job).
      - name: Reassemble coverage data files
        run: |
          mv coverage-unit.dat .coverage.unit
          mv coverage-docker.dat .coverage.docker
          mv coverage-firecracker.dat .coverage.firecracker

      - name: Combined coverage (unit + integration, incl. firecracker)
        run: PYTHON=python3 bash scripts/coverage.sh aggregate critical

      - name: Diff-coverage gate (changed lines >= 90%)
        run: |
          git fetch --no-tags origin main:refs/remotes/origin/main
          python3 scripts/diff_coverage.py --base origin/main --min 90

  publish-infra:
    needs: [unit, integration-docker, integration-firecracker, coverage]
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout the tested revision
        uses: actions/checkout@v4

      - name: Download the tested rootfs
        uses: actions/download-artifact@v3
        with:
          name: infra-candidate
          path: infra-candidate

      # publish_infra re-derives the version from the checkout to confirm the
      # bundle matches before uploading, and the version hashes the dropbear
      # bytes. Download the SAME dropbear integration-firecracker used, or
      # the recheck computes a "<missing>"-dropbear version and rejects the
      # candidate.
      - name: Download the staged dropbear (matches build's version)
        uses: actions/download-artifact@v3
        with:
          name: firecracker-inputs
          path: firecracker-inputs

      - name: Publish the tested candidate
        env:
          BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
          BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
        run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
