name: CI & Deploy

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  workflows-lint:
    name: Workflows lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

      - name: actionlint
        uses: docker://rhysd/actionlint:1.7.7
        with:
          args: -color

      - name: dependabot.yml shape
        if: hashFiles('.github/dependabot.yml') != ''
        run: |
          python3 -c "import yaml; yaml.safe_load(open('.github/dependabot.yml'))"
          echo "dependabot.yml parses as valid YAML"

  ci:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

      - name: Setup Flutter
        uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
        with:
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Verify formatting
        run: dart format --output=none --set-exit-if-changed lib/ test/ example/lib/

      - name: Analyze code
        run: dart analyze lib/ test/ example/lib/

      - name: Install lcov
        run: sudo apt-get update && sudo apt-get install -y lcov

      - name: Run tests with coverage (90% floor)
        run: ./tool/coverage.sh 90

      - name: Upload coverage
        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f  # v7.0.0
        with:
          files: coverage/lcov.info
          fail_ci_if_error: false

      - name: Validate publish archive
        run: dart pub publish --dry-run

      - name: Assert WASM-ready (pana)
        run: |
          # Pin pana to 0.23.12: 0.23.13 bundles a newer analyzer that
          # mis-evaluates flutter_svg's `dart.library.js_interop` conditional
          # export and reports a false-positive `is:wasm-ready` miss, even though
          # `flutter build web --wasm` compiles the package cleanly. Revisit when
          # upstream pana fixes the conditional-export wasm evaluation.
          dart pub global activate pana 0.23.12
          dart pub global run pana --json --no-warning . > pana.json
          python3 -c "import json, sys; tags = json.load(open('pana.json')).get('tags', []); ok = 'is:wasm-ready' in tags; print('OK: is:wasm-ready present' if ok else 'FAIL: is:wasm-ready missing; tags=' + repr(tags)); sys.exit(0 if ok else 1)"

  changes:
    name: Detect demo-affecting changes
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    outputs:
      demo: ${{ steps.filter.outputs.demo }}
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
        with:
          fetch-depth: 2

      - name: Filter paths
        id: filter
        run: |
          set -euo pipefail
          if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ] \
             || ! git rev-parse "${{ github.event.before }}^{commit}" >/dev/null 2>&1; then
            BASE="HEAD~1"
          else
            BASE="${{ github.event.before }}"
          fi
          if git diff --name-only "$BASE" HEAD 2>/dev/null | grep -qE '^(lib|example|web|tool)/|^pubspec\.(yaml|lock)$|^\.github/workflows/deploy\.yml$'; then
            echo "demo=true" >> "$GITHUB_OUTPUT"
            echo "demo-affecting changes detected"
          else
            echo "demo=false" >> "$GITHUB_OUTPUT"
            echo "no demo-affecting changes; skipping build + deploy"
          fi

  build-demo:
    name: Build Demo (preview gallery)
    needs: [ci, changes]
    if: github.event_name == 'push' && needs.changes.outputs.demo == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

      - name: Setup Flutter
        uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
        with:
          channel: stable
          cache: true

      - name: Install dependencies
        run: |
          flutter pub get
          cd example && flutter pub get

      - name: Build Demo (lib/main.dart)
        working-directory: example
        # --no-tree-shake-icons keeps the full MaterialIcons-Regular.otf
        # (~340KB vs 17KB tree-shaken). The shaker can't see through WIcon's
        # `IconData icon` parameter indirection (almost every Icons.* glyph
        # gets stripped from the production bundle, rendering empty squares).
        #
        # --pwa-strategy=none drops Flutter's service worker. The CloudPanel
        # nginx vhost serves .js immutable (max-age=315360000) behind
        # Cloudflare, and Flutter's entry files (flutter_bootstrap.js,
        # main.dart.js) keep the same names across builds, so an SW plus an
        # immutable edge copy together pin returning visitors to the previous
        # build. With the SW gone, the stamp step below appends ?v=<hash> so
        # every layer (browser, nginx, Cloudflare) sees a fresh cache key.
        run: |
          flutter build web --release --target lib/main.dart --base-href / --no-tree-shake-icons --pwa-strategy=none
          ../tool/stamp-demo-cache-bust.sh build/web

      - name: Upload Demo artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: demo-build
          path: example/build/web/
          retention-days: 7

  deploy-demo:
    name: Deploy Demo
    needs: build-demo
    if: github.event_name == 'push' && needs.build-demo.result == 'success'
    runs-on: ubuntu-latest
    environment: Demo Production
    steps:
      - name: Download Demo artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          name: demo-build
          path: demo-build/

      # The server's public SSH port is firewalled from GitHub runner IPs, so the
      # runner joins the tailnet and copies over Tailscale with native scp. The
      # Docker-based appleboy actions cannot see the runner-host tailnet.
      - name: Connect to Tailscale
        uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888  # v4.1.3
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:ci
          ping: ${{ secrets.SSH_HOST }}

      - name: Deploy via SCP over Tailscale
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_PORT: ${{ secrets.SSH_PORT }}
          DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          install -m 600 /dev/null ~/.ssh/id_deploy
          printf '%s\n' "$SSH_KEY" > ~/.ssh/id_deploy
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts
          ssh -i ~/.ssh/id_deploy -p "$SSH_PORT" \
            -o StrictHostKeyChecking=yes -o UserKnownHostsFile=~/.ssh/known_hosts \
            "$SSH_USER@$SSH_HOST" \
            "mkdir -p '$DEPLOY_PATH' && find '$DEPLOY_PATH' -mindepth 1 -delete"
          scp -i ~/.ssh/id_deploy -P "$SSH_PORT" \
            -o StrictHostKeyChecking=yes -o UserKnownHostsFile=~/.ssh/known_hosts -r \
            demo-build/. \
            "$SSH_USER@$SSH_HOST:$DEPLOY_PATH/"

      # Drop Cloudflare's edge copies of the demo's stable-named entry files.
      # nginx serves them immutable (max-age=315360000) and Flutter keeps the
      # same filenames across builds, so the edge would otherwise pin returning
      # visitors to the previous build until the next ?v=<hash> reference lands.
      # Targeted file purge (not purge_everything) so the rest of the
      # fluttersdk.com zone is untouched. Skips cleanly when no token is set.
      - name: Purge Cloudflare edge cache
        env:
          CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID || '478745d343e7c481b65a3d904ba69553' }}
        run: |
          if [ -z "$CF_API_TOKEN" ]; then
            echo "Cloudflare purge skipped: CLOUDFLARE_API_TOKEN secret not set."
            exit 0
          fi
          curl -fsS -X POST \
            "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
            -H "Authorization: Bearer ${CF_API_TOKEN}" \
            -H "Content-Type: application/json" \
            --data '{"files":[
              "https://wind-demo.fluttersdk.com/",
              "https://wind-demo.fluttersdk.com/index.html",
              "https://wind-demo.fluttersdk.com/flutter_bootstrap.js",
              "https://wind-demo.fluttersdk.com/main.dart.js",
              "https://wind-demo.fluttersdk.com/flutter.js",
              "https://wind-demo.fluttersdk.com/flutter_service_worker.js",
              "https://wind-demo.fluttersdk.com/manifest.json",
              "https://wind-demo.fluttersdk.com/version.json"
            ]}'
          echo "Cloudflare edge cache purged for wind-demo entry files."
