name: CI

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      release:
        description: "Simulate a release publish (runs build + publish jobs)"
        type: boolean
        default: true

permissions:
  contents: read

jobs:
  # -------------------------------------------------------------------------
  # Version consistency — Cargo.toml and pyproject.toml must have same version
  # -------------------------------------------------------------------------
  changelog-check:
    name: CHANGELOG has [Unreleased] entry
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v6
      - name: Check CHANGELOG.md
        run: python3 scripts/check_changelog.py

  version-check:
    name: Version consistency (Cargo.toml == pyproject.toml)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Check version parity
        run: |
          CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
          PYPROJECT_VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
          echo "Cargo.toml version:     $CARGO_VERSION"
          echo "pyproject.toml version: $PYPROJECT_VERSION"
          if [ "$CARGO_VERSION" != "$PYPROJECT_VERSION" ]; then
            echo "ERROR: Version mismatch! Update both files before releasing."
            exit 1
          fi
          echo "OK: versions match ($CARGO_VERSION)"

  # -------------------------------------------------------------------------
  # Modularized CI suites.
  # Keep release publishing jobs in this file so trusted-publisher bindings
  # remain anchored to CI.yml for PyPI and crates.io.
  # -------------------------------------------------------------------------
  rust-suite:
    name: Rust and audit
    uses: ./.github/workflows/ci-rust.yml

  python-suite:
    name: Python quality and tests
    uses: ./.github/workflows/ci-python.yml

  wasm:
    name: WASM binding
    uses: ./.github/workflows/ci-wasm.yml

  docs:
    name: Documentation
    uses: ./.github/workflows/ci-docs.yml
    with:
      upload-pages-artifact: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}

  # -------------------------------------------------------------------------
  # Deploy docs to GitHub Pages (on push to main only)
  # -------------------------------------------------------------------------
  deploy-docs:
    name: Deploy docs to GitHub Pages
    runs-on: ubuntu-latest
    needs: docs
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    permissions:
      pages: write
      id-token: write
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v5

  # -------------------------------------------------------------------------
  # CI gate — all required jobs must pass before this job succeeds.
  # Set "ci-complete" as a required status check in branch protection rules
  # to block merging of PRs that fail any required job.
  # -------------------------------------------------------------------------
  ci-complete:
    name: CI complete (required gate)
    runs-on: ubuntu-latest
    needs:
      - changelog-check
      - version-check
      - rust-suite
      - python-suite
      - wasm
      - docs
    if: always()
    steps:
      - name: Check all required jobs passed
        run: |
          results='${{ toJSON(needs) }}'
          echo "Job results: $results"
          failed="$(
            RESULTS_JSON="$results" python3 - <<'PY'
          import json
          import os

          needs = json.loads(os.environ["RESULTS_JSON"])
          failed = [
              name
              for name, data in needs.items()
              if data["result"] not in ("success", "skipped")
          ]
          if failed:
              print(" ".join(failed))
          PY
          )"
          if [ -n "$failed" ]; then
            echo "FAILED jobs: $failed"
            exit 1
          fi
          echo "All required CI jobs passed."

  # -------------------------------------------------------------------------
  # Build wheels for all platforms and publish to PyPI on release
  # Keep release jobs here because trusted-publisher configuration points to
  # CI.yml specifically.
  # -------------------------------------------------------------------------
  # One abi3 wheel per (platform, arch). abi3-py310 means a single wheel
  # covers CPython 3.10+ on each target, so there is no python-version axis.
  # extension-module + abi3 link no libpython, which is what lets the linux
  # jobs cross-compile aarch64/musl from an x86_64 runner via maturin-action.

  build-wheels-linux:
    name: Build wheels (linux-gnu / ${{ matrix.target }})
    runs-on: ubuntu-latest
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    strategy:
      fail-fast: false
      matrix:
        target: [x86_64, aarch64]

    steps:
      - uses: actions/checkout@v6

      - name: Build abi3 manylinux wheel
        uses: PyO3/maturin-action@v1
        with:
          command: build
          target: ${{ matrix.target }}
          args: --release --out dist
          manylinux: "2_17"

      - name: Upload wheels as artifact
        uses: actions/upload-artifact@v7
        with:
          name: wheels-linux-gnu-${{ matrix.target }}
          path: dist/*.whl

  build-wheels-musllinux:
    name: Build wheels (linux-musl / ${{ matrix.target }})
    runs-on: ubuntu-latest
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    strategy:
      fail-fast: false
      matrix:
        target: [x86_64, aarch64]

    steps:
      - uses: actions/checkout@v6

      - name: Build abi3 musllinux wheel
        uses: PyO3/maturin-action@v1
        with:
          command: build
          target: ${{ matrix.target }}
          args: --release --out dist
          manylinux: musllinux_1_2

      - name: Upload wheels as artifact
        uses: actions/upload-artifact@v7
        with:
          name: wheels-linux-musl-${{ matrix.target }}
          path: dist/*.whl

  build-wheels-macos:
    name: Build wheels (macos / universal2)
    runs-on: macos-latest
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    steps:
      - uses: actions/checkout@v6

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.10"

      - name: Build universal2 abi3 wheel
        uses: PyO3/maturin-action@v1
        with:
          command: build
          target: universal2-apple-darwin
          args: --release --out dist

      - name: Upload wheels as artifact
        uses: actions/upload-artifact@v7
        with:
          name: wheels-macos-universal2
          path: dist/*.whl

  build-wheels-windows:
    name: Build wheels (windows / ${{ matrix.platform.arch }})
    runs-on: ${{ matrix.platform.runner }}
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    strategy:
      fail-fast: false
      matrix:
        platform:
          - runner: windows-latest
            arch: x64
            target: x64
          - runner: windows-11-arm
            arch: arm64
            target: aarch64

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"
          architecture: ${{ matrix.platform.arch }}

      - name: Build abi3 wheel
        uses: PyO3/maturin-action@v1
        with:
          command: build
          target: ${{ matrix.platform.target }}
          args: --release --out dist

      - name: Upload wheels as artifact
        uses: actions/upload-artifact@v7
        with:
          name: wheels-windows-${{ matrix.platform.arch }}
          path: dist/*.whl

  build-sdist:
    name: Build source distribution
    runs-on: ubuntu-latest
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    steps:
      - uses: actions/checkout@v6

      - name: Build sdist
        uses: PyO3/maturin-action@v1
        with:
          command: sdist
          args: --out dist

      - name: Upload sdist as artifact
        uses: actions/upload-artifact@v7
        with:
          name: sdist
          path: dist/*.tar.gz

  # -------------------------------------------------------------------------
  # Publish to PyPI
  # -------------------------------------------------------------------------
  publish:
    name: Publish to PyPI
    runs-on: ubuntu-latest
    needs:
      - build-wheels-linux
      - build-wheels-musllinux
      - build-wheels-macos
      - build-wheels-windows
      - build-sdist
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    environment:
      name: pypi
      url: https://pypi.org/p/ferro-ta
    permissions:
      id-token: write
      attestations: write
      contents: read
    steps:
      - name: Download all wheels
        uses: actions/download-artifact@v8
        with:
          pattern: wheels-*
          merge-multiple: true
          path: dist

      - name: Download source distribution
        uses: actions/download-artifact@v8
        with:
          name: sdist
          path: dist

      - name: Generate SLSA build provenance attestations
        uses: actions/attest-build-provenance@v2
        with:
          subject-path: "dist/*"

      - name: Verify distribution coverage
        run: |
          python3 - <<'PY'
          import fnmatch
          import sys
          from pathlib import Path

          dist = Path("dist")
          files = sorted(p.name for p in dist.iterdir() if p.is_file())
          print("Distributions:")
          for name in files:
              print(f" - {name}")

          # One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
          expected = [
              "ferro_ta-*-cp310-abi3-manylinux*_x86_64.whl",
              "ferro_ta-*-cp310-abi3-manylinux*_aarch64.whl",
              "ferro_ta-*-cp310-abi3-musllinux*_x86_64.whl",
              "ferro_ta-*-cp310-abi3-musllinux*_aarch64.whl",
              "ferro_ta-*-cp310-abi3-macosx*_universal2.whl",
              "ferro_ta-*-cp310-abi3-win_amd64.whl",
              "ferro_ta-*-cp310-abi3-win_arm64.whl",
              "ferro_ta-*.tar.gz",
          ]

          missing = [
              pattern for pattern in expected
              if not any(fnmatch.fnmatch(name, pattern) for name in files)
          ]
          if missing:
              print("Missing expected distributions:")
              for pattern in missing:
                  print(f" - {pattern}")
              sys.exit(1)
          PY

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1

  # -------------------------------------------------------------------------
  # Publish ferro_ta_core to crates.io (requires CARGO_REGISTRY_TOKEN secret)
  # -------------------------------------------------------------------------
  publish-cratesio:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    steps:
      - uses: actions/checkout@v6

      - name: Install Rust
        uses: dtolnay/rust-toolchain@v1
        with:
          toolchain: stable

      - name: Publish ferro_ta_core to crates.io
        run: cargo publish -p ferro_ta_core --token ${{ secrets.CARGO_REGISTRY_TOKEN }}

  # -------------------------------------------------------------------------
  # SBOM generation — Software Bill of Materials for supply-chain transparency
  # Generates SBOMs for both Python (syft/CycloneDX) and Rust (cargo-cyclonedx)
  # and uploads them as GitHub Release assets.
  # -------------------------------------------------------------------------
  sbom:
    name: Generate SBOM (Python + Rust)
    runs-on: ubuntu-latest
    needs:
      - publish
      - publish-cratesio
    if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
    permissions:
      contents: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@v6

      - name: Set up Python 3.12
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"

      - name: Install maturin
        run: pip install maturin numpy

      - name: Build and install ferro_ta wheel
        run: |
          maturin build --release --out dist
          pip install dist/*.whl

      - name: Generate Python SBOM (CycloneDX via anchore/sbom-action)
        uses: anchore/sbom-action@v0.24.0
        with:
          artifact-name: ferro-ta-python-sbom.spdx.json
          output-file: ferro-ta-python-sbom.spdx.json
          format: spdx-json

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@v1
        with:
          toolchain: stable

      - name: Install cargo-cyclonedx
        run: cargo install cargo-cyclonedx --locked

      - name: Generate Rust SBOM (CycloneDX)
        run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx

      - name: Validate SBOMs (schema check)
        run: |
          python3 -c "import json, sys; json.load(open('ferro-ta-python-sbom.spdx.json')); print('Python SBOM valid JSON')"
          python3 -c "import json, sys; data=json.load(open('ferro-ta-rust-sbom.cdx.json')); assert data.get('bomFormat')=='CycloneDX', 'not CycloneDX'; print('Rust SBOM valid CycloneDX')"

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign SBOMs with cosign (keyless)
        env:
          COSIGN_EXPERIMENTAL: "1"
        run: |
          cosign sign-blob --yes ferro-ta-python-sbom.spdx.json --output-signature ferro-ta-python-sbom.spdx.json.sig --output-certificate ferro-ta-python-sbom.spdx.json.pem
          cosign sign-blob --yes ferro-ta-rust-sbom.cdx.json     --output-signature ferro-ta-rust-sbom.cdx.json.sig     --output-certificate ferro-ta-rust-sbom.cdx.json.pem

      - name: Attest SBOM provenance
        uses: actions/attest-build-provenance@v2
        with:
          subject-path: |
            ferro-ta-python-sbom.spdx.json
            ferro-ta-rust-sbom.cdx.json

      - name: Upload Python SBOM to release
        uses: softprops/action-gh-release@v3
        with:
          files: |
            ferro-ta-python-sbom.spdx.json
            ferro-ta-python-sbom.spdx.json.sig
            ferro-ta-python-sbom.spdx.json.pem

      - name: Upload Rust SBOM to release
        uses: softprops/action-gh-release@v3
        with:
          files: |
            ferro-ta-rust-sbom.cdx.json
            ferro-ta-rust-sbom.cdx.json.sig
            ferro-ta-rust-sbom.cdx.json.pem
