# Build the wasm web app and publish it to GitHub Pages on every release
# (issue #45). No threads are used on wasm (rayon runs sequentially), so the
# COOP/COEP headers GitHub Pages can't set are not needed.
name: Deploy web (GitHub Pages)
run-name: ${{ github.event.release.tag_name || github.ref_name }} Web

on:
  release:
    types: [published]
  workflow_dispatch:

permissions:
  contents: read
  discussions: read
  pages: write
  id-token: write

# Allow only one Pages deployment at a time.
concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown

      - uses: Swatinem/rust-cache@v2

      - name: Install trunk
        uses: taiki-e/install-action@v2
        with:
          tool: trunk

      - name: Read wasm-bindgen version
        id: wasm-bindgen-version
        shell: bash
        run: |
          VERSION=$(sed -n '/name = "wasm-bindgen"/{n;s/version = "\(.*\)"/\1/p;q;}' Cargo.lock)
          test -n "$VERSION"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"

      - name: Install wasm-bindgen CLI
        uses: taiki-e/install-action@v2
        with:
          tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }}

      - name: Build web bundle
        run: trunk build --release --public-url /app/ --dist dist/app --html-output index.html web-app.html

      - name: Assemble landing page
        run: sh scripts/assemble-site.sh

      # GitHub Pages runs Jekyll, which ignores files; opt out so the
      # wasm-bindgen output is served verbatim.
      - run: touch dist/.nojekyll

      # The web app can't call the Patreon API directly (CORS + the token would
      # be exposed in the bundle), so generate the supporters list server-side
      # here — token stays in the CI secret — and serve it next to the app.
      # The web build fetches `supporters.json` on the same origin.
      - name: Generate supporters.json
        env:
          OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
        run: |
          python3 - <<'PY'
          import json
          import os
          import urllib.parse
          import urllib.request
          from datetime import datetime, timedelta, timezone
          from decimal import Decimal, ROUND_HALF_UP
          from pathlib import Path

          output = Path("dist/app/supporters.json")
          token = os.environ.get("OCS_PATREON_TOKEN", "")
          if not token:
              output.write_text("[]\n", encoding="utf-8")
              raise SystemExit(0)

          headers = {
              "Authorization": f"Bearer {token}",
              "User-Agent": "OpenCADStudio-supporters",
          }

          def fetch_json(url, authenticated=True):
              request = urllib.request.Request(
                  url,
                  headers=headers if authenticated else {"User-Agent": headers["User-Agent"]},
              )
              with urllib.request.urlopen(request, timeout=30) as response:
                  return json.load(response)

          campaigns = fetch_json("https://www.patreon.com/api/oauth2/v2/campaigns")
          campaign_id = campaigns["data"][0]["id"]
          params = urllib.parse.urlencode({
              "include": "pledge_history",
              "fields[member]": "full_name",
              "fields[pledge-event]": "amount_cents,currency_code,date,payment_status",
              "page[count]": "200",
          })
          url = (
              f"https://www.patreon.com/api/oauth2/v2/campaigns/"
              f"{campaign_id}/members?{params}"
          )

          cutoff_date = (datetime.now(timezone.utc) - timedelta(days=31)).date()
          payments = []
          for _ in range(50):
              page = fetch_json(url)
              included = {
                  (item.get("type"), item.get("id")): item.get("attributes", {})
                  for item in page.get("included", [])
                  if item.get("type") == "pledge-event"
              }
              for member in page.get("data", []):
                  latest = None
                  history = (
                      member.get("relationships", {})
                      .get("pledge_history", {})
                      .get("data", [])
                  )
                  for relationship in history:
                      event = included.get((relationship.get("type"), relationship.get("id")))
                      if not event or event.get("payment_status") != "Paid":
                          continue
                      cents = int(event.get("amount_cents") or 0)
                      currency = str(event.get("currency_code") or "").strip().upper()
                      date_text = str(event.get("date") or "")
                      try:
                          paid_at = datetime.fromisoformat(date_text.replace("Z", "+00:00"))
                      except ValueError:
                          continue
                      if cents <= 0 or not currency or paid_at.date() < cutoff_date:
                          continue
                      if latest is None or paid_at > latest[0]:
                          latest = (paid_at, cents, currency)
                  name = str(member.get("attributes", {}).get("full_name") or "").strip()
                  if latest is not None and name:
                      payments.append((name, latest[1], latest[2]))
              url = page.get("links", {}).get("next")
              if not url:
                  break

          rates = {"USD": Decimal("1")}
          if any(currency != "USD" for _, _, currency in payments):
              rate_entries = fetch_json(
                  "https://api.frankfurter.dev/v2/rates?base=USD",
                  authenticated=False,
              )
              rates.update({
                  str(entry["quote"]).upper(): Decimal(str(entry["rate"]))
                  for entry in rate_entries
                  if Decimal(str(entry.get("rate", 0))) > 0
              })

          supporters = []
          for name, cents, currency in payments:
              rate = rates.get(currency)
              if rate is None:
                  continue
              usd_cents = int(
                  (Decimal(cents) / rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
              )
              if usd_cents > 0:
                  supporters.append({"name": name, "cents": usd_cents})

          supporters.sort(key=lambda item: (-item["cents"], item["name"]))
          output.write_text(
              json.dumps(supporters, ensure_ascii=False, separators=(",", ":")) + "\n",
              encoding="utf-8",
          )
          print(f"supporters: {len(supporters)}")
          PY

      # Publish one themed growth chart for stars and release downloads.
      - name: Generate project growth charts
        env:
          GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }}
        run: python3 scripts/generate-star-history.py --output-dir dist

      # Browsers cannot reliably fetch YouTube playlist/oEmbed responses because
      # of CORS. Build a same-origin listing and thumbnail directory for the
      # Start page. Keep the checked-in snapshot if YouTube is temporarily
      # unavailable during deployment.
      - name: Generate videos.json
        run: |
          python3 - <<'PY'
          import json
          import re
          import shutil
          import urllib.request
          from pathlib import Path

          playlist = "https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"
          fallback = Path("web/videos.json")
          output = Path("dist/app/videos.json")
          thumbs = Path("dist/app/video_thumbs")
          thumbs.mkdir(parents=True, exist_ok=True)
          request_headers = {"User-Agent": "Mozilla/5.0"}

          def fetch(url):
              request = urllib.request.Request(url, headers=request_headers)
              with urllib.request.urlopen(request, timeout=20) as response:
                  return response.read()

          try:
              page = fetch(playlist).decode("utf-8", errors="replace")
              ids = []
              for video_id in re.findall(r'"videoId":"([^"]+)"', page):
                  if len(video_id) == 11 and video_id not in ids:
                      ids.append(video_id)
                  if len(ids) >= 50:
                      break

              entries = []
              for video_id in ids:
                  try:
                      metadata = json.loads(fetch(
                          "https://www.youtube.com/oembed"
                          f"?url=https://youtu.be/{video_id}&format=json"
                      ))
                      title = str(metadata.get("title", "")).strip()
                      if not title:
                          continue
                      entries.append({"id": video_id, "title": title})
                      (thumbs / f"{video_id}.jpg").write_bytes(fetch(
                          f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg"
                      ))
                  except Exception as error:
                      print(f"video {video_id}: {error}")

              if not entries:
                  raise RuntimeError("playlist returned no usable videos")
              output.write_text(
                  json.dumps(list(reversed(entries)), ensure_ascii=False),
                  encoding="utf-8",
              )
          except Exception as error:
              print(f"video snapshot fallback: {error}")
              shutil.copyfile(fallback, output)

          print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}")
          PY

      # GitHub's Discussions API is authenticated GraphQL. Generate a public,
      # token-free snapshot next to the web app; discussion activity (including
      # pin/unpin) triggers this workflow so pinned entries stay at the top.
      - name: Generate discussions.json
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          QUERY='
            query($owner: String!, $name: String!) {
              repository(owner: $owner, name: $name) {
                pinnedDiscussions(first: 10) {
                  nodes {
                    discussion {
                      number title url updatedAt
                      author { login }
                    }
                  }
                }
                discussions(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
                  nodes {
                    number title url updatedAt
                    author { login }
                  }
                }
              }
            }'
          gh api graphql \
            -f query="$QUERY" \
            -F owner="$GITHUB_REPOSITORY_OWNER" \
            -F name="${GITHUB_REPOSITORY#*/}" \
            | jq -c '
                .data.repository as $repo
                | [$repo.pinnedDiscussions.nodes[].discussion.number] as $pinned
                | (
                    [$repo.pinnedDiscussions.nodes[].discussion + {pinned: true}]
                    + [
                        $repo.discussions.nodes[]
                        | select((.number as $number | $pinned | index($number)) == null)
                        | . + {pinned: false}
                      ]
                  )
                | map({
                    number,
                    title,
                    url,
                    author: (.author.login // ""),
                    updated_at: .updatedAt,
                    pinned
                  })' \
            > dist/app/discussions.json
          echo "discussions: $(jq 'length' dist/app/discussions.json)"

      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist

  deploy:
    needs: build
    runs-on: ubuntu-22.04
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4
