name: Release

on:
  push:
    branches:
      - master

concurrency:
  group: release
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions:
  contents: read

defaults:
  run:
    shell: bash

jobs:
  # ─── Detect changed paths & pending releases ──────────────────────────
  detect:
    name: detect changed paths
    runs-on: ubuntu-latest
    outputs:
      rust_changed: ${{ steps.detect.outputs.rust_changed }}
      ts_changed: ${{ steps.detect.outputs.ts_changed }}
      rust_version: ${{ steps.detect.outputs.rust_version }}
      crates_needs_publish: ${{ steps.detect.outputs.crates_needs_publish }}
      npm_needs_publish: ${{ steps.detect.outputs.npm_needs_publish }}
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 2

      - name: Install pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: "22"
          package-manager-cache: false

      - name: Sync versions from package.json
        working-directory: rust
        run: node scripts/sync-version.mjs

      - name: Detect changed paths & pending releases
        id: detect
        working-directory: rust
        run: |
          changed_files=$(git -C .. diff --name-only HEAD~1 HEAD)
          rust_changed=false
          ts_changed=false

          # ── File-change detection ──
          if echo "$changed_files" | grep -q '^rust/'; then
            rust_changed=true
          fi
          ts_files=$(echo "$changed_files" | grep -v '^rust/' || true)
          if [ -n "$ts_files" ]; then
            ts_changed=true
          fi

          # ── Registry-based release detection (Rust) ──
          # If local version is not yet on crates.io or npm, we need a release
          rust_version=$(node -p "require('./package.json').version")
          crates_needs_publish=false
          npm_needs_publish=false

          for toml in crates/*/Cargo.toml; do
            name=$(grep '^name = ' "$toml" | head -1 | sed 's/name = "\(.*\)"/\1/')
            local_ver=$(grep '^version = ' "$toml" | head -1 | sed 's/version = "\(.*\)"/\1/')
            if [ -z "$name" ] || [ -z "$local_ver" ]; then
              continue
            fi
            # Use the crates.io HTTP API for exact version matching.
            # cargo info crate@ver exits 0 if the crate exists at ANY version,
            # not just the specified one — so we must check the versions list.
            published_ver=$(curl -sfH "Accept: application/json" -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://crates.io/api/v1/crates/${name}" 2>/dev/null \
              | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const c=JSON.parse(d);process.stdout.write(c.crate.max_version)}catch(e){process.stdout.write('')}})" || echo "")
            if [ "$published_ver" = "$local_ver" ] && [ -n "$published_ver" ]; then
              echo "${name}@${local_ver} already on crates.io (max_version=${published_ver})"
            else
              echo "crates.io release needed: ${name}@${local_ver} (published=${published_ver:-none})"
              crates_needs_publish=true
              rust_changed=true
            fi
          done

          # ── npm: publish if rust/package.json version differs from npm registry
          npm_published=$(npm info cpd version 2>/dev/null || echo "")
          if [ "$npm_published" != "$rust_version" ]; then
            echo "npm release needed: cpd published=${npm_published:-none} local=${rust_version}"
            npm_needs_publish=true
            rust_changed=true
          else
            echo "cpd@${rust_version} already on npm"
          fi

          echo "rust_changed=${rust_changed}" >> "$GITHUB_OUTPUT"
          echo "ts_changed=${ts_changed}" >> "$GITHUB_OUTPUT"
          echo "rust_version=${rust_version}" >> "$GITHUB_OUTPUT"
          echo "crates_needs_publish=${crates_needs_publish}" >> "$GITHUB_OUTPUT"
          echo "npm_needs_publish=${npm_needs_publish}" >> "$GITHUB_OUTPUT"
          echo "Detected: rust_changed=${rust_changed}, ts_changed=${ts_changed}, crates_needs_publish=${crates_needs_publish}, npm_needs_publish=${npm_needs_publish}"

  # ─── Rust CI ─────────────────────────────────────────────────────
  rust-ci:
    name: rust ci (clippy, test, build)
    needs: detect
    if: needs.detect.outputs.rust_changed == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: rust
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 2

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: "1.93"
          components: clippy

      - name: Cache Cargo registry and target
        uses: Swatinem/rust-cache@v2
        with:
          workspaces: "rust -> rust/target"
          cache-on-failure: true

      - name: Clippy
        run: cargo clippy --workspace -- -D warnings

      - name: Test
        run: cargo test --workspace --locked

      - name: Build
        run: cargo build --release --workspace --locked

  # ─── TypeScript CI ──────────────────────────────────────────────
  ts-ci:
    name: typescript ci (build, lint, test)
    needs: detect
    if: needs.detect.outputs.ts_changed == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      checks: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 2

      - name: Install pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: "lts/*"
          package-manager-cache: false

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm build

      - name: Lint
        run: pnpm lint

      - name: Test
        run: pnpm test -- --reporter=default --reporter=junit --outputFile=test-results/junit.xml

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-release-ts
          path: "**/test-results/junit.xml"
          if-no-files-found: warn

      - name: Test Report
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: Node.js Tests (release)
          path: "**/test-results/junit.xml"
          reporter: java-junit
          fail-on-error: false

      # ─── Rust version bump detection ─────────────────────────────────
  rust-version:
    name: detect rust version bump
    needs: [detect, rust-ci]
    if: needs.detect.outputs.rust_changed == 'true' && needs.rust-ci.result == 'success'
    runs-on: ubuntu-latest
    outputs:
      version: ${{ needs.detect.outputs.rust_version }}
      crates_bumped: ${{ needs.detect.outputs.crates_needs_publish }}
      npm_bumped: ${{ needs.detect.outputs.npm_needs_publish }}
    steps:
      - name: Confirm
        run: |
          echo "Releasing Rust ${{ needs.detect.outputs.rust_version }}"
          echo "crates_needs_publish=${{ needs.detect.outputs.crates_needs_publish }}"
          echo "npm_needs_publish=${{ needs.detect.outputs.npm_needs_publish }}"

  # ─── TypeScript version bump detection ───────────────────────────
  ts-version:
    name: detect typescript version bump
    needs: [detect, ts-ci]
    if: needs.detect.outputs.ts_changed == 'true' && needs.ts-ci.result == 'success'
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
      bumped: ${{ steps.version.outputs.bumped }}
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 2

      - name: Install pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: "22"
          package-manager-cache: false

      - name: Detect version bump
        id: version
        run: |
          version=$(node -p "require('./package.json').version")
          bumped=false

          # Check root package.json
          old_root=$(git show "HEAD~1:package.json" 2>/dev/null | node -e "
            let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
              try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) }
            })" || true)
          new_root=$(node -p "require('./package.json').version")
          if [ -n "$old_root" ] && [ "$old_root" != "$new_root" ]; then
            echo "Version bump in package.json: $old_root -> $new_root"
            bumped=true
          fi

          # Check apps/*/package.json
          for pkg in apps/*/package.json; do
            old_v=$(git show "HEAD~1:$pkg" 2>/dev/null | node -e "
              let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
                try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) }
              })" || true)
            new_v=$(node -p "require('./$pkg').version")
            if [ -n "$old_v" ] && [ "$old_v" != "$new_v" ]; then
              echo "Version bump in $pkg: $old_v -> $new_v"
              bumped=true
            fi
          done

          # Check packages/*/package.json (skip private packages)
          for pkg in packages/*/package.json; do
            is_private=$(node -p "require('./$pkg').private || false" 2>/dev/null || echo "true")
            if [ "$is_private" = "true" ]; then
              continue
            fi
            old_v=$(git show "HEAD~1:$pkg" 2>/dev/null | node -e "
              let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
                try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) }
              })" || true)
            new_v=$(node -p "require('./$pkg').version")
            if [ -n "$old_v" ] && [ "$old_v" != "$new_v" ]; then
              echo "Version bump in $pkg: $old_v -> $new_v"
              bumped=true
            fi
          done

          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "bumped=$bumped" >> "$GITHUB_OUTPUT"

  # ─── Rust publish ────────────────────────────────────────────────
  rust-publish-crates:
    name: publish crates.io packages
    needs: rust-version
    if: needs.rust-version.outputs.crates_bumped == 'true'
    uses: ./.github/workflows/crates-publish.yml
    with:
      version: ${{ needs.rust-version.outputs.version }}
    secrets: inherit
    permissions:
      contents: read
      id-token: write
      checks: write

  rust-publish-npm:
    name: publish npm rust packages
    needs: rust-version
    if: needs.rust-version.outputs.npm_bumped == 'true'
    uses: ./.github/workflows/npm-rust-publish.yml
    with:
      version: ${{ needs.rust-version.outputs.version }}
    secrets: inherit
    permissions:
      contents: read
      id-token: write
      checks: write

  # ─── TypeScript publish ──────────────────────────────────────────
  ts-publish:
    name: publish npm packages
    needs: ts-version
    if: needs.ts-version.outputs.bumped == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - name: Install pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: "lts/*"
          registry-url: "https://registry.npmjs.org"
          package-manager-cache: false

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm build

      - name: Publish packages
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: pnpm publish-packages

  # ─── GitHub releases ─────────────────────────────────────────────
  rust-release:
    name: create rust github release
    needs: [rust-version, rust-publish-crates, rust-publish-npm]
    if: always() && !cancelled() &&
      (needs.rust-version.outputs.crates_bumped == 'true' || needs.rust-version.outputs.npm_bumped == 'true') &&
      needs.rust-publish-crates.result != 'failure' &&
      needs.rust-publish-npm.result != 'failure'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Download binary artifacts
        uses: actions/download-artifact@v4
        with:
          pattern: cpd-binary-*
          path: release-assets
          merge-multiple: true
        continue-on-error: true

      - name: Resolve published packages
        id: packages
        working-directory: rust
        run: |
          version="${{ needs.rust-version.outputs.version }}"
          published=""
          failed=""

          # ── crates.io packages ──
          for toml in crates/*/Cargo.toml; do
            name=$(grep '^name = ' "$toml" | head -1 | sed 's/name = "\(.*\)"/\1/')
            local_ver=$(grep '^version = ' "$toml" | head -1 | sed 's/version = "\(.*\)"/\1/')
            if [ -z "$name" ] || [ -z "$local_ver" ]; then
              continue
            fi
            max_ver=$(curl -sfH "Accept: application/json" -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://crates.io/api/v1/crates/${name}" 2>/dev/null \
              | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const c=JSON.parse(d);process.stdout.write(c.crate.max_version)}catch(e){process.stdout.write('')}})" || echo "")
            if [ "$max_ver" = "$local_ver" ]; then
              published="${published}  - \`${name}@${local_ver}\` on crates.io\n"
            else
              failed="${failed}  - \`${name}@${local_ver}\` (published: ${max_ver:-none})\n"
            fi
          done

          # ── npm packages ──
          npm_pkgs=(cpd jscpd cpd-darwin-arm64 cpd-darwin-x64 cpd-linux-x64-gnu cpd-linux-arm64-gnu cpd-linux-x64-musl cpd-windows-x64-msvc)
          for pkg in "${npm_pkgs[@]}"; do
            pub_ver=$(npm view "${pkg}@${version}" version 2>/dev/null || echo "")
            if [ "$pub_ver" = "$version" ]; then
              published="${published}  - \`${pkg}@${version}\` on npm\n"
            else
              # Platform packages may have different versions
              latest_ver=$(npm view "${pkg}" version 2>/dev/null || echo "")
              failed="${failed}  - \`${pkg}@${version}\` (published: ${latest_ver:-none})\n"
            fi
          done

          {
            echo "published<<EOF"
            echo -e "$published"
            echo "EOF"
            echo "failed<<EOF"
            echo -e "$failed"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Extract release notes
        id: notes
        working-directory: rust
        run: |
          version="${{ needs.rust-version.outputs.version }}"
          notes=$(node -e "
            const fs = require('fs');
            const ver = process.argv[1];
            const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
            const escaped = ver.replace(/\./g, '\\\\.');
            const re = new RegExp('^## .*' + escaped);
            const lines = changelog.split('\n');
            let found = false;
            const out = [];
            for (const line of lines) {
              if (!found && re.test(line)) { found = true; continue; }
              if (found && /^## /.test(line)) break;
              if (found && /^---$/.test(line)) break;
              if (found) out.push(line);
            }
            process.stdout.write(out.join('\n'));
          " "$version")
          if [ -z "$notes" ]; then
            notes="Release v${version}"
          fi

          # ── Append published packages info ──
          # Use single-quoted assignments so markdown backticks in package
          # names are not interpreted as command substitution.
          published='${{ steps.packages.outputs.published }}'
          failed='${{ steps.packages.outputs.failed }}'
          if [ -n "$published" ]; then
            notes="${notes}

          ## Published Packages

          ${published}"
          fi
          if [ -n "$failed" ]; then
            notes="${notes}

          ## Not Yet Published

          ${failed}"
          fi

          {
            echo "notes<<EOF"
            echo "$notes"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_NOTES: ${{ steps.notes.outputs.notes }}
        run: |
          version="${{ needs.rust-version.outputs.version }}"
          tag="v${version}"

          # Collect binary assets if they exist
          assets=""
          if [ -d release-assets ] && ls release-assets/*.tar.gz >/dev/null 2>&1; then
            for f in release-assets/*.tar.gz; do
              assets="$assets $f"
            done
          fi

          if [ -n "$assets" ]; then
            gh release create "${tag}" \
              --title "Release v${version}" \
              --notes "$RELEASE_NOTES" \
              $assets
          else
            gh release create "${tag}" \
              --title "Release v${version}" \
              --notes "$RELEASE_NOTES"
          fi

  ts-release:
    name: create typescript github release
    needs: [ts-version, ts-publish]
    if: needs.ts-version.outputs.bumped == 'true' && needs.ts-publish.result == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Resolve published npm packages
        id: packages
        run: |
          version="${{ needs.ts-version.outputs.version }}"
          published=""
          failed=""

          # ── TypeScript workspace packages ──
          for pkg_dir in apps/*/ packages/*/; do
            pkg_json="${pkg_dir}package.json"
            [ -f "$pkg_json" ] || continue
            is_private=$(node -p "require('./${pkg_json}').private || false" 2>/dev/null || echo "true")
            [ "$is_private" = "true" ] && continue
            name=$(node -p "require('./${pkg_json}').name" 2>/dev/null || continue)
            local_ver=$(node -p "require('./${pkg_json}').version" 2>/dev/null || continue)
            pub_ver=$(npm view "${name}@${local_ver}" version 2>/dev/null || echo "")
            if [ "$pub_ver" = "$local_ver" ]; then
              published="${published}  - \`${name}@${local_ver}\` on npm\n"
            else
              latest_ver=$(npm view "${name}" version 2>/dev/null || echo "")
              failed="${failed}  - \`${name}@${local_ver}\` (published: ${latest_ver:-none})\n"
            fi
          done

          {
            echo "published<<EOF"
            echo -e "$published"
            echo "EOF"
            echo "failed<<EOF"
            echo -e "$failed"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Extract release notes
        id: notes
        run: |
          version="${{ needs.ts-version.outputs.version }}"
          notes=$(node -e "
            const fs = require('fs');
            const ver = process.argv[1];
            const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
            const escaped = ver.replace(/\./g, '\\\\.');
            const re = new RegExp('^## .*' + escaped);
            const lines = changelog.split('\n');
            let found = false;
            const out = [];
            for (const line of lines) {
              if (!found && re.test(line)) { found = true; continue; }
              if (found && /^## /.test(line)) break;
              if (found && /^---$/.test(line)) break;
              if (found) out.push(line);
            }
            process.stdout.write(out.join('\n'));
          " "$version")
          if [ -z "$notes" ]; then
            notes="Release v${version}"
          fi

          # ── Append published packages info ──
          # Use single-quoted assignments so markdown backticks in package
          # names are not interpreted as command substitution.
          published='${{ steps.packages.outputs.published }}'
          failed='${{ steps.packages.outputs.failed }}'
          if [ -n "$published" ]; then
            notes="${notes}

          ## Published Packages

          ${published}"
          fi
          if [ -n "$failed" ]; then
            notes="${notes}

          ## Not Yet Published

          ${failed}"
          fi

          {
            echo "notes<<EOF"
            echo "$notes"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_NOTES: ${{ steps.notes.outputs.notes }}
        run: |
          version="${{ needs.ts-version.outputs.version }}"
          tag="v${version}"
          gh release create "${tag}" \
            --title "Release v${version}" \
            --notes "$RELEASE_NOTES"