name: Tests

on:
  pull_request:
  push:
    branches:
      - master
      - beta
      - canary
      - internal

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  php:
    name: PHP ${{ matrix.suite }} (required)
    runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
    strategy:
      fail-fast: false
      matrix:
        suite: [unit, integration, api, legacy]
    env:
      COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Ensure Docker access
        run: |
          set -euo pipefail
          if docker ps >/dev/null 2>&1; then
            exit 0
          fi
          test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
          if command -v sudo >/dev/null 2>&1; then
            sudo -n chmod 666 /var/run/docker.sock
          else
            chmod 666 /var/run/docker.sock
          fi
          docker ps >/dev/null

      - name: Setup Node.js
        if: ${{ matrix.suite == 'unit' }}
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Check AI workflow sync
        if: ${{ matrix.suite == 'unit' }}
        run: node scripts/sync-ai-workflow.mjs --check

      - name: Run PHP ${{ matrix.suite }} suite
        run: bash scripts/php-ci-test.sh ${{ matrix.suite }}

      - name: Upload PHP suite logs
        if: ${{ failure() }}
        continue-on-error: true
        uses: actions/upload-artifact@v4
        with:
          name: php-${{ matrix.suite }}-logs
          path: .tmp/ci-logs/${{ matrix.suite }}
          if-no-files-found: warn
          retention-days: 3

  edge-agent:
    name: Edge Agent (required)
    runs-on: [self-hosted, Linux, X64, pleno, backend]

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install native build tools
        run: |
          set -euo pipefail
          if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then
            exit 0
          fi

          if ! command -v apt-get >/dev/null 2>&1; then
            echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2
            exit 1
          fi

          apt_cmd=(apt-get)
          if [ "$(id -u)" -ne 0 ]; then
            if ! command -v sudo >/dev/null 2>&1; then
              echo "make and g++ are missing, and sudo is not available to install them." >&2
              exit 1
            fi
            apt_cmd=(sudo apt-get)
          fi

          "${apt_cmd[@]}" update
          "${apt_cmd[@]}" install -y --no-install-recommends build-essential python3

      - name: Install dependencies
        working-directory: services/edge-agent
        run: npm ci

      - name: Run edge agent tests
        working-directory: services/edge-agent
        run: npm test

  edge-broker:
    name: Edge Broker (required)
    runs-on: [self-hosted, Linux, X64, pleno, backend, docker]

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Ensure Docker access
        run: |
          set -euo pipefail
          if docker ps >/dev/null 2>&1; then
            exit 0
          fi
          test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
          if command -v sudo >/dev/null 2>&1; then
            sudo -n chmod 666 /var/run/docker.sock
          else
            chmod 666 /var/run/docker.sock
          fi
          docker ps >/dev/null

      - name: Materialize CI compose env files
        run: |
          set -euo pipefail
          cp .github/ci.env .env
          cp .github/ci.env.staging .env.staging

      - name: Validate compose contracts
        run: |
          docker compose -f docker-compose.yml -f docker-compose.prod.yml config > /dev/null
          docker compose -f docker-compose.example.yml config > /dev/null

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install dependencies
        working-directory: services/edge-broker
        run: npm ci

      - name: Run edge broker tests
        working-directory: services/edge-broker
        run: npm test

  edge-gateway-backend:
    name: Edge Gateway Backend (required)
    runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
    env:
      COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
      COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
      COMPOSE_PROFILES: dev
      TRAEFIK_WEB_PORT: "18080"
      TRAEFIK_WEBSECURE_PORT: "18443"
      TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
      TRAEFIK_METRICS_PORT: "19100"
      EDGE_BROKER_CI_PORT: "14300"
      EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Ensure Docker access
        run: |
          set -euo pipefail
          if docker ps >/dev/null 2>&1; then
            exit 0
          fi
          test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
          if command -v sudo >/dev/null 2>&1; then
            sudo -n chmod 666 /var/run/docker.sock
          else
            chmod 666 /var/run/docker.sock
          fi
          docker ps >/dev/null

      - name: Allocate CI ports
        run: |
          set -euo pipefail
          find_free_port() {
            start="$1"
            end="$2"
            port="$start"
            while [ "$port" -le "$end" ]; do
              if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
                echo "$port"
                return 0
              fi
              port=$((port + 1))
            done

            echo "No free port in range ${start}-${end}." >&2
            exit 1
          }

          base=$((20000 + (GITHUB_RUN_ID % 20000)))
          web_port="$(find_free_port "$base" "$((base + 2000))")"
          websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
          staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
          metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
          broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
          checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
          subnet_second=$((64 + ((checksum / 256) % 64)))
          subnet_third=$((checksum % 256))
          ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"

          {
            echo "TRAEFIK_WEB_PORT=${web_port}"
            echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
            echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
            echo "TRAEFIK_METRICS_PORT=${metrics_port}"
            echo "EDGE_BROKER_CI_PORT=${broker_port}"
            echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
            echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
            echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
          } >> "$GITHUB_ENV"

      - name: Materialize CI compose env files
        run: |
          set -euo pipefail
          cp .github/ci.env .env
          cp .github/ci.env.staging .env.staging
          printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Boot local stack
        run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy

      - name: Sync PHP app checkout
        run: >
          tar
          --exclude='./vendor'
          --exclude='./.phpunit.cache'
          --exclude='./build/logs'
          -C services/nginx/app -cf - .
          | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -

      - name: Resolve dependencies
        run: |
          set -euo pipefail
          composer_install() {
            install_mode="$1"
            max_attempts="$2"
            attempt=1
            while :; do
              if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
                return 0
              fi
              if [ "$attempt" -ge "$max_attempts" ]; then
                return 1
              fi
              sleep_seconds=$((attempt * 5))
              echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
              sleep "$sleep_seconds"
              attempt=$((attempt + 1))
            done
          }

          composer_install --prefer-dist 3 || {
            echo "Composer dist install failed; retrying with --prefer-source." >&2
            composer_install --prefer-source 2
          }

      - name: Verify edge gateway test files
        run: >
          docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
          "cd /var/www/html &&
          php -r '\$composer = json_decode(file_get_contents(\"composer.json\"), true); echo \"Composer scripts: \", implode(\",\", array_keys(\$composer[\"scripts\"] ?? [])), PHP_EOL;' &&
          find tests/Api -maxdepth 1 -type f -name 'EdgeGateway*ApiTest.php' -print &&
          test -f tests/Api/EdgeGatewayAgentApiTest.php &&
          test -f tests/Api/EdgeGatewayBrokerApiTest.php &&
          test -f tests/Api/EdgeGatewayOperatorApiTest.php"

      - name: Run edge gateway API tests
        run: >
          docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
          "cd /var/www/html &&
          RUN_API_TESTS=1
          API_TEST_BOOTSTRAP_SCHEMA=1
          API_TEST_ALLOW_LIVE_DB=1
          CONFIG_DB_TARGET=debug
          CONFIG_DB_HOST=mysql-debug
          CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
          CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
          CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
          CONFIG_DB_PORT=3306
          CONFIG_DB_DEBUG_HOST=mysql-debug
          CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
          CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
          CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
          CONFIG_DB_DEBUG_PORT=3306
          API_TEST_REQUEST_TIMEOUT=180
          EDGE_GATEWAY_VIEW_CACHE_TTL=0
          EDGE_BROKER_URL=
          vendor/bin/pest
          tests/Api/EdgeGatewayAgentApiTest.php
          tests/Api/EdgeGatewayBrokerApiTest.php
          tests/Api/EdgeGatewayOperatorApiTest.php
          --colors=always"

      - name: Run edge gateway integration tests
        run: >
          docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
          "cd /var/www/html &&
          RUN_INTEGRATION_TESTS=1
          CONFIG_DB_TARGET=debug
          CONFIG_DB_HOST=mysql-debug
          CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
          CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
          CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
          CONFIG_DB_PORT=3306
          CONFIG_DB_DEBUG_HOST=mysql-debug
          CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
          CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
          CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
          CONFIG_DB_DEBUG_PORT=3306
          EDGE_BROKER_URL=
          vendor/bin/pest tests/Integration/EdgeGateway --colors=always"

      - name: Run edge gateway E2E smoke
        env:
          EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
          EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
        run: node scripts/edge-gateway-e2e.mjs

      - name: Tear down local stack
        if: always()
        run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v

  required-ci:
    name: Required CI
    runs-on: ubuntu-latest
    needs: [php, edge-agent, edge-broker, edge-gateway-backend]
    if: ${{ always() }}

    steps:
      - name: Verify required jobs succeeded
        env:
          PHP_RESULT: ${{ needs.php.result }}
          EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
          EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
          EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
        run: |
          set -euo pipefail
          failed=0
          for dependency in \
            "php=${PHP_RESULT}" \
            "edge-agent=${EDGE_AGENT_RESULT}" \
            "edge-broker=${EDGE_BROKER_RESULT}" \
            "edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
          do
            name="${dependency%%=*}"
            result="${dependency#*=}"
            if [ "$result" != "success" ]; then
              echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
              failed=1
            fi
          done
          test "$failed" -eq 0

  release-manager-gate:
    name: Release Manager gate
    runs-on: [self-hosted, Linux, X64, pleno, backend]
    needs: [required-ci]
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}

    steps:
      - name: Record Release Manager API gate
        run: |
          set -euo pipefail
          test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
          response_file="$(mktemp)"
          http_code="$(curl --show-error --silent \
            --connect-timeout 10 \
            --retry 5 \
            --retry-all-errors \
            --retry-delay 15 \
            --retry-max-time 300 \
            -o "$response_file" \
            -w '%{http_code}' \
            -X POST "$RELEASE_MANAGER_GATE_URL" \
            -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
            -H "Content-Type: application/json" \
            --data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
          response_body="$(cat "$response_file")"
          rm -f "$response_file"

          if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
            printf '%s\n' "$response_body"
            exit 0
          fi

          if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
            echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
            printf '%s\n' "$response_body"
            exit 0
          fi

          printf '%s\n' "$response_body"
          echo "Release Manager gate failed with HTTP $http_code." >&2
          exit 1
        env:
          RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
          RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
          RELEASE_REPOSITORY: ${{ github.repository }}
          RELEASE_BRANCH: ${{ github.ref_name }}
          RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
          RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
