name: Unity Build

on:
  push:
    branches: [ "main" ]
    paths:
      - ".github/workflows/unity_build.yml"
      - "src/main/csharp/net/eagle0/clients/unity/**"
      - "src/main/protobuf/net/eagle0/common/**"
      - "src/main/protobuf/net/eagle0/shardok/**"
      - "src/main/protobuf/net/eagle0/eagle/api/**"
      - "src/main/protobuf/net/eagle0/eagle/common/**"
      - "src/main/protobuf/net/eagle0/eagle/views/**"
      - "scripts/build_protos.sh"
      - "scripts/build_plugins.sh"
      - "scripts/build_windows_plugin.sh"
      - "scripts/sync_bazel_xcode.sh"
      - "ci/github_actions/build_unity.sh"
      - "ci/github_actions/test_unity_editmode.sh"
      - "ci/github_actions/ensure_bazel_installed.sh"
      - ".github/actions/setup-bazel/**"
      - "ci/github_actions/detect_addressables_changes.sh"
      - "ci/github_actions/upload_addressables.sh"
      - "ci/github_actions/ensure_unity_installed.sh"
      - "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
      - "MODULE.bazel"
      - "MODULE.bazel.lock"
      - ".bazelrc"
      - "WORKSPACE"
  workflow_dispatch:
  pull_request:
    paths:
      - ".github/workflows/unity_build.yml"
      - "src/main/csharp/net/eagle0/clients/unity/**"
      - "src/main/protobuf/net/eagle0/common/**"
      - "src/main/protobuf/net/eagle0/shardok/**"
      - "src/main/protobuf/net/eagle0/eagle/api/**"
      - "src/main/protobuf/net/eagle0/eagle/common/**"
      - "src/main/protobuf/net/eagle0/eagle/views/**"
      - "scripts/build_protos.sh"
      - "scripts/build_plugins.sh"
      - "scripts/build_windows_plugin.sh"
      - "scripts/sync_bazel_xcode.sh"
      - "ci/github_actions/build_unity.sh"
      - "ci/github_actions/test_unity_editmode.sh"
      - "ci/github_actions/ensure_bazel_installed.sh"
      - ".github/actions/setup-bazel/**"
      - "ci/github_actions/detect_addressables_changes.sh"
      - "ci/github_actions/upload_addressables.sh"
      - "ci/github_actions/ensure_unity_installed.sh"
      - "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
      - "MODULE.bazel"
      - "MODULE.bazel.lock"
      - ".bazelrc"
      - "WORKSPACE"

permissions:
  actions: write
  contents: read

env:
  # Runner-specific build directory to allow parallel builds on multiple runners
  EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}-${{ github.job }}

jobs:
  unity-editmode-tests:
    runs-on: [self-hosted, macOS, unity-windows]
    concurrency:
      group: ${{ github.workflow }}-tests-${{ github.head_ref || github.ref }}
      cancel-in-progress: ${{ github.event_name == 'pull_request' }}

    steps:
      - name: Prune stale PR refs
        run: |
          # Self-hosted runners persist .git between runs. When a PR is updated,
          # old local refs (refs/remotes/pull/*/merge) may point to commits whose
          # objects were never fetched or have been pruned. Remove these stale refs
          # before checkout to prevent "missing object" errors.
          if [ -d ".git" ]; then
            echo "Pruning stale PR refs..."
            git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
              xargs -r git update-ref -d 2>/dev/null || true
          fi

      - name: Ensure Git LFS available for checkout
        run: |
          COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
          export PATH="${COMMON_PATHS}:${PATH}"
          for path in /opt/homebrew/bin /usr/local/bin; do
            if [ -d "$path" ]; then
              echo "$path" >> "$GITHUB_PATH"
            fi
          done
          if ! command -v git-lfs >/dev/null 2>&1; then
            brew install git-lfs
          fi
          git-lfs --version

      - uses: actions/checkout@v7
        env:
          GIT_LFS_SKIP_SMUDGE: 1
        with:
          persist-credentials: false
          lfs: false  # Fetch LFS after checkout to avoid stale ref issues
          clean: false  # Library/ persists between runs on self-hosted runners
          fetch-depth: 2

      - name: Clean stale files
        run: |
          git clean -ffd
          UNITY_ROOT="src/main/csharp/net/eagle0/clients/unity/eagle0"
          # These ignored proto outputs are mutually exclusive across proto-generation
          # layouts. Clean both before regenerating to avoid stale DLL/source duplicates
          # when a self-hosted runner switches between main and PR branches.
          rm -rf "$UNITY_ROOT/Assets/GeneratedProtos"
          rm -rf "$UNITY_ROOT/Assets/Plugins/Eagle0Protos"
          LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
          VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
          PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
          CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')

          # Nuke Library/ when Unity version changes to avoid import loops
          if [ -f "$VERSION_CACHE" ]; then
            CACHED_VERSION=$(cat "$VERSION_CACHE")
            if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
              echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
              rm -rf "$LIBRARY_DIR"
            fi
          fi

          # Only clear Bee/ when C# files were added/deleted/renamed (structural
          # changes that stale the DAG). Content-only modifications are handled by
          # Bee's incremental compilation. See persist_library.sh for background.
          BEE_DIR="$LIBRARY_DIR/Bee"
          SHA_FILE="$LIBRARY_DIR/.last_built_sha"
          if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
            LAST_SHA=$(cat "$SHA_FILE")
            if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
              echo "C# files added/deleted/renamed since $LAST_SHA -- clearing Bee/"
              rm -rf "$BEE_DIR"
            else
              echo "No structural C# changes since $LAST_SHA -- keeping Bee/"
            fi
          else
            echo "No previous build SHA or no Bee/ -- clearing Bee/ as safe default"
            rm -rf "$BEE_DIR"
          fi

      - name: Fetch LFS files
        env:
          GITHUB_TOKEN: ${{ github.token }}
        run: ./ci/github_actions/fetch_lfs.sh
      - name: Ensure Unity version installed
        run: ./ci/github_actions/ensure_unity_installed.sh windows
      - name: Ensure .NET SDK installed
        uses: actions/setup-dotnet@v5
        with:
          dotnet-version: '8.0.x'
      - name: Ensure Bazel installed
        uses: ./.github/actions/setup-bazel
      - name: Sync Bazel Xcode configuration
        run: ./scripts/sync_bazel_xcode.sh

      - name: Run Unity EditMode tests
        run: ./ci/github_actions/test_unity_editmode.sh

      - name: Archive EditMode test artifacts
        if: success() || failure()
        uses: actions/upload-artifact@v7
        with:
          name: unity-editmode-tests
          path: |
            ${{ env.EAGLE0_BUILD_DIR }}/editor_editmode_tests.log
            ${{ env.EAGLE0_BUILD_DIR }}/editmode-test-results.xml
          retention-days: 3
      - name: Cleanup build directory
        if: always()
        run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"

  windows-unity:
    runs-on: [self-hosted, macOS, unity-windows]
    outputs:
      addressables_changed: ${{ steps.addressables.outputs.changed }}
      deployed_version: ${{ steps.get-version.outputs.deployed_version }}
    concurrency:
      group: ${{ github.workflow }}-build-${{ github.head_ref || github.ref }}
      cancel-in-progress: ${{ github.event_name == 'pull_request' }}

    steps:
      - name: Prune stale PR refs
        run: |
          # Self-hosted runners persist .git between runs. When a PR is updated,
          # old local refs (refs/remotes/pull/*/merge) may point to commits whose
          # objects were never fetched or have been pruned. Remove these stale refs
          # before checkout to prevent "missing object" errors.
          if [ -d ".git" ]; then
            echo "Pruning stale PR refs..."
            git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
              xargs -r git update-ref -d 2>/dev/null || true
          fi

      - name: Ensure Git LFS available for checkout
        run: |
          COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
          export PATH="${COMMON_PATHS}:${PATH}"
          for path in /opt/homebrew/bin /usr/local/bin; do
            if [ -d "$path" ]; then
              echo "$path" >> "$GITHUB_PATH"
            fi
          done
          if ! command -v git-lfs >/dev/null 2>&1; then
            brew install git-lfs
          fi
          git-lfs --version

      - uses: actions/checkout@v7
        env:
          GIT_LFS_SKIP_SMUDGE: 1
        with:
          persist-credentials: false
          lfs: false  # Fetch LFS after checkout to avoid stale ref issues
          clean: false  # Library/ persists between runs on self-hosted runners
          fetch-depth: 2  # Keep the previous main commit available for Addressables change detection

      - name: Clean stale files
        run: |
          git clean -ffd
          UNITY_ROOT="src/main/csharp/net/eagle0/clients/unity/eagle0"
          # These ignored proto outputs are mutually exclusive across proto-generation
          # layouts. Clean both before regenerating to avoid stale DLL/source duplicates
          # when a self-hosted runner switches between main and PR branches.
          rm -rf "$UNITY_ROOT/Assets/GeneratedProtos"
          rm -rf "$UNITY_ROOT/Assets/Plugins/Eagle0Protos"
          LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
          VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
          PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
          CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')

          # Nuke Library/ when Unity version changes to avoid import loops
          if [ -f "$VERSION_CACHE" ]; then
            CACHED_VERSION=$(cat "$VERSION_CACHE")
            if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
              echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
              rm -rf "$LIBRARY_DIR"
            fi
          fi

          # Only clear Bee/ when C# files were added/deleted/renamed (structural
          # changes that stale the DAG). Content-only modifications are handled by
          # Bee's incremental compilation. See persist_library.sh for background.
          BEE_DIR="$LIBRARY_DIR/Bee"
          SHA_FILE="$LIBRARY_DIR/.last_built_sha"
          if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
            LAST_SHA=$(cat "$SHA_FILE")
            if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
              echo "C# files added/deleted/renamed since $LAST_SHA -- clearing Bee/"
              rm -rf "$BEE_DIR"
            else
              echo "No structural C# changes since $LAST_SHA -- keeping Bee/"
            fi
          else
            echo "No previous build SHA or no Bee/ -- clearing Bee/ as safe default"
            rm -rf "$BEE_DIR"
          fi

      - name: Fetch LFS files
        env:
          GITHUB_TOKEN: ${{ github.token }}
        run: ./ci/github_actions/fetch_lfs.sh
      - name: Ensure Unity version installed
        run: ./ci/github_actions/ensure_unity_installed.sh windows
      - name: Ensure .NET SDK installed
        uses: actions/setup-dotnet@v5
        with:
          dotnet-version: '8.0.x'
      - name: Ensure Bazel installed
        uses: ./.github/actions/setup-bazel
      - name: Sync Bazel Xcode configuration
        run: ./scripts/sync_bazel_xcode.sh

      - name: Detect Addressables changes
        id: addressables
        if: success()
        shell: bash
        run: |
          set -euo pipefail

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

          ./ci/github_actions/detect_addressables_changes.sh "$BASE_SHA" "${{ github.sha }}"
      - name: Build Windows unity
        run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" true "${{ steps.addressables.outputs.changed }}"

      - name: Save build SHA for Bee/ cache invalidation
        if: success()
        run: |
          mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
          git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha

      - name: Save Unity version for Library/ cache invalidation
        if: success()
        run: |
          mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
          grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version

      - name: Check if run is still latest main for Addressables
        id: check-addressables-latest
        if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.addressables.outputs.changed == 'true'
        run: |
          LATEST_SHA=$(curl -fsSL \
            -H "Authorization: Bearer ${{ github.token }}" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${{ github.repository }}/commits/main" \
            | python3 -c 'import json, sys; print(json.load(sys.stdin)["sha"])')
          if [[ "$LATEST_SHA" != "${{ github.sha }}" ]]; then
            echo "Skipping Addressables upload because ${{ github.sha }} is no longer latest main ($LATEST_SHA is latest)"
            echo "should_upload=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "should_upload=true" >> "$GITHUB_OUTPUT"

      - name: Wait for EditMode tests before publishing
        if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          deadline=$((SECONDS + 600))
          while [ "$SECONDS" -lt "$deadline" ]; do
            result=$(curl -fsSL \
              -H "Authorization: Bearer $GH_TOKEN" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
              | python3 -c 'import json, sys; job = next(j for j in json.load(sys.stdin)["jobs"] if j["name"] == "unity-editmode-tests"); print("{} {}".format(job["status"], job["conclusion"] or ""))')
            status="${result%% *}"
            conclusion="${result#* }"
            if [ "$status" = "completed" ]; then
              if [ "$conclusion" = "success" ]; then
                echo "EditMode tests passed; publishing may continue."
                exit 0
              fi
              echo "EditMode tests completed with conclusion: $conclusion"
              exit 1
            fi
            echo "EditMode tests are still $status; waiting..."
            sleep 5
          done
          echo "Timed out waiting for EditMode tests."
          exit 1

      - name: Check if run is still latest main for publish
        id: check-publish-latest
        if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
        run: |
          LATEST_SHA=$(curl -fsSL \
            -H "Authorization: Bearer ${{ github.token }}" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${{ github.repository }}/commits/main" \
            | python3 -c 'import json, sys; print(json.load(sys.stdin)["sha"])')
          if [[ "$LATEST_SHA" != "${{ github.sha }}" ]]; then
            echo "Skipping publish because ${{ github.sha }} is no longer latest main ($LATEST_SHA is latest)"
            echo "should_publish=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "should_publish=true" >> "$GITHUB_OUTPUT"

      - name: Stage Windows blobs for publish
        if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
        env:
          ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
        run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- --skip-previous-manifest "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt"

      - name: Upload Windows Addressables to CDN
        if: success() && steps.check-publish-latest.outputs.should_publish == 'true' && steps.check-addressables-latest.outputs.should_upload == 'true'
        env:
          ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
        run: ./ci/github_actions/upload_addressables.sh StandaloneWindows64

      - name: Publish previous Windows manifest
        if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
        env:
          ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
        run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- --publish-previous-manifest "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt"

      - name: Update unified manifest
        if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
        env:
          ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
          MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
        run: |
          # Write signing key to temp file (if available)
          SIGNING_ARGS=""
          if [ -n "$MANIFEST_SIGNING_KEY" ]; then
            echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
            chmod 600 /tmp/manifest_signing_key
            SIGNING_ARGS="/tmp/manifest_signing_key"
            echo "Manifest signing key available"
          else
            echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
          fi

          # Update the v2 manifest at installer/v2/eagle0_manifest.txt
          bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d-v2 "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt" $SIGNING_ARGS

          # Cleanup
          rm -f /tmp/manifest_signing_key

      - name: Export deployed version
        id: get-version
        if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
        run: |
          VERSION=$(grep "^version=" "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt" | cut -d= -f2 || date +%Y.%m.%d)
          echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT

      - name: Archive build log
        if: success() || failure()
        uses: actions/upload-artifact@v7
        with:
          name: editor_win.log
          path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
          retention-days: 3
      - name: Archive Addressables build reports
        if: (success() || failure()) && steps.addressables.outputs.changed == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: windows-addressables-build-reports
          path: src/main/csharp/net/eagle0/clients/unity/eagle0/Library/com.unity.addressables/BuildReports
          if-no-files-found: ignore
          retention-days: 3
      - name: Cleanup build directory
        if: always()
        run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"

  notify-windows:
    needs: windows-unity
    if: needs.windows-unity.outputs.deployed_version != ''
    runs-on: ubuntu-latest
    steps:
      - name: Notify clients of update
        env:
          NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
        run: |
          curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
            -H "X-Notify-Secret: $NOTIFY_SECRET" \
            --fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
