# Scheduled Fluid Theme updater for sites that VENDOR the CSS (no
# @fluid-theme/core in package.json). Sites with the npm dependency use
# Dependabot (dependabot.yml) instead; this workflow skips them, so a bump
# never opens two PRs. Copy to .github/workflows/update-fluid-theme.yml.
#
# @fluid-theme/core is public on npm, so no npm token is needed. A PR opened
# with the built-in GITHUB_TOKEN does not trigger the site's own
# pull_request checks; set a FLUID_UPDATE_TOKEN secret (a GitHub App or
# fine-grained token with contents + pull-requests write) to get CI on the
# update PR. Each run costs about a minute of Actions time. Major-version jumps are held by default on the weekly
# schedule so a site's look never changes silently; trigger manually with
# allow_major=true when ready to migrate.
name: Update Fluid Theme

on:
  schedule:
    - cron: '17 9 * * 1' # Mondays at 09:17 UTC
  workflow_dispatch:
    inputs:
      allow_major:
        description: 'Allow major-version upgrades (may change default look/tokens)'
        required: false
        type: boolean
        default: false
      vendor_dir:
        description: 'Optional directory where fluid.min.css is vendored (auto-detected if blank)'
        required: false
        type: string
        default: ''

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: update-fluid-theme
  cancel-in-progress: true

jobs:
  update-theme:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v5
        with:
          token: ${{ secrets.FLUID_UPDATE_TOKEN || secrets.GITHUB_TOKEN }}

      - uses: actions/setup-node@v5
        with:
          node-version: 24

      - name: Check current and published @fluid-theme/core versions
        id: check
        env:
          ALLOW_MAJOR: ${{ inputs.allow_major || 'false' }}
          INPUT_VENDOR_DIR: ${{ inputs.vendor_dir || '' }}
        run: |
          node <<'EOF'
          import { existsSync, readFileSync, readdirSync, statSync, appendFileSync } from 'node:fs';
          import { execFileSync } from 'node:child_process';
          import { dirname, join } from 'node:path';

          const out = process.env.GITHUB_OUTPUT;
          const summary = process.env.GITHUB_STEP_SUMMARY;
          const allowMajor = process.env.ALLOW_MAJOR === 'true';
          const inputDir = (process.env.INPUT_VENDOR_DIR || '').trim();

          const parseSemver = (v) => {
            const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)$/);
            return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
          };
          const cmpSemver = (a, b) => {
            const pa = parseSemver(a);
            const pb = parseSemver(b);
            if (!pa || !pb) return 0;
            for (let i = 0; i < 3; i++) {
              if (pa[i] !== pb[i]) return pa[i] - pb[i];
            }
            return 0;
          };

          let current = '';
          let hasPkgDep = false;

          if (existsSync('package.json')) {
            const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
            const raw = pkg.dependencies?.['@fluid-theme/core'] || pkg.devDependencies?.['@fluid-theme/core'] || '';
            if (raw) {
              hasPkgDep = true;
              current = raw.replace(/^[^\d]*/, '');
            }
          }

          if (hasPkgDep) {
            console.log('@fluid-theme/core is a package.json dependency: Dependabot updates this site (dependabot.yml). Nothing to do.');
            appendFileSync(summary, '### Skipped\n`@fluid-theme/core` is in package.json, so Dependabot updates this site.\n\n');
            appendFileSync(out, 'update=false\n');
            process.exit(0);
          }

          const CORE_NAMES = ['fluid.min.css', 'fluid-1x.min.css', 'fluid-zero.min.css'];

          function findVendoredCore(dir, depth = 0) {
            if (depth > 4 || !existsSync(dir)) return [];
            const skip = new Set(['.git', 'node_modules', 'dist', '.astro', '.wrangler', '_site']);
            const found = [];
            for (const entry of readdirSync(dir)) {
              if (skip.has(entry)) continue;
              const full = join(dir, entry);
              const st = statSync(full);
              if (st.isDirectory()) found.push(...findVendoredCore(full, depth + 1));
              else if (CORE_NAMES.includes(entry)) found.push(full);
            }
            return found;
          }

          const vendorFiles = inputDir
            ? CORE_NAMES.map((name) => join(inputDir, name)).filter(existsSync)
            : findVendoredCore('.');

          for (const f of vendorFiles) {
            if (current) break;
            const firstLine = readFileSync(f, 'utf8').split('\n')[0] || '';
            const m = firstLine.match(/@fluid-theme\/(?:core|zero)\s+v(\d+\.\d+\.\d+)/);
            if (m) current = m[1];
          }

          if (!parseSemver(current)) {
            console.error(`Could not detect a valid stable @fluid-theme/core version (got "${current}") from package.json or vendored banner.`);
            process.exit(1);
          }

          const rawVersions = JSON.parse(execFileSync('npm', ['view', '@fluid-theme/core', 'versions', '--json'], { encoding: 'utf8' }));
          const published = [rawVersions].flat().filter((v) => parseSemver(v)).sort(cmpSemver);
          if (!published.length) {
            console.error('No stable versions of @fluid-theme/core found on npm.');
            process.exit(1);
          }

          const latest = published[published.length - 1];
          const curMajor = parseSemver(current)[0];
          const latestMajor = parseSemver(latest)[0];

          let target = latest;
          if (latestMajor > curMajor && !allowMajor) {
            const sameMajorNewer = published.filter((v) => parseSemver(v)[0] === curMajor && cmpSemver(v, current) > 0);
            target = sameMajorNewer.length ? sameMajorNewer[sameMajorNewer.length - 1] : current;
            appendFileSync(
              summary,
              `### Major upgrade held\nLatest published version is **${latest}** (current: **${current}**). Run this workflow manually with \`allow_major: true\` after reviewing migration notes.\n\n`
            );
          }

          const shouldUpdate = cmpSemver(target, current) > 0;
          const vendorDirs = [...new Set(vendorFiles.map((f) => dirname(f)))];

          console.log(`Current: ${current} | Target: ${target} | Latest: ${latest} | Update: ${shouldUpdate}`);
          appendFileSync(out, `update=${shouldUpdate}\n`);
          appendFileSync(out, `current=${current}\n`);
          appendFileSync(out, `target=${target}\n`);
          appendFileSync(out, `latest=${latest}\n`);
          appendFileSync(out, `has_pkg=${hasPkgDep}\n`);
          appendFileSync(out, `vendor_dirs=${JSON.stringify(vendorDirs)}\n`);
          EOF

      - name: Apply theme update
        if: steps.check.outputs.update == 'true'
        id: apply
        env:
          TARGET: ${{ steps.check.outputs.target }}
          VENDOR_DIRS: ${{ steps.check.outputs.vendor_dirs }}
        run: |
          set -euo pipefail
          CHANGED=()

          WORK=$(mktemp -d)
          TARBALL=$(npm pack "@fluid-theme/core@$TARGET" --pack-destination "$WORK" | tail -n 1)
          tar -xzf "$WORK/$TARBALL" -C "$WORK"
          PKG="$WORK/package"

          node - "$PKG" "$VENDOR_DIRS" <<'EOF' > "$WORK/copied.txt"
          import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
          import { join } from 'node:path';

          const [, , pkgDir, rawDirs] = process.argv;
          const dirs = JSON.parse(rawDirs || '[]');
          const copied = [];

          for (const dir of dirs) {
            if (!existsSync(dir)) continue;
            for (const f of readdirSync(join(pkgDir, 'dist'))) {
              if (!f.endsWith('.css')) continue;
              const dest = join(dir, f);
              if (!existsSync(dest)) continue;
              let srcFile = join(pkgDir, 'dist', f);
              // Path-A 1.x compatibility sites vendor dist/fluid-1x.min.css under the fluid.min.css name;
              // keep serving fluid-1x.min.css when the existing banner marks "core + legacy-1x".
              if (f === 'fluid.min.css') {
                const banner = readFileSync(dest, 'utf8').split('\n')[0] || '';
                if (banner.includes('core + legacy-1x') && existsSync(join(pkgDir, 'dist', 'fluid-1x.min.css'))) {
                  srcFile = join(pkgDir, 'dist', 'fluid-1x.min.css');
                }
              }
              cpSync(srcFile, dest);
              copied.push(dest);
            }
            const siteFontsDir = join(dir, 'fonts');
            const pkgFontsDir = join(pkgDir, 'dist', 'fonts');
            if (existsSync(siteFontsDir) && existsSync(pkgFontsDir)) {
              const existingFonts = readdirSync(siteFontsDir);
              for (const f of readdirSync(pkgFontsDir)) {
                const dest = join(siteFontsDir, f);
                const stem = f.startsWith('OFL-') ? f.replace(/^OFL-|\.txt$/g, '') : '';
                const hasMatchingFace = stem && existingFonts.some((ef) => ef.endsWith('.woff2') && ef.startsWith(stem));
                if (existsSync(dest) || hasMatchingFace) {
                  cpSync(join(pkgFontsDir, f), dest);
                  copied.push(dest);
                }
              }
            }
            // The official child themes, when the site vendors them.
            for (const t of ['swiss.css', 'gallery.css']) {
              for (const sub of ['', 'themes']) {
                const dest = sub ? join(dir, sub, t) : join(dir, t);
                // Only a file that is Fluid's theme: a site's own gallery.css stays.
                const isFluid = existsSync(dest) && readFileSync(dest, 'utf8').slice(0, 300).includes(`"${t.replace('.css', '')}": an alternate child theme on the Fluid`);
                if (isFluid && existsSync(join(pkgDir, 'themes', t))) {
                  cpSync(join(pkgDir, 'themes', t), dest);
                  copied.push(dest);
                }
              }
            }
            for (const js of ['toc.js', 'toc-build.mjs', 'fit-text.js']) {
              for (const sub of ['', 'js']) {
                const dest = sub ? join(dir, sub, js) : join(dir, js);
                if (existsSync(dest) && existsSync(join(pkgDir, 'assets', 'js', js))) {
                  cpSync(join(pkgDir, 'assets', 'js', js), dest);
                  copied.push(dest);
                }
              }
            }
          }
          for (const c of copied) console.log(c);
          EOF

          while IFS= read -r file; do
            [ -n "$file" ] && CHANGED+=("$file")
          done < "$WORK/copied.txt"
          rm -rf "$WORK"

          printf '%s\n' "${CHANGED[@]}" | awk 'NF && !seen[$0]++' > /tmp/changed-files.txt
          cat /tmp/changed-files.txt

      - name: Commit and open or update pull request
        if: steps.check.outputs.update == 'true'
        env:
          GH_TOKEN: ${{ secrets.FLUID_UPDATE_TOKEN || secrets.GITHUB_TOKEN }}
          CURRENT: ${{ steps.check.outputs.current }}
          TARGET: ${{ steps.check.outputs.target }}
        run: |
          set -euo pipefail
          if [ ! -s /tmp/changed-files.txt ] || git diff --quiet; then
            echo "No tracked files changed after update."
            exit 0
          fi

          # One branch for every update, so a newer release replaces an open
          # update PR instead of opening a second one beside it.
          BRANCH="chore/fluid-theme-update"
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" 2>/dev/null || true
          git checkout -B "$BRANCH"

          while IFS= read -r path; do
            [ -n "$path" ] && git add -- "$path"
          done < /tmp/changed-files.txt

          TITLE="chore(theme): update @fluid-theme/core from $CURRENT to $TARGET"
          git commit -m "$TITLE"
          git push --force-with-lease origin "$BRANCH"

          BODY=$(cat <<EOB
          Updates \`@fluid-theme/core\` from **v$CURRENT** to **v$TARGET**.

          - npm package: https://www.npmjs.com/package/@fluid-theme/core/v/$TARGET
          - Compare: https://github.com/Agile-Media-Agency/the-fluid-theme/compare/v$CURRENT...v$TARGET
          EOB
          )

          EXISTING=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
          if [ -n "$EXISTING" ]; then
            gh pr edit "$EXISTING" --title "$TITLE" --body "$BODY"
          else
            gh pr create --title "$TITLE" --body "$BODY" --head "$BRANCH"
          fi
