name: Reusable Test Workflow

on:
  workflow_call:
    inputs:
      test-type:
        description: 'Type of tests to run (unit, integration, docker, send-message, sqlite)'
        required: true
        type: string
      core-directory:
        description: 'Working directory for commands. Uses . (root) by default.'
        required: false
        type: string
        default: '.'
      install-args:
        description: 'uv sync arguments'
        required: true
        type: string
      test-command:
        description: 'Command to run tests'
        required: false
        type: string
        default: 'uv run --frozen pytest -svv'
      test-path-prefix:
        description: 'Prefix for test path (e.g., tests/)'
        required: false
        type: string
        default: 'tests/'
      timeout-minutes:
        description: 'Timeout in minutes'
        required: false
        type: number
        default: 15
      runner:
        description: 'Runner to use'
        required: false
        type: string
        default: '["self-hosted", "small"]'
      matrix-strategy:
        description: 'JSON string for matrix strategy'
        required: false
        type: string
        default: '{}'
      changed-files-pattern:
        description: 'Pattern for changed files detection'
        required: false
        type: string
        default: |
          **
          .github/workflows/reusable-test-workflow.yml
      skip-fern-generation:
        description: 'Skip Fern SDK generation'
        required: false
        type: boolean
        default: false
      use-docker:
        description: 'Use Docker for tests'
        required: false
        type: boolean
        default: false
      ref:
        description: 'Git ref to wait for checks on'
        required: false
        type: string
        default: ${{ github.sha }}
      use-redis:
        description: 'Use Redis for tests'
        required: false
        type: boolean
        default: false
      is-external-pr:
        description: 'Whether this is an external PR that needs protection'
        required: false
        type: boolean
        default: false

jobs:
  changed-files:
    runs-on: ${{ fromJSON(inputs.runner) }}
    name: changed-files
    outputs:
      all_changed_files: ${{ steps.changed-files.outputs.all_changed_files }}
      any_changed: ${{ steps.changed-files.outputs.any_changed }}
    steps:
      - uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name }}
          ref: ${{ github.event.pull_request.head.ref }}
          fetch-depth: 0
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v46.0.4
        with:
          files: ${{ inputs.changed-files-pattern }}

  cache-check:
    needs: [changed-files]
    runs-on: ${{ fromJSON(inputs.runner) }}
    name: Check cache key
    outputs:
      cache_key: ${{ steps.cache-key.outputs.key }}
      cache_hit: ${{ steps.cache.outputs.cache-hit }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name }}
          ref: ${{ github.event.pull_request.head.ref }}

      - name: Generate cache key
        if: inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))
        id: cache-key
        run: |
          echo "key=sdk-${{ github.ref_name }}-${{ hashFiles('fern/*', 'pyproject.toml') }}" >> $GITHUB_OUTPUT

      - name: Restore SDK cache
        # skip if "skip-fern-generation" is true or if the upstream workflow would've generated an sdk preview (changes to openapi files)
        if: inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))
        id: cache
        uses: actions/cache/restore@v4
        with:
          path: |
            fern/.preview/fern-python-sdk/
          key: ${{ steps.cache-key.outputs.key }}
          fail-on-cache-miss: false

  block-until-sdk-preview-finishes:
    needs: [changed-files, cache-check]
    if: |
      needs.cache-check.outputs.cache_hit != 'true'
    timeout-minutes: ${{ inputs.timeout-minutes }}
    runs-on: ${{ fromJSON(inputs.runner) }}
    name: block-until-sdk-preview-finishes
    steps:
      - name: Debug ref information
        run: |
          echo "Input ref: ${{ inputs.ref }}"
          echo "GitHub SHA: ${{ github.sha }}"
          echo "GitHub ref: ${{ github.ref }}"
          echo "PR head SHA: ${{ github.event.pull_request.head.sha }}"
          echo "Event name: ${{ github.event_name }}"

      - name: Wait for Preview SDK workflow
        if: inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          echo "Waiting for 'preview-python-sdk' check to complete on ref: ${{ inputs.ref }}"

          # Wait for the check to complete with timeout
          timeout_seconds=1800
          interval_seconds=60
          elapsed=0

          while [ $elapsed -lt $timeout_seconds ]; do
            echo "Checking status... (elapsed: ${elapsed}s)"

            # Get check runs using pr checks syntax with branch name or PR number
            if [ "${{ github.event_name }}" = "pull_request" ]; then
              pr_identifier="${{ github.event.pull_request.number }}"
            else
              pr_identifier="${{ github.ref_name }}"
            fi

            check_info=$(gh pr checks "$pr_identifier" -R ${{ github.repository }} --json name,state,startedAt \
              | jq -r '.[] | select(.name == "preview-python-sdk") | [.startedAt, .state] | @tsv' | sort -r | head -1 | cut -f2)

            if [ -n "$check_info" ]; then
              echo "Check state: $check_info"

              if [ "$check_info" = "SUCCESS" ] || [ "$check_info" = "SKIPPED" ]; then
                echo "Check completed with state: $check_info"
                exit 0
              elif [ "$check_info" = "FAILURE" ] || [ "$check_info" = "CANCELLED" ]; then
                echo "❌ Preview Python SDK build failed with state: $check_info"
                echo "🚫 Blocking dependent test jobs to prevent extraneous failures"
                echo "📋 To fix: Check the 'preview-python-sdk' job logs for build errors"
                exit 1
              fi
            else
              echo "Check 'preview-python-sdk' not found yet"
            fi

            sleep $interval_seconds
            elapsed=$((elapsed + interval_seconds))
          done

          echo "Timeout waiting for check to complete"
          exit 1

  test-run:
    needs: [changed-files, block-until-sdk-preview-finishes]
    if: |
      always() &&
      needs.changed-files.outputs.any_changed == 'true' &&
      (needs.block-until-sdk-preview-finishes.result == 'success' ||
       needs.block-until-sdk-preview-finishes.result == 'skipped')

    runs-on: ${{ fromJSON(inputs.runner) }}
    timeout-minutes: ${{ inputs.timeout-minutes }}
    strategy: ${{ fromJSON(inputs.matrix-strategy) }}

    services:
      postgres:
        image: pgvector/pgvector:pg17
        ports:
          # avoids conflict with docker postgres
          - ${{ inputs.use-docker && '9999:5432' || '5432:5432' }}
        env:
          POSTGRES_HOST_AUTH_METHOD: trust
          POSTGRES_DB: postgres
          POSTGRES_USER: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: ${{ inputs.use-redis && 'redis:8-alpine' || '' }}
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name }}
          ref: ${{ github.event.pull_request.head.ref }}

      - name: Install uv
        run: |
          curl -LsSf https://astral.sh/uv/install.sh | sh
          echo "$HOME/.cargo/bin" >> $GITHUB_PATH

      - name: Set core directory
        id: detect-core-dir
        run: |
          echo "dir=${{ inputs.core-directory }}" >> $GITHUB_OUTPUT
          echo "detected=manual" >> $GITHUB_OUTPUT
          echo "Using core directory: $(cat $GITHUB_OUTPUT | grep '^dir=' | cut -d'=' -f2)"

      - name: Generate cache key
        if: inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))
        id: cache-key
        run: |
          echo "key=sdk-${{ github.ref_name }}-${{ hashFiles('fern/*', 'pyproject.toml') }}" >> $GITHUB_OUTPUT

      - name: Restore SDK cache
        # skip if "skip-fern-generation" is true or if the upstream workflow would've generated an sdk preview (changes to openapi files)
        if: inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))
        id: restore-sdk-cache
        uses: actions/cache/restore@v4
        with:
          path: |
            fern/.preview/fern-python-sdk/
          key: ${{ steps.cache-key.outputs.key }}
          fail-on-cache-miss: false

      - name: Check SDK cache availability
        if: (inputs.skip-fern-generation != true || (!contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi.json') && !contains(needs.changed-files.outputs.all_changed_files, 'fern/openapi-overrides.yml'))) && steps.restore-sdk-cache.outputs.cache-hit != 'true'
        run: |
          echo "❌ Preview Python SDK cache expired or missing!"
          echo "📦 Cache key: ${{ steps.cache-key.outputs.key }}"
          echo "🔄 To fix: Re-run the 'preview-python-sdk' workflow job to regenerate the SDK"
          echo "💡 This can happen when:"
          echo "   - The cache entry has expired"
          echo "   - Dependencies in fern/* or pyproject.toml have changed"
          echo "   - The preview-python-sdk job hasn't run successfully for this branch/commit"
          exit 1

      - name: Install dependencies with retry
        shell: bash
        working-directory: .
        run: |
          uv sync --no-install-project ${{ inputs.install-args }}

      - name: Install custom SDK
        if: inputs.skip-fern-generation != true
        working-directory: .
        run: |
          echo "Fixing Fern SDK pyproject.toml for uv compatibility..."
          SDK_PYPROJECT="fern/.preview/fern-python-sdk/pyproject.toml"
          VERSION=$(grep -A 10 '^\[tool\.poetry\]' "$SDK_PYPROJECT" | grep '^version' | head -1 | cut -d'"' -f2)
          head -n 2 < fern/.preview/fern-python-sdk/pyproject.toml > fern/.preview/fern-python-sdk/pyproject.toml.tmp
          echo "version = \"$VERSION\"" >> fern/.preview/fern-python-sdk/pyproject.toml.tmp
          tail -n +3 fern/.preview/fern-python-sdk/pyproject.toml >> fern/.preview/fern-python-sdk/pyproject.toml.tmp
          mv fern/.preview/fern-python-sdk/pyproject.toml.tmp fern/.preview/fern-python-sdk/pyproject.toml

          uv pip install -e fern/.preview/fern-python-sdk/.
      - name: Migrate database
        if: inputs.use-docker != true && inputs.test-type != 'sqlite'
        working-directory: .
        env:
          LETTA_PG_PORT: 5432
          LETTA_PG_USER: postgres
          LETTA_PG_PASSWORD: postgres
          LETTA_PG_DB: postgres
          LETTA_PG_HOST: localhost
        run: |
          psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION vector'
          uv run alembic upgrade head
      - name: Inject env vars into environment
        if: inputs.is-external-pr != true
        working-directory: .
        run: |
          # Get secrets and mask them before adding to environment
          while IFS= read -r line || [[ -n "$line" ]]; do
            if [[ -n "$line" ]]; then
              value=$(echo "$line" | cut -d= -f2-)
              echo "::add-mask::$value"
              echo "$line" >> $GITHUB_ENV
            fi
          done < <(letta_secrets_helper --env dev --service ci)

      - name: Docker setup for Docker tests
        if: inputs.use-docker
        run: |
          mkdir -p /home/ci-runner/.letta/logs
          sudo chown -R $USER:$USER /home/ci-runner/.letta/logs
          chmod -R 755 /home/ci-runner/.letta/logs

      - name: Build and run docker dev server
        if: inputs.use-docker && inputs.is-external-pr != true
        env:
          LETTA_PG_DB: letta
          LETTA_PG_USER: letta
          LETTA_PG_PASSWORD: letta
          LETTA_PG_PORT: 5432
          OPENAI_API_KEY: ${{ env.OPENAI_API_KEY }}
        run: |
          cd libs/config-core-deploy
          docker compose -f compose.yaml up --build -d

      - name: Wait for Docker service
        if: inputs.use-docker
        working-directory: ${{ steps.detect-core-dir.outputs.dir }}
        run: |
          bash scripts/wait_for_service.sh localhost:8083 -- echo "Service is ready"

      - name: Run tests
        if: inputs.is-external-pr != true
        working-directory: ${{ steps.detect-core-dir.outputs.dir }}
        env:
          # Database configuration (shared, but values depend on Docker usage)
          LETTA_PG_PORT: 5432
          LETTA_PG_USER: ${{ inputs.use-docker && 'letta' || 'postgres' }}
          LETTA_PG_PASSWORD: ${{ inputs.use-docker && 'letta' || 'postgres' }}
          LETTA_PG_DB: ${{ inputs.use-docker && 'letta' || 'postgres' }}
          LETTA_PG_HOST: localhost

          # Server configuration (conditional)
          LETTA_SERVER_PASS: test_server_token

          # LLM Provider API Keys (shared across all test types)
          OPENAI_API_KEY: ${{ env.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ env.ANTHROPIC_API_KEY }}
          GEMINI_API_KEY: ${{ env.GEMINI_API_KEY }}
          GROQ_API_KEY: ${{ env.GROQ_API_KEY }}
          AZURE_API_KEY: ${{ env.AZURE_API_KEY }}
          AZURE_BASE_URL: ${{ secrets.AZURE_BASE_URL }}
          DEEPSEEK_API_KEY: ${{ env.DEEPSEEK_API_KEY }}
          LETTA_MISTRAL_API_KEY: ${{ secrets.LETTA_MISTRAL_API_KEY }}

          # External service API Keys (shared across all test types)
          E2B_API_KEY: ${{ env.E2B_API_KEY }}
          E2B_SANDBOX_TEMPLATE_ID: ${{ env.E2B_SANDBOX_TEMPLATE_ID }}

          # Turbopuffer flags
          LETTA_USE_TPUF: true
          LETTA_TPUF_API_KEY: ${{ env.LETTA_TPUF_API_KEY }}

          # Encryption key
          LETTA_ENCRYPTION_KEY: ${{ env.LETTA_ENCRYPTION_KEY }}

          # Google Cloud (shared across all test types)
          GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }}
          GOOGLE_CLOUD_LOCATION: ${{ secrets.GOOGLE_CLOUD_LOCATION }}

          # Real object store (required for git-backed memory integration test)
          # Use DEV bucket/prefix variable to avoid prod resources.
          LETTA_OBJECT_STORE_URI: ${{ vars.LETTA_OBJECT_STORE_URI_DEV }}

          # Feature flags (shared across all test types)
          LETTA_ENABLE_BATCH_JOB_POLLING: true

          # Gemini flags
          GEMINI_FORCE_MINIMUM_THINKING_BUDGET: true
          GEMINI_MAX_RETRIES: 10

          # Pinecone flags
          LETTA_PINECONE_API_KEY: ${{ secrets.LETTA_PINECONE_API_KEY }}
          LETTA_ENABLE_PINECONE: true

          EXA_API_KEY: ${{ env.EXA_API_KEY }}

          # Docker-specific environment variables
          PYTHONPATH: ${{ inputs.use-docker && format('{0}:{1}', github.workspace, env.PYTHONPATH) || '' }}

          LETTA_REDIS_HOST: localhost
        run: |
          set -o xtrace

          # Set LETTA_SERVER_URL only for Docker tests
          if [[ "${{ inputs.use-docker }}" == "true" ]]; then
            export LETTA_SERVER_URL="http://localhost:8083"
          fi

          # Set LLM_CONFIG_FILE only for send-message tests
          if [[ "${{ inputs.test-type }}" == "send-message" ]]; then
            export LLM_CONFIG_FILE="${{ matrix.config_file }}"
          fi

          # Set Ollama base URL only for Ollama tests
          if [[ "${{ inputs.test-type }}" == "integration" && "${{ inputs.runner }}" == *"ollama"* ]]; then
            export LLM_CONFIG_FILE="ollama.json"
            export OLLAMA_BASE_URL="http://localhost:11434"
          fi

          # Set LMStudio base URL only for LMStudio tests
          if [[ "${{ inputs.test-type }}" == "integration" && "${{ inputs.runner }}" == *"lmstudio"* ]]; then
            export LLM_CONFIG_FILE="lmstudio.json"
            export LMSTUDIO_BASE_URL="http://localhost:1234"
          fi

          # Set VLLM base URL only for VLLM tests
          if [[ "${{ inputs.test-type }}" == "integration" && "${{ inputs.runner }}" == *"vllm"* ]]; then
            export LLM_CONFIG_FILE="vllm.json"
            export VLLM_BASE_URL="http://localhost:8000"
          fi

          uv pip install pytest-github-actions-annotate-failures

          # Handle different matrix variable names and test commands based on test type
          if [[ "${{ inputs.test-type }}" == "integration" ]]; then
            uv pip install letta
            uv pip show letta
            uv pip show letta-client
            uv run --frozen pytest -svv ${{ inputs.test-path-prefix }}${{ matrix.test_suite }}
          elif [[ "${{ inputs.test-type }}" == "unit" ]]; then
            uv pip show letta-client
            uv run --frozen pytest -svv ${{ inputs.test-path-prefix }}${{ matrix.test_suite }}
          elif [[ "${{ inputs.test-type }}" == "send-message" ]]; then
            uv run --frozen  pytest -s -vv tests/integration_test_send_message.py --maxfail=1 --durations=10
          elif [[ "${{ inputs.test-type }}" == "docker" ]]; then
            uv run --frozen pytest -s tests/test_client.py
          elif [[ "${{ inputs.test-type }}" == "sqlite" ]]; then
            # force sqlite
            unset LETTA_PG_USER
            unset LETTA_PG_PASSWORD
            unset LETTA_PG_DB
            unset LETTA_PG_HOST
            uv pip show letta-client
            uv run alembic upgrade head
            uv run --frozen pytest -svv ${{ inputs.test-path-prefix }}${{ matrix.test_suite }}
          else
            ${{ inputs.test-command }}
          fi

      - name: Remove sqlite db
        if: ${{ always() && inputs.test-type == 'sqlite' }}
        run: sudo rm -rf ~/.letta || true

      - name: Print docker logs if tests fail
        if: ${{ (failure() || cancelled()) && inputs.use-docker }}
        working-directory: libs/config-core-deploy
        run: |
          echo "Printing Docker Logs..."
          docker compose -f compose.yaml logs

      - name: Stop docker
        if: ${{ always() && inputs.use-docker }}
        working-directory: libs/config-core-deploy
        run: |
          docker compose -f compose.yaml down --volumes
          sudo rm -rf .persist
