name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
# Scorecard: Token-Permissions).
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"
  # Network-flake resilience: retry transient registry/DNS failures at the tool
  # level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
  # napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
  # failing the job and needing a manual re-run. Cargo treats "couldn't resolve
  # host" / connect / timeout as spurious and retries with backoff; 10 attempts
  # ride out a transient DNS blip on a runner. Complements the setup-action /
  # cache retries (which only covered toolchain download + cache restore).
  CARGO_NET_RETRY: "10"
  CARGO_NET_GIT_FETCH_WITH_CLI: "true"
  npm_config_fetch_retries: "5"
  npm_config_fetch_retry_maxtimeout: "120000"
  PIP_RETRIES: "5"
  PIP_DEFAULT_TIMEOUT: "120"

jobs:
  rust:
    name: Rust ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust
        with:
          components: rustfmt, clippy

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # CARGO_NET_RETRY rides out short blips, but a longer runner DNS outage
        # outlasts cargo's rapid in-process retries: the crates.io index fetch
        # the first cargo step does ("Could not resolve host: index.crates.io")
        # then fails the whole job. Pre-fetch the dependency graph here with real
        # backoff so clippy/build/test resolve from the warmed local cache.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Format check
        run: cargo fmt --all -- --check

      - name: Clippy (workspace, all targets)
        run: cargo clippy -p wickra-core -p wickra -p wickra-data -p wickra-wasm --all-targets -- -D warnings

      - name: Build
        run: cargo build -p wickra-core -p wickra -p wickra-data --verbose

      - name: Tests (default features)
        run: cargo test -p wickra-core -p wickra -p wickra-data --verbose

      - name: Tests (live-binance feature)
        run: cargo test -p wickra-data --features live-binance --verbose

      - name: Compile benches (smoke)
        run: cargo build -p wickra --benches --verbose

      - name: Compile examples
        # All runnable examples now live in the dedicated wickra-examples crate
        # (examples/rust/src/bin/*.rs) which enables the live-binance feature
        # on its wickra-data dep, so a single --bins build covers backtest,
        # live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and
        # streaming.
        run: cargo build -p wickra-examples --bins

  # Syntax/parse smoke for the non-Rust examples. The Rust examples are built
  # in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
  # browser-WASM and Python examples otherwise have no build gate, so a broken
  # edit could land unnoticed. This is a parse-only smoke — actually running the
  # examples needs the built native binding / wasm module / wheel, which the
  # binding jobs provide separately.
  examples-smoke:
    name: Examples (syntax smoke)
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"

      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - name: Node examples — syntax check
        run: |
          shopt -s nullglob
          count=0
          for f in examples/node/*.js examples/wasm/*.js; do
            echo "node --check $f"
            node --check "$f"
            count=$((count + 1))
          done
          echo "checked $count Node/WASM .js files"

      - name: WASM demo module scripts — syntax check
        # The .html demos embed an ES module; extract it and parse-check so a
        # broken edit to the in-page strategy logic fails CI.
        run: |
          shopt -s nullglob
          count=0
          for f in examples/wasm/*.html; do
            node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
            echo "node --check (module of) $f"
            node --check module-check.mjs
            count=$((count + 1))
          done
          rm -f module-check.mjs
          echo "checked $count WASM .html module scripts"

      - name: Python examples — byte-compile
        run: |
          shopt -s nullglob
          count=0
          for f in examples/python/*.py; do
            echo "py_compile $f"
            python -m py_compile "$f"
            count=$((count + 1))
          done
          echo "compiled $count Python files"

  # Clippy for the Python and Node bindings. These are kept out of the main
  # `rust` job because PyO3 / napi build scripts need a Python interpreter and
  # a Node toolchain on PATH, which the 3-OS matrix job does not provision.
  # Ubuntu-only is sufficient: the lints are platform-independent.
  clippy-bindings:
    name: Clippy bindings
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust
        with:
          components: clippy

      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
        with:
          python-version: "3.12"

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
        with:
          python-version: "3.12"

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "22"

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # See the rust job: pre-fetch with backoff so the clippy index update
        # can't fail the job on a transient "Could not resolve host" DNS blip.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Clippy (bindings, all targets)
        run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings

  # Verify the crates still build and test on their declared minimum supported
  # Rust version. The workspace pins rust-version = "1.86" — that floor is
  # set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
  # clap_lex 1.1.0 / edition2024 / Rust 1.85 floor and now needs 1.86; the
  # earlier rayon-core 1.13.0 (1.80) and clap_lex (1.85) requirements are
  # subsumed. bindings/node pins rust-version = "1.88" because napi-build
  # 2.3.2 requires it (and that subsumes the older 1.77 floor needed for
  # `cargo::` directives). Without this job an accidental use of a newer
  # API would only surface for downstream users.
  msrv:
    name: ${{ matrix.name }}
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: MSRV workspace (Rust 1.86)
            toolchain: "1.86"
            packages: "-p wickra-core -p wickra -p wickra-data"
          - name: MSRV node binding (Rust 1.88)
            toolchain: "1.88"
            packages: "-p wickra-node"
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust ${{ matrix.toolchain }}
        uses: ./.github/actions/setup-rust
        with:
          toolchain: ${{ matrix.toolchain }}

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # See the rust job: pre-fetch with backoff so the first cargo step can't
        # fail the job on a transient "Could not resolve host" DNS blip.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Build on MSRV
        run: cargo build ${{ matrix.packages }} --verbose

      - name: Test on MSRV
        run: cargo test ${{ matrix.packages }} --verbose

  # Code coverage for the pure-Rust crates, uploaded to Codecov.
  coverage:
    name: Coverage
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust
        with:
          components: llvm-tools-preview

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install cargo-llvm-cov
        uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cargo-llvm-cov

      - name: Generate coverage (lcov)
        run: >
          cargo llvm-cov
          -p wickra-core -p wickra -p wickra-data
          --features wickra-data/live-binance
          --lcov --output-path lcov.info

      - name: Upload to Codecov
        uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
        with:
          files: lcov.info
          fail_ci_if_error: false
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  # Supply-chain audit: security advisories, license policy, banned crates,
  # and source restrictions. Configured by deny.toml at the repo root.
  supply-chain:
    name: Supply-chain (cargo-deny)
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: cargo-deny
        uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
        with:
          command: check

  # Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic
  # fails the job. The goal is to catch a regression in the harness (e.g. a
  # newly added indicator that panics on a particular input shape), not to
  # discover novel bugs — long fuzz campaigns should be run on dedicated
  # infrastructure with persistent corpora.
  fuzz-smoke:
    name: Fuzz (smoke)
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install nightly Rust
        uses: ./.github/actions/setup-rust
        with:
          toolchain: nightly

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6
        with:
          workspaces: fuzz

      - name: Install cargo-fuzz
        # Use the prebuilt binary from taiki-e/install-action instead of
        # `cargo install cargo-fuzz --locked`. The latter resolves the
        # version graph from cargo-fuzz's own Cargo.lock, which pins to
        # rustix 0.36.5 — a version that still uses internal `rustc_*`
        # attributes the modern nightly compiler rejects, so the install
        # never gets off the ground. The prebuilt binary avoids the entire
        # transitive-dep compile.
        uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cargo-fuzz

      - name: Fuzz csv_reader (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu csv_reader -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz binance_envelope (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu binance_envelope -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz indicator_update (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz indicator_update_candle (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update_candle -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz tick_aggregator (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu tick_aggregator -- -max_total_time=30
        working-directory: fuzz

  python:
    name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.9", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # setup-python downloads the interpreter from the Actions tool cache /
      # nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
      # Run it with continue-on-error, then retry once after a backoff so a
      # single CDN flake does not fail the whole job (see also: GitHub
      # Actions runner-images#7061).
      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: .github/requirements/ci-dev-*.txt

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: .github/requirements/ci-dev-*.txt

      - name: Install Python dev dependencies
        shell: bash
        run: |
          python -m pip install --upgrade pip
          # Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
          # Split by Python version: numpy ships no single release with wheels
          # for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
          if [ "${{ matrix.python-version }}" = "3.9" ]; then
            python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
          else
            python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
          fi

      - name: Build wheel
        working-directory: bindings/python
        run: maturin build --release --out dist

      - name: Install wheel
        shell: bash
        working-directory: bindings/python
        # --no-index forces pip to ignore PyPI; --no-deps skips re-resolving
        # numpy (already installed in the previous step). Without --no-index
        # pip prefers the PyPI 0.2.x wheel over our freshly built one when
        # platform tags overlap (e.g. macOS arm64), so tests would run
        # against the released package and miss any new symbols the PR adds.
        run: python -m pip install --no-index --find-links dist --force-reinstall --no-deps wickra

      - name: Run Python tests
        working-directory: bindings/python
        run: pytest -v

  wasm:
    name: WASM build
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain (with wasm target)
        uses: ./.github/actions/setup-rust
        with:
          targets: wasm32-unknown-unknown

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install wasm-pack
        # jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
        # old wasm-pack (~0.10.x) whose `build` subcommand does not yet accept
        # `--features`, so `wasm-pack build … --features panic-hook` fails
        # with "Found argument '--features' which wasn't expected". Use the
        # same taiki-e prebuilt-binary installer we already use for
        # cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
        # release, which has `--features` as a top-level flag (since 0.12).
        uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: wasm-pack

      - name: Build WASM package
        run: wasm-pack build bindings/wasm --target web --release --features panic-hook

      - name: Run WASM tests
        run: wasm-pack test --node bindings/wasm

      - name: Verify generated artefacts
        run: |
          test -f bindings/wasm/pkg/wickra_wasm.js
          test -f bindings/wasm/pkg/wickra_wasm_bg.wasm
          test -f bindings/wasm/pkg/wickra_wasm.d.ts

      - name: Build WASM package (nodejs target) for the golden suite
        run: wasm-pack build bindings/wasm --target nodejs --release --out-dir pkg

      - name: Golden parity — all 514 indicators vs the Rust reference
        run: node --test bindings/wasm/tests/golden.test.js

  node:
    name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: ["22", "24"]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # setup-node downloads Node from nodejs.org and we've seen it fail on
      # Windows runners with "Attempting to download 18..." followed by a
      # silent hang or curl error. Retry once after a backoff so a single
      # CDN flake does not fail the whole job.
      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
          cache-dependency-path: bindings/node/package-lock.json

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
          cache-dependency-path: bindings/node/package-lock.json

      # npm's own fetch-retry (npm_config_fetch_retries) rides out per-request
      # blips; wrap the whole `npm ci` once more so a longer registry hiccup
      # retries the install instead of failing the job.
      - name: Install Node dependencies
        uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
        with:
          timeout_minutes: 10
          max_attempts: 3
          retry_wait_seconds: 20
          command: cd bindings/node && npm ci
          shell: bash

      - name: Build native module
        working-directory: bindings/node
        # --platform puts the target triple into the filename so the loader's
        # `wickra.<target>.node` lookup finds the freshly built binary instead
        # of falling back to the per-platform npm subpackage (which doesn't
        # exist yet for win32-x64-msvc).
        run: npx napi build --platform --release

      # The Node test process has wedged on macOS runners (the step hung for
      # 1h+ while the same tests passed in ~1.5 min elsewhere). Wrap it so a
      # hung attempt is killed after 6 min and retried once, rather than
      # running up to the job-level backstop. A normal run is under a minute.
      - name: Run Node tests
        uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
        with:
          timeout_minutes: 6
          max_attempts: 2
          command: cd bindings/node && node --test
          shell: bash

  c-abi:
    name: C ABI on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install cbindgen
        uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cbindgen

      - name: Build the C ABI library (cdylib + staticlib)
        run: cargo build -p wickra-c --release

      - name: Rust unit tests
        run: cargo test -p wickra-c

      # The generated header is platform-independent, so checking drift on one OS
      # is enough — and avoids a spurious CRLF/LF diff on the Windows runner.
      - name: Check the committed header is in sync with cbindgen
        if: runner.os == 'Linux'
        shell: bash
        run: |
          cbindgen --config bindings/c/cbindgen.toml --crate wickra-c --output bindings/c/include/wickra.h
          if ! git diff --quiet -- bindings/c/include/wickra.h; then
            echo "::error::bindings/c/include/wickra.h is out of sync — run cbindgen and commit the result"
            git --no-pager diff -- bindings/c/include/wickra.h
            exit 1
          fi

      # The real cross-language test: a foreign C consumer links the generated
      # header + the compiled library and runs. If this passes on all three OSes,
      # every C-capable language can link the same way.
      - name: Build and run the C smoke example (CMake + ctest)
        shell: bash
        run: |
          cmake -S examples/c -B examples/c/build
          cmake --build examples/c/build --config Release
          ctest --test-dir examples/c/build -C Release --output-on-failure

  csharp:
    name: C# on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # Cache the restored NuGet packages so dotnet test/build resolve from the
      # local store instead of hitting nuget.org every run. No packages.lock.json
      # exists, so key on the project files; never block the job on a slow restore.
      - name: Cache NuGet packages
        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
        continue-on-error: true
        timeout-minutes: 6
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
          restore-keys: |
            ${{ runner.os }}-nuget-

      # The binding links against the C ABI hub at runtime; build it first so the
      # DllImportResolver finds target/release/wickra.{dll,so,dylib}. .NET 8 SDK is
      # preinstalled on the GitHub runners, so no setup-dotnet step is needed.
      - name: Build the C ABI library
        run: cargo build -p wickra-c --release

      - name: .NET info
        run: dotnet --info

      # dotnet test restores from nuget.org first; retry so a transient restore
      # blip retries instead of failing the job (the cached packages above make
      # the retry cheap).
      - name: Test the C# binding
        uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
        with:
          timeout_minutes: 15
          max_attempts: 2
          retry_wait_seconds: 20
          command: dotnet test bindings/csharp/Wickra.Tests/Wickra.Tests.csproj -c Release
          shell: bash

      - name: Build the C# examples
        shell: bash
        run: |
          for d in streaming backtest multi_timeframe parallel_assets \
                   strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze \
                   fetch_btcusdt live_binance; do
            dotnet build "examples/csharp/$d" -c Release
          done

      # Run only the offline examples (fetch_btcusdt / live_binance need network).
      - name: Run the offline C# examples
        shell: bash
        run: |
          for d in streaming backtest multi_timeframe parallel_assets \
                   strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
            dotnet run --project "examples/csharp/$d" -c Release --no-build
          done

  go:
    name: Go on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      # Go is not reliably on PATH on every runner image, so install it
      # explicitly. cache: false — the cargo build dominates and the modules
      # have no external Go deps worth caching.
      - name: Set up Go
        id: setup-go
        continue-on-error: true
        uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version: "stable"
          cache: false

      - name: Retry Go setup (CDN flake)
        if: steps.setup-go.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-go failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Go (retry)
        if: steps.setup-go.outcome == 'failure'
        uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version: "stable"
          cache: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # The binding links against the C ABI hub via cgo; build it first and stage
      # the platform library under bindings/go/lib so cgo's LDFLAGS find it. Go is
      # preinstalled on the GitHub runners, so no setup-go step is needed.
      - name: Build the C ABI library
        run: cargo build -p wickra-c --release

      - name: Vendored header in sync with the C ABI
        shell: bash
        # bindings/go/include/wickra.h is a committed copy of the cbindgen header
        # (the parent ../c/include is outside the Go module, so it must be
        # vendored). Fail if it drifts from the source of truth.
        run: |
          if ! diff -u bindings/c/include/wickra.h bindings/go/include/wickra.h; then
            echo "::error::bindings/go/include/wickra.h is stale — copy bindings/c/include/wickra.h over it"
            exit 1
          fi

      - name: Stage the native library
        shell: bash
        # Stage into lib/<goos>_<goarch>/ to match the per-platform cgo LDFLAGS.
        # CI builds the host target, so RUNNER_OS/ARCH give the right directory
        # (note macos-latest is arm64).
        run: |
          case "$RUNNER_ARCH" in
            X64) arch=amd64 ;;
            ARM64) arch=arm64 ;;
            *) echo "::error::unsupported RUNNER_ARCH '$RUNNER_ARCH'"; exit 1 ;;
          esac
          case "$RUNNER_OS" in
            Linux)   dir="linux_$arch";   lib=target/release/libwickra.so ;;
            macOS)   dir="darwin_$arch";  lib=target/release/libwickra.dylib ;;
            Windows) dir="windows_$arch"; lib=target/release/wickra.dll ;;
          esac
          mkdir -p "bindings/go/lib/$dir"
          cp "$lib" "bindings/go/lib/$dir/"
          echo "WICKRA_GO_LIBDIR=$PWD/bindings/go/lib/$dir" >> "$GITHUB_ENV"

      - name: Go info
        run: go version

      - name: Check gofmt
        shell: bash
        run: |
          unformatted="$(gofmt -l bindings/go examples/go)"
          if [ -n "$unformatted" ]; then
            echo "gofmt needed on:"; echo "$unformatted"; exit 1
          fi

      - name: Vet and test the Go binding
        shell: bash
        # On Windows there is no rpath; the loader resolves wickra.dll via PATH
        # (WICKRA_GO_LIBDIR is the per-platform staged lib dir). Linux/macOS use
        # the rpath baked by the per-platform cgo LDFLAGS.
        run: |
          export PATH="$WICKRA_GO_LIBDIR:$PATH"
          cd bindings/go
          go vet ./...
          go test ./...

      - name: Build the Go examples
        shell: bash
        run: |
          export PATH="$WICKRA_GO_LIBDIR:$PATH"
          cd examples/go
          go build ./...

      # Run only the offline examples (fetch_btcusdt / live_binance need network).
      - name: Run the offline Go examples
        shell: bash
        run: |
          export PATH="$WICKRA_GO_LIBDIR:$PATH"
          cd examples/go
          for d in streaming backtest multi_timeframe parallel_assets \
                   strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
            go run "./$d"
          done

  r:
    name: R on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    env:
      WICKRA_INCLUDE_DIR: ${{ github.workspace }}/bindings/c/include
      WICKRA_LIB_DIR: ${{ github.workspace }}/target/release
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true
        timeout-minutes: 6

      # The binding compiles a thin .Call glue layer against the C ABI hub; build
      # the library first. On Windows configure.win bundles a renamed copy
      # (wickra_abi.dll) so the package's own wickra.dll does not collide with it.
      - name: Build the C ABI library
        run: cargo build -p wickra-c --release

      - name: Set up R
        uses: r-lib/actions/setup-r@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2
        with:
          r-version: "release"
          use-public-rspm: true

      # Install the R dependencies via setup-r-dependencies: it restores a cached
      # package library (actions/cache) and pulls RSPM *binaries* instead of
      # compiling testthat / knitr and their deps from source — the slow, flaky
      # path that previously blew past the job timeout on the ubuntu runner.
      - name: Install R dependencies (cached binaries)
        uses: r-lib/actions/setup-r-dependencies@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2
        with:
          working-directory: bindings/r
          extra-packages: |
            any::testthat
            any::knitr

      - name: Install and test the R binding
        shell: bash
        # github.workspace is a backslash path on Windows; configure(.win) (sh) and
        # mingw need forward slashes. WICKRA_*_DIR makes configure use the locally
        # built C ABI (dev override) instead of downloading the release asset, so
        # CI is version-independent. Deliberately do NOT set LD_LIBRARY_PATH /
        # DYLD_LIBRARY_PATH: the lib is bundled into the package and must resolve
        # via the rpath ($ORIGIN / @loader_path) baked by configure — that is the
        # self-contained install path real users (and r-universe) get.
        run: |
          export WICKRA_INCLUDE_DIR="${WICKRA_INCLUDE_DIR//\\//}"
          export WICKRA_LIB_DIR="${WICKRA_LIB_DIR//\\//}"
          R CMD INSTALL bindings/r
          Rscript -e 'library(testthat); library(wickra); test_dir("bindings/r/tests/testthat", stop_on_failure = TRUE)'

      # Full R CMD check, gated on the documentation problems r-universe surfaces
      # (undocumented exported objects, codoc mismatches) that R CMD INSTALL above
      # does not catch — exactly what shipped stale to r-universe with the 0.9.3
      # data layer. Ubuntu-only; these checks are platform-independent. Vignettes
      # are skipped here (building them needs pandoc and the vignette is exercised
      # in the next step), so the two --no-build-vignettes warnings are ignored.
      - name: R CMD check (documentation & consistency, like r-universe)
        if: matrix.os == 'ubuntu-latest'
        shell: bash
        run: |
          export WICKRA_INCLUDE_DIR="${WICKRA_INCLUDE_DIR//\\//}"
          export WICKRA_LIB_DIR="${WICKRA_LIB_DIR//\\//}"
          R CMD build bindings/r --no-build-vignettes --no-manual
          R CMD check wickra_*.tar.gz --no-manual --no-vignettes --no-tests || true
          log=$(find . -maxdepth 2 -name 00check.log | head -1)
          echo "::group::00check.log"; cat "$log"; echo "::endgroup::"
          problems=$(grep -E '\.\.\. (WARNING|ERROR)' "$log" \
            | grep -vE "checking (files in .vignettes.|package vignettes)" || true)
          if [ -n "$problems" ]; then
            echo "::error::R CMD check found problems (run roxygen2::roxygenise() in bindings/r if the docs are stale):"
            echo "$problems"
            exit 1
          fi
          echo "R CMD check: documentation and consistency clean."

      - name: Build the vignette code
        shell: bash
        # The getting-started vignette runs at R CMD check time on r-universe /
        # CRAN (with pandoc); this job only INSTALLs, so execute the vignette's R
        # chunks here (knit, no pandoc needed) to catch a broken example before it
        # reaches the published build.
        # knitr is installed by the cached setup-r-dependencies step above.
        run: |
          Rscript -e 'knitr::knit("bindings/r/vignettes/getting-started.Rmd", output = tempfile(fileext = ".md"), quiet = TRUE); cat("vignette code OK\n")'

      - name: Run the offline R examples
        shell: bash
        # No loader-path exports: the installed package is self-contained (bundled
        # lib + rpath), so the examples exercise exactly what end users run.
        run: |
          cd examples/r
          for f in streaming backtest multi_timeframe parallel_assets \
                   strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
            Rscript "$f.R"
          done

      # fetch_btcusdt / live_binance need the network (and jsonlite / websocket);
      # build-check that they parse without running them.
      - name: Parse the network R examples
        run: Rscript -e 'invisible(lapply(c("examples/r/fetch_btcusdt.R", "examples/r/live_binance.R"), parse)); cat("network R examples parse OK\n")'

  java:
    name: Java on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30 # backstop: cap a wedged job instead of GitHub's 6h default (headroom for slow registry/package installs)
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: ./.github/actions/setup-rust

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # The binding links the C ABI hub at runtime through the Java FFM API; build
      # it first so WickraNative's development fallback finds
      # target/release/wickra.{so,dylib,dll}. The FFM API is final since JDK 22; we
      # build on the 25 LTS (22 is EOL; the pom pins bytecode to release 22 so the
      # runtime floor stays Java 22). Installed explicitly (runners ship 17/21).
      - name: Build the C ABI library
        run: cargo build -p wickra-c --release

      - name: Set up JDK 25
        id: setup-java
        continue-on-error: true
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          distribution: temurin
          java-version: "25"
          cache: maven

      - name: Retry JDK setup (CDN flake)
        if: steps.setup-java.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-java failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up JDK 25 (retry)
        if: steps.setup-java.outcome == 'failure'
        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
        with:
          distribution: temurin
          java-version: "25"
          cache: maven

      - name: Java info
        run: java -version

      # `install` runs the archetype test suite (the real FFI boundary check) and
      # installs the binding to the local repo so the examples can resolve it.
      # Maven resolves plugins/deps from the network on a cache miss; retry so a
      # transient Central blip retries instead of failing the job.
      - name: Test and install the Java binding
        uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
        with:
          timeout_minutes: 20
          max_attempts: 2
          retry_wait_seconds: 20
          command: mvn -B -f bindings/java install
          shell: bash

      - name: Build the Java examples
        run: mvn -B -f examples/java compile

      # Run only the offline examples (fetch_btcusdt / live_binance need network).
      - name: Run the offline Java examples
        shell: bash
        run: |
          for cls in Streaming Backtest MultiTimeframe ParallelAssets \
                     StrategyRsiMeanReversion StrategyMacdAdx StrategyBollingerSqueeze; do
            mvn -B -q -f examples/java exec:exec -Dexec.mainClass="org.wickra.examples.$cls"
          done

  # Build a Python wheel inside both the manylinux and the musllinux container,
  # mirroring the Linux wheel build in release.yml. The 3-OS Python jobs build
  # natively on the runner, which already ships system OpenSSL, so they cannot
  # catch a build-time gap that only exists inside the slim release containers —
  # exactly what broke the 0.9.3 Linux wheels (the live-binance data layer links
  # native-tls -> openssl-sys, and the containers provide no OpenSSL: manylinux
  # lacks the headers, musllinux cross-compiles against a musl sysroot without
  # OpenSSL at all). The wheels are built with the `vendored-tls` feature, which
  # statically compiles OpenSSL from source. This job exercises both container
  # builds on every PR, so the same class of breakage now fails CI, not release.
  python-wheel-container-smoke:
    name: Python wheel (${{ matrix.manylinux }} smoke)
    runs-on: ubuntu-latest
    timeout-minutes: 30 # backstop: vendored OpenSSL adds a from-source compile
    strategy:
      fail-fast: false
      matrix:
        manylinux: [auto, musllinux_1_2]
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          persist-credentials: false

      - name: Sync root README into bindings/python so the build matches release
        run: cp README.md bindings/python/README.md

      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          working-directory: bindings/python
          target: x86_64
          # Keep in sync with release.yml: vendored OpenSSL for the Linux wheels.
          args: --release --out dist --features vendored-tls
          manylinux: ${{ matrix.manylinux }}
          # Building OpenSSL from source needs Perl modules (IPC::Cmd,
          # Time::Piece, ...) the minimal manylinux (CentOS 7) image lacks.
          # perl-core pulls the full distribution; the explicit names document
          # the ones OpenSSL's Configure has required. The musllinux cross image
          # ships a complete Perl and has no yum, so this is a no-op there.
          before-script-linux: |
            if command -v yum >/dev/null 2>&1; then yum install -y perl-core perl-IPC-Cmd perl-Data-Dumper perl-Time-Piece; fi

  # The cross-library benchmark has moved to a dedicated scheduled workflow
  # (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
  # at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
  # the every-push / every-PR critical path.
