name: Promote Master to Production
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]

on:
  workflow_dispatch:
    inputs:
      target_ref:
        description: "Git ref/branch to promote"
        required: true
        default: "master"
      run_backend_tests:
        description: "Run backend pytest gate"
        required: true
        default: "true"
      run_frontend_tests:
        description: "Run frontend unit test gate"
        required: true
        default: "true"
      run_playwright_tests:
        description: "Run Playwright E2E gate"
        required: true
        default: "true"
      skip_tests:
        description: "Skip the entire tests stage"
        required: true
        default: "false"
      deploy:
        description: "Run production deploy over SSH"
        required: true
        default: "true"
      create_tag:
        description: "Create and push git release tag after successful deploy"
        required: true
        default: "true"
      require_manual_approval:
        description: "Require approval token for deploy job"
        required: true
        default: "false"
      approval_token:
        description: "Approval token value when manual approval is required"
        required: false
        default: ""
      rollback_on_failed_healthcheck:
        description: "Auto-rollback if post-deploy health checks fail"
        required: true
        default: "false"

concurrency:
  group: promotion-production
  cancel-in-progress: false

jobs:
  prepare:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      target_ref: ${{ steps.resolve.outputs.target_ref }}
      version: ${{ steps.version.outputs.version }}
      release_tag: ${{ steps.version.outputs.release_tag }}
      commit_sha: ${{ steps.commit.outputs.commit_sha }}
    steps:
      - name: Resolve target ref
        id: resolve
        run: |
          target_ref="${{ github.event.inputs.target_ref }}"
          if [ -z "$target_ref" ]; then
            target_ref="master"
          fi
          echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"

      - name: Check out repository code
        uses: actions/checkout@v3
        with:
          ref: ${{ steps.resolve.outputs.target_ref }}
          fetch-depth: 0

      - name: Resolve version and release tag
        id: version
        run: |
          version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "release_tag=v$version" >> "$GITHUB_OUTPUT"

      - name: Resolve promoted commit SHA
        id: commit
        run: |
          echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      - name: Guard against duplicate release tags
        if: ${{ github.event.inputs.create_tag != 'false' }}
        run: |
          release_tag="${{ steps.version.outputs.release_tag }}"
          if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
            echo "Release tag ${release_tag} already exists on origin; aborting promotion."
            exit 1
          fi

  tests:
    runs-on: ubuntu-latest
    timeout-minutes: 120
    needs: prepare
    steps:
      - name: Skip tests stage
        if: ${{ github.event.inputs.skip_tests == 'true' }}
        run: echo "Skipping tests stage because skip_tests=true"

      - name: Check out promoted ref
        if: ${{ github.event.inputs.skip_tests != 'true' }}
        uses: actions/checkout@v3
        with:
          ref: ${{ needs.prepare.outputs.target_ref }}

      - name: Set up Python for backend tests
        if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"

      - name: Install backend dependencies (runner python)
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
        run: |
          python -m pip install --upgrade pip
          pip install -r backend/requirements.txt

      - name: Run backend unit tests
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
        run: |
          cd backend
          pytest -q

      - name: Set up Node.js
        if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
        uses: actions/setup-node@v4
        with:
          node-version: "20.19.0"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install frontend dependencies
        if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
        run: npm ci
        working-directory: frontend

      - name: Run frontend unit tests
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
        run: npm run test:unit --if-present
        working-directory: frontend

      - name: Create backend venv for Playwright webServer
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
        run: |
          python -m venv backend/.venv
          backend/.venv/bin/python -m pip install --upgrade pip
          backend/.venv/bin/python -m pip install -r backend/requirements.txt

      - name: Install Playwright browsers
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
        run: npx playwright install --with-deps
        working-directory: frontend

      - name: Run Playwright tests
        if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
        run: npx playwright test --reporter=line
        working-directory: frontend
        env:
          CI: "true"
          PLAYWRIGHT_BASE_URL: "https://localhost:5173"
          E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"

  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    needs:
      - prepare
      - tests
    if: ${{ github.event.inputs.deploy != 'false' }}
    steps:
      - name: Validate manual approval token
        if: ${{ github.event.inputs.require_manual_approval != 'false' }}
        run: |
          if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
            echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
            exit 1
          fi
          if [ -z "${{ github.event.inputs.approval_token }}" ]; then
            echo "approval_token input is required when manual approval is enabled."
            exit 1
          fi
          if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
            echo "Approval token does not match PROD_APPROVAL_TOKEN."
            exit 1
          fi

      - name: Deploy to production homeserver
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_PROD_HOST }}
          username: ${{ secrets.DEPLOY_PROD_USER }}
          key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
          port: 22
          script: |
            set -euo pipefail

            repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
            if [ -z "$repo_dir" ]; then
              repo_dir="$HOME/chore"
            fi

            repo_parent=$(dirname "$repo_dir")
            if [ ! -d "$repo_parent" ]; then
              mkdir -p "$repo_parent"
            fi
            if [ ! -w "$repo_parent" ]; then
              echo "Deploy user $(whoami) cannot write to ${repo_parent}."
              echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
              exit 1
            fi

            if [ -d "$repo_dir/.git" ]; then
              cd "$repo_dir"
              git fetch --all --tags
            else
              git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
              cd "$repo_dir"
            fi

            git checkout "${{ needs.prepare.outputs.target_ref }}"
            git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"

            timestamp=$(date +%Y%m%d-%H%M%S)
            mkdir -p backups

            # Capture currently running images for rollback.
            backend_prev_id=""
            frontend_prev_id=""
            if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
              backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
              docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
            fi
            if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
              frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
              docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
            fi

            cat > .rollback-meta << EOF
            BACKEND_PREV_ID=${backend_prev_id}
            FRONTEND_PREV_ID=${frontend_prev_id}
            CREATED_AT=${timestamp}
            PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
            RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
            EOF

            # Backup TinyDB volume before replacement.
            docker run --rm \
              -v chores-app-backend-data:/data \
              -v "$PWD/backups:/backup" \
              alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true

            # Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
            PREV_SECRET_KEY=""
            PREV_DIGEST_TOKEN_SECRET=""
            PREV_VAPID_PUBLIC_KEY=""
            PREV_VAPID_PRIVATE_KEY=""
            PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
            if [ -f .env ]; then
              PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
              PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
              PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
              PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
              PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
            fi

            SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
            DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
            VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
            VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
            REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"

            if [ -z "$SECRET_KEY_VALUE" ]; then
              if [ -n "$PREV_SECRET_KEY" ]; then
                SECRET_KEY_VALUE="$PREV_SECRET_KEY"
              else
                SECRET_KEY_VALUE=$(openssl rand -hex 32)
              fi
            fi

            if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
              if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
                DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
              else
                DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
              fi
            fi

            if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
              VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
            fi
            if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
              VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
            fi

            if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
              echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
              exit 1
            fi

            if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
              if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
                REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
              else
                REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
              fi
            fi

            if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
              echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
              exit 1
            fi

            cat > .env << EOF
            FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
            BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
            FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
            SECRET_KEY=${SECRET_KEY_VALUE}
            REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
            DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
            VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
            VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
            ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
            ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
            ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
            ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
            EOF

            chmod 600 .env .rollback-meta

            backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
            frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
            if [ -z "$backend_host_port" ]; then
              backend_host_port="5001"
            fi
            if [ -z "$frontend_host_port" ]; then
              frontend_host_port="4601"
            fi

            docker-compose down

            wait_for_container_removal() {
              container_name="$1"
              attempts=0
              while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
                attempts=$((attempts + 1))
                if [ "$attempts" -ge 12 ]; then
                  echo "Container ${container_name} still exists after waiting for teardown."
                  docker ps -a --filter "name=^${container_name}$"
                  return 1
                fi
                echo "Waiting for ${container_name} to be removed..."
                sleep 5
              done
            }

            wait_for_port_release() {
              port="$1"
              attempts=0
              while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
                attempts=$((attempts + 1))
                if [ "$attempts" -ge 12 ]; then
                  echo "Port ${port} is still allocated after teardown. Blocking container(s):"
                  docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
                  return 1
                fi
                echo "Waiting for port ${port} to be released..."
                sleep 5
              done
            }

            wait_for_container_removal chores-app-frontend-prod
            wait_for_container_removal chores-app-backend-prod
            wait_for_port_release "$frontend_host_port"
            wait_for_port_release "$backend_host_port"

            docker-compose pull
            docker-compose up -d

            sleep 20

            backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
            frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)

            backend_ok=false
            if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
              backend_ok=true
            fi

            frontend_ok=false
            if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
              frontend_ok=true
            fi

            if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
              echo "Post-deploy health check failed."
              docker-compose ps
              echo "--- Backend logs (last 200 lines) ---"
              docker logs --tail 200 chores-app-backend-prod || true
              echo "--- Frontend logs (last 120 lines) ---"
              docker logs --tail 120 chores-app-frontend-prod || true

              if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
                echo "Attempting rollback using predeploy-backup image tags..."
                docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
                docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
                docker-compose up -d
                sleep 20
                curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
              fi

              exit 1
            fi

            docker-compose ps

  create-release-tag:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    needs:
      - prepare
      - tests
      - deploy
    if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
    steps:
      - name: Check out promoted ref
        uses: actions/checkout@v3
        with:
          ref: ${{ needs.prepare.outputs.target_ref }}
          fetch-depth: 0

      - name: Create and push annotated release tag
        run: |
          tag_name="${{ needs.prepare.outputs.release_tag }}"
          git config user.name "gitea-actions"
          git config user.email "gitea-actions@local"
          git tag -a "$tag_name" -m "Release $tag_name"
          git push origin "$tag_name"

  notify-on-failure:
    runs-on: ubuntu-latest
    needs:
      - prepare
      - tests
      - deploy
      - create-release-tag
    if: ${{ always() }}
    steps:
      - name: Send email when promotion fails
        if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 465
          username: ${{ secrets.MAIL_USER }}
          password: ${{ secrets.MAIL_PASSWORD }}
          secure: true
          to: ${{ secrets.MAIL_TO }}
          from: Gitea <git@git.ryankegel.com>
          subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
          convert_markdown: true
          html_body: |
            ### Production promotion failed

            - Repository: ${{ gitea.repository }}
            - Ref: ${{ needs.prepare.outputs.target_ref }}
            - Commit: ${{ needs.prepare.outputs.commit_sha }}
            - Intended tag: ${{ needs.prepare.outputs.release_tag }}
            - prepare: ${{ needs.prepare.result }}
            - tests: ${{ needs.tests.result }}
            - deploy: ${{ needs.deploy.result }}
            - tag: ${{ needs.create-release-tag.result }}

      - name: Send email when promotion succeeds
        if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 465
          username: ${{ secrets.MAIL_USER }}
          password: ${{ secrets.MAIL_PASSWORD }}
          secure: true
          to: ${{ secrets.MAIL_TO }}
          from: Gitea <git@git.ryankegel.com>
          subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
          convert_markdown: true
          html_body: |
            ### Production promotion succeeded

            - Repository: ${{ gitea.repository }}
            - Ref: ${{ needs.prepare.outputs.target_ref }}
            - Commit: ${{ needs.prepare.outputs.commit_sha }}
            - Release tag: ${{ needs.prepare.outputs.release_tag }}
            - prepare: ${{ needs.prepare.result }}
            - tests: ${{ needs.tests.result }}
            - deploy: ${{ needs.deploy.result }}
            - tag: ${{ needs.create-release-tag.result }}
