name: i18n Auto-Translate

# Backends (select with repository variable TRANSLATE_BACKEND):
#   github (default) — GitHub Models inference API (https://models.github.ai/inference)
#     with the repository's GITHUB_TOKEN. Requires `models: read`. Default model
#     openai/gpt-5-mini; override via repository variable TRANSLATE_MODEL.
#   anthropic — Anthropic API direct. Set secret ANTHROPIC_API_KEY and
#     TRANSLATE_MODEL to a Claude model id (e.g. claude-sonnet-4-6).
#   openai — any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen, one-api/
#     new-api gateways, vLLM, ...). Set secret TRANSLATE_API_KEY, secret or
#     variable TRANSLATE_ENDPOINT (API base ending in /v1), and TRANSLATE_MODEL.
#
# Trigger is `pull_request_target` (NOT `pull_request`) so that fork PRs also
# get secrets and a write token — this is what allows auto-translating and
# pushing translations back to fork branches ("Allow edits by maintainers").
#
# SECURITY MODEL — read before editing:
#   pull_request_target runs with secrets in the BASE repo context, so this
#   workflow must NEVER execute code from the PR head. We therefore:
#     1. check out the trusted base branch (workflow + scripts/i18n/* run from it),
#     2. fetch the PR *merge ref* and copy in DATA files only
#        (skills/, manifest.json, categories.json), dropping symlinks,
#     3. run the trusted scripts over that data,
#     4. push resulting translations to the PR head branch with the bot token.
#   Any value derived from the PR (branch name, repo name) is passed to shell
#   steps via `env:` — never interpolated with ${{ }} inside `run:`.
#
# Scope: on PR events, only skills whose SKILL*.md files actually changed in
# the PR are checked/translated — keeps token usage proportional to the PR. If
# manifest.json or categories.json changed, falls back to full-repo scan (these
# affect the i18n fallback chain globally). workflow_dispatch always does a full
# scan unless `skill` input is supplied.

on:
  pull_request_target:
  workflow_dispatch:
    inputs:
      target_locale:
        description: "Target locale (default: all from manifest.supportedLocales)"
        required: false
      skill:
        description: "Specific skill path, e.g. skills/web-access (default: all)"
        required: false

permissions:
  contents: write
  pull-requests: write
  issues: write
  models: read

concurrency:
  # Per-PR group (pull_request_target's github.ref is the base branch, which
  # would otherwise serialize/cancel unrelated PRs).
  group: i18n-translate-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  translate:
    # Don't run on the bot's own translation pushes to avoid loops
    if: github.actor != 'desirecore-bot' && github.actor != 'desirecore-bot[bot]'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Checkout trusted base
        uses: actions/checkout@v4
        with:
          # No `ref:` on purpose — for pull_request_target this is the BASE
          # branch (trusted scripts); for workflow_dispatch it's the chosen ref.
          token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
          fetch-depth: 0

      - name: Overlay PR content (data only)
        if: github.event_name == 'pull_request_target'
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          set -e
          # The merge ref is maintained by GitHub in the BASE repo (fork branches
          # live elsewhere) and already contains PR + base merged.
          if ! git fetch origin "refs/pull/${PR_NUMBER}/merge"; then
            echo "::error::Could not fetch refs/pull/${PR_NUMBER}/merge — the PR likely has merge conflicts. Resolve them and the check will re-run."
            exit 1
          fi
          # Replace data files with the PR's merged state. Trusted code
          # (scripts/, .github/) intentionally stays at the base version.
          rm -rf skills manifest.json categories.json
          git checkout FETCH_HEAD -- skills manifest.json categories.json
          # Defense in depth: a malicious PR could add a symlink named SKILL.md
          # pointing outside the tree; translate.py would read through it and
          # could leak runner file content into a committed "translation".
          find skills -type l -print -delete
          # The root data files are untrusted too — reject symlinked variants
          # instead of letting trusted scripts read through them.
          for f in manifest.json categories.json; do
            if [ -L "$f" ]; then
              echo "::error::$f in the PR is a symlink; refusing to process."
              exit 1
            fi
          done
          git add -A
          git -c user.name=ci -c user.email=ci@invalid commit --allow-empty -m "overlay: PR #${PR_NUMBER} data files"

      - name: Detect relevant changes
        id: changes
        run: |
          # GitHub Actions default shell already runs with -eo pipefail; we set
          # it explicitly so the `|| true` workaround on grep below is unambiguous.
          set -eo pipefail
          # workflow_dispatch: full scan (skill input handled later if provided)
          if [ "${{ github.event_name }}" != "pull_request_target" ]; then
            echo "relevant=true" >> "$GITHUB_OUTPUT"
            echo "skill_paths=" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # The overlay commit (HEAD) contains exactly the PR's effective
          # changes to skills/ + manifest.json + categories.json vs base.
          changed=$(git diff --name-only HEAD~1 HEAD)

          # If manifest.json or categories.json changed, fall back to full scan
          # (these affect i18n fallback chain / supportedLocales globally).
          if printf '%s\n' "$changed" | grep -qE '^(manifest\.json|categories\.json)$'; then
            echo "relevant=true" >> "$GITHUB_OUTPUT"
            echo "skill_paths=" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Otherwise: extract unique skill directories touched by SKILL*.md edits.
          # Grep enforces:
          #   - skill name: lowercase ASCII letters, digits, hyphens
          #     (no leading/trailing hyphen — anchored with [a-z0-9])
          #   - locale tag (BCP-47 subset): 2-3 lowercase letters + optional -RR
          # awk enforces the rest of the schema's name constraints (no consecutive
          # hyphens, no reserved names) as defense-in-depth — those would already
          # have been blocked upstream by validate-i18n.py / schema validation.
          # The brace group with `|| true` turns grep's "no match" (exit 1) into
          # success so pipefail doesn't kill the step when this PR is unrelated.
          # Strict filtering also sanitizes input for the $GITHUB_OUTPUT write
          # below (any path failing the shape is dropped).
          skill_paths=$(printf '%s\n' "$changed" \
            | { grep -E '^skills/[a-z0-9]([a-z0-9-]*[a-z0-9])?/SKILL(\.[a-z]{2,3}(-[A-Z]{2})?)?\.md$' || true; } \
            | awk -F/ '
                $2 !~ /--/ && $2 != "anthropic" && $2 != "claude" {
                  print $1 "/" $2
                }' \
            | sort -u \
            | tr '\n' ' ' \
            | sed 's/ $//')

          if [ -n "$skill_paths" ]; then
            echo "relevant=true" >> "$GITHUB_OUTPUT"
            echo "skill_paths=$skill_paths" >> "$GITHUB_OUTPUT"
          else
            echo "relevant=false" >> "$GITHUB_OUTPUT"
            echo "skill_paths=" >> "$GITHUB_OUTPUT"
          fi

      - name: Install uv
        if: steps.changes.outputs.relevant == 'true'
        uses: astral-sh/setup-uv@v3

      - name: Check for stale translations
        id: precheck
        if: steps.changes.outputs.relevant == 'true'
        env:
          SKILL_PATHS: ${{ steps.changes.outputs.skill_paths }}
          INPUT_SKILL: ${{ github.event.inputs.skill }}
        run: |
          set +e
          ARGS=(--check)
          if [ -n "$INPUT_SKILL" ]; then
            # workflow_dispatch with explicit skill input takes precedence
            ARGS+=("$INPUT_SKILL")
          elif [ -n "$SKILL_PATHS" ]; then
            # pull_request_target: read space-separated paths into an array to
            # avoid word-splitting / glob expansion surprises.
            read -r -a SKILL_ARR <<<"$SKILL_PATHS"
            ARGS+=("${SKILL_ARR[@]}")
          fi
          uv run --quiet scripts/i18n/translate.py "${ARGS[@]}"
          rc=$?
          if [ $rc -eq 0 ]; then
            echo "stale=false" >> "$GITHUB_OUTPUT"
          else
            echo "stale=true" >> "$GITHUB_OUTPUT"
          fi
          exit 0

      - name: Translate stale locales
        if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TRANSLATE_API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
          TRANSLATE_ENDPOINT: ${{ secrets.TRANSLATE_ENDPOINT || vars.TRANSLATE_ENDPOINT }}
          TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }}
          TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }}
          SKILL_PATHS: ${{ steps.changes.outputs.skill_paths }}
          INPUT_SKILL: ${{ github.event.inputs.skill }}
          INPUT_TARGET: ${{ github.event.inputs.target_locale }}
        run: |
          set -e
          ARGS=()
          if [ -n "$INPUT_SKILL" ]; then
            # workflow_dispatch input takes precedence (single specific skill)
            ARGS+=("$INPUT_SKILL")
          elif [ -n "$SKILL_PATHS" ]; then
            # pull_request_target: only translate skills touched by this PR
            read -r -a SKILL_ARR <<<"$SKILL_PATHS"
            ARGS+=("${SKILL_ARR[@]}")
          fi
          if [ -n "$INPUT_TARGET" ]; then
            ARGS+=(--target "$INPUT_TARGET")
          fi
          uv run --quiet scripts/i18n/translate.py "${ARGS[@]}"

      - name: Validate after translation
        if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true'
        run: uv run --quiet scripts/i18n/validate-i18n.py

      - name: Detect changes
        id: diff
        if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true'
        run: |
          if git diff --quiet; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
            git diff --stat
          fi

      - name: Push translations to PR branch
        if: steps.diff.outputs.changed == 'true' && github.event_name == 'pull_request_target'
        env:
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          HEAD_REF: ${{ github.event.pull_request.head.ref }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          BOT_TOKEN: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
          TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }}
          TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }}
        run: |
          set -e
          changed_files=$(git diff --name-only)
          # Enforce the security model: translate.py only ever writes
          # skills/<name>/SKILL*.md. Anything else in the diff means an
          # unexpected write — abort rather than push it to a contributor branch.
          unexpected=$(printf '%s\n' "$changed_files" \
            | { grep -vE '^skills/[a-z0-9]([a-z0-9-]*[a-z0-9])?/SKILL(\.[a-z]{2,3}(-[A-Z]{2})?)?\.md$' || true; })
          if [ -n "$unexpected" ]; then
            echo "::error::Refusing to push non-translation changes to the PR branch:"
            printf '%s\n' "$unexpected"
            exit 1
          fi
          # The working tree is base + PR overlay + translations; the PR branch
          # itself may live in a fork. Apply only the translation-generated
          # files onto a checkout of the actual PR head and push that.
          git remote add prhead "https://x-access-token:${BOT_TOKEN}@github.com/${HEAD_REPO}.git"
          git fetch prhead "refs/heads/${HEAD_REF}"
          if [ "$(git rev-parse FETCH_HEAD)" != "$HEAD_SHA" ]; then
            echo "::notice::PR head moved since this run started; skipping push — the newer run will translate."
            exit 0
          fi
          git worktree add --detach ../prpush FETCH_HEAD
          while IFS= read -r f; do
            mkdir -p "../prpush/$(dirname "$f")"
            cp "$f" "../prpush/$f"
          done <<<"$changed_files"
          cd ../prpush
          git config user.name "desirecore-bot"
          git config user.email "bot@desirecore.net"
          git add -A
          # No [skip ci] on purpose: required checks (validate/translate) must
          # re-run and report on the translated commit, or the PR becomes
          # unmergeable. Loop prevention is the job-level bot-actor guard.
          git commit -m "chore(i18n): auto-translate skills" \
                     -m "Generated by scripts/i18n/translate.py via i18n-translate workflow." \
                     -m "Backend: ${TRANSLATE_BACKEND}  Model: ${TRANSLATE_MODEL}"
          if ! git push prhead HEAD:"refs/heads/${HEAD_REF}"; then
            echo "::error::Could not push translations to ${HEAD_REPO}:${HEAD_REF}. For fork PRs, enable 'Allow edits by maintainers' on the PR, or run 'uv run scripts/i18n/translate.py' locally and commit the generated SKILL.<locale>.md files."
            exit 1
          fi

      - name: Commit & push translations
        if: steps.diff.outputs.changed == 'true' && github.event_name != 'pull_request_target'
        run: |
          git config user.name "desirecore-bot"
          git config user.email "bot@desirecore.net"
          git add -A
          git commit -m "chore(i18n): auto-translate skills" \
                     -m "Generated by scripts/i18n/translate.py via i18n-translate workflow." \
                     -m "Backend: ${TRANSLATE_BACKEND:-github}  Model: ${TRANSLATE_MODEL:-openai/gpt-5-mini}"
          git push
        env:
          TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }}
          TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }}

      - name: Comment on PR
        if: steps.diff.outputs.changed == 'true' && github.event_name == 'pull_request_target'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
          script: |
            const backend = process.env.TRANSLATE_BACKEND || 'github';
            const model = process.env.TRANSLATE_MODEL || 'openai/gpt-5-mini';
            const body = [
              '🌐 **i18n auto-translation pushed**',
              '',
              'New machine-translated content was added to this PR by `scripts/i18n/translate.py`.',
              '',
              '**Please review the translated strings** before merging:',
              '- Check terminology against `scripts/i18n/glossary.json`',
              '- Verify Markdown structure (headings/tables/code fences) is preserved',
              '- If you edit a translation, set `metadata.i18n.<locale>.translated_by: human` to lock it.',
              '',
              `Backend: \`${backend}\`  Model: \`${model}\``,
            ].join('\n');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body,
            });
        env:
          TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }}
          TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }}

      - name: Label on translation failure
        if: failure() && github.event_name == 'pull_request_target'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
          script: |
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['i18n-translation-failed'],
            });
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: [
                '🌐 **i18n translation check failed**',
                '',
                'Please inspect the workflow logs for the exact failing skill/locale.',
                '',
                'Common causes:',
                '- A machine-translated locale could not be regenerated.',
                '- A `translated_by: human` locale has source hash drift and must be manually synchronized.',
                '',
                'After manually reviewing a human-locked translation, update its `source_hash` to match the current source content.',
              ].join('\n'),
            });

      - name: Skip notice
        if: steps.changes.outputs.relevant != 'true'
        run: echo "No i18n-relevant changes; translation skipped."
