name: Build & Release

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to build (e.g., v0.2.0)'
        required: false

env:
  RUST_BACKTRACE: 1
  CARGO_TERM_COLOR: always
  # Incremental state is never reused between CI runs -- pure disk cost.
  CARGO_INCREMENTAL: 0

jobs:
  test:
    name: Run tests
    runs-on: linux/amd64
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Cache Rust dependencies
        uses: actions/cache@v3
        with:
          # Registry only -- never `target`. That directory is ~30 GB locally
          # (25G debug, 3.1G android, 2.9G release) and was cached under two
          # keys, on a runner that JellyTau's builds share -- the same leak that
          # filled its disk in August 2026. registry/src is left out too: cargo
          # re-extracts it for free from registry/cache (155 MB of .crate
          # tarballs vs 1.1 GB extracted).
          path: |
            ~/.cargo/registry/index
            ~/.cargo/registry/cache
            ~/.cargo/git/db
          # One shared key across every job. The host/android split existed to
          # stop the two `target` dirs clobbering each other; with target no
          # longer cached, registry contents are target-independent and every
          # job wants the same crates. First job to finish saves; the rest
          # restore.
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-registry-

      - name: Install frontend dependencies
        run: npm --prefix ui ci

      - name: Run workspace tests
        run: cargo test --workspace --locked

      - name: Frontend tests
        run: npm --prefix ui test

      - name: Type-check the frontend
        run: npm --prefix ui run check

  build-linux:
    name: Build Linux (deb + AppImage)
    runs-on: linux/amd64
    needs: test
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Cache Rust dependencies
        uses: actions/cache@v3
        with:
          # Registry only -- never `target`. That directory is ~30 GB locally
          # (25G debug, 3.1G android, 2.9G release) and was cached under two
          # keys, on a runner that JellyTau's builds share -- the same leak that
          # filled its disk in August 2026. registry/src is left out too: cargo
          # re-extracts it for free from registry/cache (155 MB of .crate
          # tarballs vs 1.1 GB extracted).
          path: |
            ~/.cargo/registry/index
            ~/.cargo/registry/cache
            ~/.cargo/git/db
          # One shared key across every job. The host/android split existed to
          # stop the two `target` dirs clobbering each other; with target no
          # longer cached, registry contents are target-independent and every
          # job wants the same crates. First job to finish saves; the rest
          # restore.
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-registry-

      - name: Install frontend dependencies
        run: npm --prefix ui ci

      - name: Set app version from tag
        run: ./scripts/ci-set-version.sh

      - name: Build the desktop bundle
        working-directory: src-tauri
        run: cargo tauri build

      - name: Collect Linux artifacts
        run: |
          set -e
          mkdir -p dist/linux
          find target/release/bundle -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.rpm' \) \
            -exec cp -v {} dist/linux/ \;
          # A release that quietly ships nothing is worse than a failed build.
          [ -n "$(ls -A dist/linux)" ] || { echo "❌ No Linux bundle produced"; exit 1; }
          ls -lah dist/linux/

      - name: Upload Linux build artifact
        uses: actions/upload-artifact@v3
        with:
          name: bikecontrol-linux
          path: dist/linux/
          retention-days: 7

  build-arch:
    name: Build Arch package
    runs-on: linux/amd64
    needs: test
    # Arch-specific image: makepkg does not exist on the Ubuntu builder, and
    # the package must be built against Arch's own webkit2gtk/gtk3.
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-arch-builder:latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          # scripts/build-arch.sh derives the version from git describe.
          fetch-depth: 0

      - name: Build the package
        run: |
          set -e
          # makepkg refuses to run as root, and the checkout is owned by the
          # job's user, so hand the tree to the image's `builder` account.
          chown -R builder:builder .
          git config --global --add safe.directory "$PWD"
          sudo -u builder git config --global --add safe.directory "$PWD"
          sudo -u builder --preserve-env=OUTPUT_DIR \
            env OUTPUT_DIR="$PWD/dist/arch" ./scripts/build-arch.sh --no-install
          ls -lah dist/arch/

      - name: Upload Arch build artifact
        uses: actions/upload-artifact@v3
        with:
          name: bikecontrol-arch
          path: dist/arch/
          retention-days: 7

  build-android:
    name: Build Android APK
    runs-on: linux/amd64
    needs: test
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
    env:
      ANDROID_HOME: /opt/android-sdk
      ANDROID_SDK_ROOT: /opt/android-sdk
      NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
      ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Cache Rust dependencies
        uses: actions/cache@v3
        with:
          # Registry only -- never `target`. That directory is ~30 GB locally
          # (25G debug, 3.1G android, 2.9G release) and was cached under two
          # keys, on a runner that JellyTau's builds share -- the same leak that
          # filled its disk in August 2026. registry/src is left out too: cargo
          # re-extracts it for free from registry/cache (155 MB of .crate
          # tarballs vs 1.1 GB extracted).
          path: |
            ~/.cargo/registry/index
            ~/.cargo/registry/cache
            ~/.cargo/git/db
          # One shared key across every job. The host/android split existed to
          # stop the two `target` dirs clobbering each other; with target no
          # longer cached, registry contents are target-independent and every
          # job wants the same crates. First job to finish saves; the rest
          # restore.
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-registry-

      - name: Install frontend dependencies
        run: npm --prefix ui ci

      - name: Set app version from tag
        run: ./scripts/ci-set-version.sh

      # Before init, and before the build: this edits tauri.conf.json, which is
      # the input both of them read. `gen/android/app/tauri.properties` is an
      # output of `android build` — it does not exist yet, and anything written
      # there would be overwritten by the build anyway.
      - name: Pin a monotonic Android versionCode
        run: ./scripts/ci-android-version-code.sh

      - name: Initialise the Android project
        working-directory: src-tauri
        run: cargo tauri android init

      # Manifest, MainActivity, gradle config, and btleplug's Java backend.
      # `tauri android init` above regenerated gen/android and knows about none
      # of it — without this step the APK builds and then finds no trainer.
      - name: Sync custom Android sources
        run: ./scripts/sync-android-sources.sh

      # gen/ is not tracked, so a Kotlin file that exists only there is one git
      # has never seen and the next init will delete. Catch it before it ships.
      - name: Verify every hand-written Android source is tracked
        run: ./scripts/check-android-sources.sh

      - name: Write the signing keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/bikecontrol-release.jks"
          cat > src-tauri/gen/android/keystore.properties <<EOF
          storeFile=$RUNNER_TEMP/bikecontrol-release.jks
          storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
          keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
          EOF

      - name: Build the signed APK
        working-directory: src-tauri
        run: cargo tauri android build --apk --target aarch64

      - name: Collect and verify the APK
        run: |
          set -e
          mkdir -p dist/android
          APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
          [ -n "$APK" ] || { echo "❌ No release APK produced"; exit 1; }
          cp "$APK" dist/android/bikecontrol-release.apk
          APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
          echo "🔏 Verifying with $APKSIGNER"
          "$APKSIGNER" verify --print-certs dist/android/bikecontrol-release.apk
          ls -lah dist/android/

      - name: Upload Android build artifact
        uses: actions/upload-artifact@v3
        with:
          name: bikecontrol-android
          path: dist/android/
          retention-days: 7

  create-release:
    name: Create release
    runs-on: linux/amd64
    needs: [build-linux, build-arch, build-android]
    if: startsWith(github.ref, 'refs/tags/v')
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Get version from tag
        id: tag_name
        run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

      - name: Download Linux artifacts
        uses: actions/download-artifact@v3
        with:
          name: bikecontrol-linux
          path: artifacts/linux/

      - name: Download Arch artifacts
        uses: actions/download-artifact@v3
        with:
          name: bikecontrol-arch
          path: artifacts/arch/

      - name: Download Android artifacts
        uses: actions/download-artifact@v3
        with:
          name: bikecontrol-android
          path: artifacts/android/

      - name: Prepare release notes
        run: |
          VERSION="${{ steps.tag_name.outputs.VERSION }}"
          cat > release_notes.md <<'EOF'
          ## Downloads

          | Platform | File | Install |
          |---|---|---|
          | Linux (any) | `*.AppImage` | `chmod +x BikeControl*.AppImage && ./BikeControl*.AppImage` |
          | Debian/Ubuntu | `*.deb` | `sudo dpkg -i bikecontrol*.deb` |
          | Arch | `*.pkg.tar.zst` | `sudo pacman -U bikecontrol-*.pkg.tar.zst` |
          | Android (arm64) | `bikecontrol-release.apk` | `adb install bikecontrol-release.apk`, or sideload |

          ## Requirements

          **Linux** — BlueZ running (`bluetoothd`), and a Bluetooth adapter with BLE.

          **Android** — 7.0 (API 24) or newer, and Bluetooth LE. The app asks for
          the Bluetooth scan/connect permissions on first launch; on Android 11
          and older it asks for location instead, which is what the platform
          required for a BLE scan at the time.

          A Zwift Click v2 must have been unlocked once in the free Zwift app —
          see the README.
          EOF
          sed -i "1i # BikeControl $VERSION\n" release_notes.md
          cat release_notes.md

      - name: Publish Gitea release & upload assets
        env:
          # A PAT is preferred; falls back to the auto-provided token.
          GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
          AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -e
          command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
          VERSION="${{ steps.tag_name.outputs.VERSION }}"
          API="${GITHUB_SERVER_URL}/api/v1"
          REPO="${GITHUB_REPOSITORY}"
          TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
          case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac

          PAYLOAD=$(jq -n \
            --arg tag "$VERSION" \
            --arg name "BikeControl $VERSION" \
            --rawfile body release_notes.md \
            --argjson pre "$PRE" \
            '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')

          echo "📦 Creating release $VERSION on $REPO"
          # Capture the status rather than using -f, so an existing release
          # (409) is handled instead of dropping the assets on the floor.
          HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
            -H "Authorization: token $TOKEN" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD")
          if [ "$HTTP" = "201" ]; then
            RELEASE_ID=$(jq -r '.id' resp.json)
          elif [ "$HTTP" = "409" ]; then
            echo "ℹ️  Release $VERSION already exists; fetching its id to upload assets"
            RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
              -H "Authorization: token $TOKEN" | jq -r '.id')
          else
            echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
          fi

          for f in artifacts/*/*; do
            [ -f "$f" ] || continue
            echo "⬆️  Uploading $(basename "$f")"
            curl -fsS -X POST \
              "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
              -H "Authorization: token $TOKEN" \
              -F "attachment=@$f" >/dev/null
          done
          echo "✅ Release $VERSION published with assets"
