name: OpenAPI Gate - Breaking Change Detection

on:
  pull_request:
    branches:
      - main
    paths:
      - 'src/KArtSell.Host/Features/**/*.cs'
      - 'src/KArtSell.Modules.*/**/*.cs'
      - '.gitea/workflows/openapi-gate.yml'

jobs:
  openapi-diff:
    name: Detect Breaking Changes in OpenAPI Spec
    runs-on: ubuntu-latest

    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '10.x'

      - name: Restore dependencies
        run: dotnet restore

      - name: Build solution
        run: dotnet build -c Release --no-restore

      - name: Generate current OpenAPI spec
        run: |
          mkdir -p /tmp/openapi
          dotnet run --project src/KArtSell.Host -c Release -- \
            --generate-openapi-spec-only \
            --output /tmp/openapi/current.json
          test -s /tmp/openapi/current.json

      - name: Checkout main branch
        run: |
          git fetch origin main:main
          git checkout main

      - name: Load approved baseline OpenAPI spec
        run: |
          test -s docs/api/openapi.json || {
            echo "Approved baseline missing: docs/api/openapi.json"
            echo "Create and approve the baseline before enabling OpenAPI diff comparisons."
            exit 1
          }
          cp docs/api/openapi.json /tmp/openapi/baseline.json
          test -s /tmp/openapi/baseline.json

      - name: Checkout PR branch again
        run: git checkout -

      - name: Analyze OpenAPI diff
        run: |
          # Compare specs and detect breaking changes
          python3 << 'EOF'
          import json
          import sys

          def load_spec(path):
              try:
                  with open(path) as f:
                      return json.load(f)
              except:
                  return {}

          baseline = load_spec('/tmp/openapi/baseline.json')
          current = load_spec('/tmp/openapi/current.json')

          breaking_changes = []

          # Check 1: Required parameter removed
          for path, baseline_ops in baseline.get('paths', {}).items():
              for method, baseline_op in baseline_ops.items():
                  if isinstance(baseline_op, dict):
                      baseline_params = {p['name']: p.get('required', False)
                                        for p in baseline_op.get('parameters', [])}

                      current_ops = current.get('paths', {}).get(path, {})
                      current_op = current_ops.get(method, {})
                      current_params = {p['name']: p.get('required', False)
                                       for p in current_op.get('parameters', [])}

                      for param_name, was_required in baseline_params.items():
                          if was_required and param_name not in current_params:
                              breaking_changes.append(
                                  f"BREAKING: Required parameter '{param_name}' removed from {method.upper()} {path}"
                              )

          # Check 2: Response status code removed
          for path, baseline_ops in baseline.get('paths', {}).items():
              for method, baseline_op in baseline_ops.items():
                  if isinstance(baseline_op, dict):
                      baseline_statuses = set(baseline_op.get('responses', {}).keys())

                      current_ops = current.get('paths', {}).get(path, {})
                      current_op = current_ops.get(method, {})
                      current_statuses = set(current_op.get('responses', {}).keys())

                      for status in ['200', '201', '202', '204']:
                          if status in baseline_statuses and status not in current_statuses:
                              breaking_changes.append(
                                  f"BREAKING: Response status {status} removed from {method.upper()} {path}"
                              )

          # Check 3: Required field removed from response
          for path, baseline_ops in baseline.get('paths', {}).items():
              for method, baseline_op in baseline_ops.items():
                  if isinstance(baseline_op, dict):
                      baseline_schema = baseline_op.get('responses', {}).get('200', {}).get('schema', {})
                      required_fields = set(baseline_schema.get('required', []))

                      current_ops = current.get('paths', {}).get(path, {})
                      current_op = current_ops.get(method, {})
                      current_schema = current_op.get('responses', {}).get('200', {}).get('schema', {})
                      current_fields = set(current_schema.get('properties', {}).keys())

                      for field in required_fields:
                          if field not in current_fields:
                              breaking_changes.append(
                                  f"BREAKING: Required field '{field}' removed from response of {method.upper()} {path}"
                              )

          if breaking_changes:
              print("❌ BREAKING CHANGES DETECTED:\n")
              for change in breaking_changes:
                  print(f"  - {change}")
              print("\n⛔ WORKFLOW HALTED: Cannot merge without approval\n")
              sys.exit(1)
          else:
              print("✅ No breaking changes detected in OpenAPI spec")
              sys.exit(0)
          EOF

      - name: Comment on PR (Breaking Changes)
        if: failure()
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '⛔ **OpenAPI Gate Failed: Breaking Changes Detected**\n\nThis PR introduces breaking changes to the API contract. Required parameters, response fields, or status codes were removed. Modify the changes for backward compatibility or request API Architect approval with rationale, migration plan, and version bump.'
            })

      - name: Comment on PR (All Clear)
        if: success()
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '✅ **OpenAPI Gate Passed: No Breaking Changes**\n\nYour API changes are backward-compatible. Safe to merge.'
            })

  openapi-approval:
    name: Manual Approval Gate (if breaking changes)
    if: failure()
    needs: openapi-diff
    runs-on: ubuntu-latest

    steps:
      - name: Require manual approval
        run: |
          echo "❌ Breaking changes detected. Waiting for @api-architects approval..."
          echo "GitHub PR Review required from 'api-architects' team before merging."
          exit 1

  openapi-specs-update:
    name: Publish OpenAPI Candidate Artifact (manual approval required)
    if: success()
    needs: openapi-diff
    runs-on: ubuntu-latest

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

      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '10.x'

      - name: Generate candidate OpenAPI spec
        run: |
          mkdir -p /tmp/openapi
          dotnet run --project src/KArtSell.Host -c Release -- \
            --generate-openapi-spec-only \
            --output /tmp/openapi/candidate.json
          test -s /tmp/openapi/candidate.json

      - name: Upload candidate for API Architect review
        uses: actions/upload-artifact@v4
        with:
          name: openapi-candidate
          path: /tmp/openapi/candidate.json
          if-no-files-found: error
