#!/bin/bash
# scripts/cq — cargo-queue: serializes cargo builds across all worktrees
#
# Four protections:
#   1. flock queue — one build gets ALL cores (burst mode), others wait
#   2. CARGO_INCREMENTAL=0 — one-shot commands (test/check/clippy) skip
#      incremental cache writes (saves ~52MB/crate, prevents 15GB bloat)
#   3. Process groups — cargo + all children (rustc, linker, test binaries)
#      run in a dedicated process group. If cq is killed, the entire group
#      is cleaned up. No more orphan rustc at 880% CPU.
#   4. Shared target dir — git worktrees default to the repo-common target/
#      instead of duplicating multi-GB Rust outputs per worktree.
#
# Usage:
#   cq build                 # queued, incremental ON (interactive dev loop)
#   cq test -p my-crate      # queued, incremental OFF; uses nextest when installed
#   cq check                 # queued, incremental OFF
#   cq sweep                 # prune stale incremental caches (>6h old)
#   cq metadata              # pass-through, no queue
#
set -euo pipefail

LOCK_FILE="/tmp/claude-view-cargo.lock"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"

# --- Honor rust-toolchain.toml (Homebrew-shadow guard) ---
# Homebrew's cargo/rustc/cargo-clippy are real binaries that sit in PATH
# ahead of rustup's shims, so they IGNORE rust-toolchain.toml and silently
# run a newer toolchain whose clippy flags lints the pinned channel does
# not (and `rustup run`/`+toolchain` don't fix it — cargo-clippy still
# resolves via PATH). Prepend the pinned toolchain's bin dir so cargo,
# rustc and cargo-clippy all resolve to exactly the pinned channel.
# No pin / unmatched / explicit $CARGO override → behaviour unchanged.
_cq_channel="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/rust-toolchain.toml" 2>/dev/null || true)"
if [[ -n "${_cq_channel:-}" ]]; then
  for _cq_tc_bin in "$HOME"/.rustup/toolchains/"${_cq_channel}"-*/bin; do
    [[ -d "$_cq_tc_bin" ]] && { export PATH="$_cq_tc_bin:$PATH"; break; }
  done
fi

CARGO="${CARGO:-$(command -v cargo)}"
COMMON_GIT_DIR="$(git -C "$ROOT_DIR" rev-parse --git-common-dir)"
case "$COMMON_GIT_DIR" in
  /*) ;;
  *) COMMON_GIT_DIR="$ROOT_DIR/$COMMON_GIT_DIR" ;;
esac
COMMON_ROOT="$(cd "$COMMON_GIT_DIR/.." && pwd)"

if [[ -z "$CARGO" ]]; then
  echo "cargo not found in PATH" >&2
  exit 1
fi

# --- sweep: prune stale incremental compilation caches ---
if [[ "${1:-}" == "sweep" ]]; then
  echo "Sweeping stale incremental caches (>6h old)..."
  swept=0
  for target_dir in \
    "$COMMON_ROOT/target" \
    "$COMMON_ROOT/target-playwright" \
    "$COMMON_ROOT/.worktrees"/*/target \
    "$COMMON_ROOT/.worktrees"/*/target-playwright
  do
    inc_dir="$target_dir/debug/incremental"
    [[ -d "$inc_dir" ]] || continue
    while IFS= read -r -d '' dir; do
      size=$(du -sh "$dir" 2>/dev/null | cut -f1)
      echo "  rm $dir ($size)"
      rm -rf "$dir"
      ((swept++))
    done < <(find "$inc_dir" -maxdepth 1 -type d -mmin +360 -not -path "$inc_dir" -print0 2>/dev/null)
  done
  echo "Swept $swept stale dirs."
  exit 0
fi

# --- pass-through: non-build commands don't need the queue ---
case "${1:-}" in
  build|test|check|clippy|run|bench|doc)
    # CPU-heavy — queue them
    ;;
  *)
    # metadata, version, tree, add, etc. — run immediately
    exec "$CARGO" "$@"
    ;;
esac

# Share Rust build outputs across git worktrees unless the caller overrides it.
# This keeps dev/test artifacts in one cache instead of duplicating multi-GB
# targets per worktree.
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$COMMON_ROOT/target}"

# --- two-phase for `run`: build under lock, then run without lock ---
# `cargo run` = compile + execute. Only the compile phase needs the queue.
# Holding the lock during execution blocks all other cq commands forever
# (e.g. pre-push hooks can't run clippy/test while bun dev is running).
if [[ "${1:-}" == "run" ]]; then
  # Convert 'run' args to 'build' args (strip -- and runtime args)
  build_args=("build")
  for arg in "${@:2}"; do
    [[ "$arg" == "--" ]] && break
    build_args+=("$arg")
  done

  # Phase 1: build under the queue lock (recursive cq call)
  "$0" "${build_args[@]}" || exit $?

  # Phase 2: run without lock — binary already compiled, starts instantly
  exec "$CARGO" "$@"
fi

# --- incremental: OFF for one-shot, ON for interactive dev loop ---
case "${1:-}" in
  test|check|clippy|bench|doc)
    export CARGO_INCREMENTAL=0
    ;;
  # build|run keep incremental ON (interactive dev benefits from cache)
esac

# --- nextest: prefer cargo-nextest for `test` when available ---
# nextest provides: better output, per-test timeout, retries, JUnit XML.
# Falls back to `cargo test` in three cases:
#   1. nextest not installed
#   2. Args contain `--` separator (post-`--` arg semantics differ:
#      cargo test passes them to the test binary, nextest does not.
#      e.g. `-- --nocapture` and `-- --test-threads=1` break under nextest)
#   3. Args contain `--doc` (nextest doesn't support doc tests)
#
# IMPORTANT: This block MUST come AFTER the CARGO_INCREMENTAL case above.
# The incremental case matches $1=="test" to set CARGO_INCREMENTAL=0.
# If we rewrote $1 to "nextest" before that case, incremental would stay ON.
if [[ "${1:-}" == "test" ]]; then
  if command -v cargo-nextest &>/dev/null; then
    # Check for incompatible arg patterns before rewriting
    _cq_use_nextest=true
    for _cq_arg in "$@"; do
      case "$_cq_arg" in
        --)    _cq_use_nextest=false; break ;;
        --doc) _cq_use_nextest=false; break ;;
      esac
    done
    if $_cq_use_nextest; then
      # Rewrite: `cq test <args>` → `cargo nextest run <args>`
      shift
      set -- nextest run "$@"
    fi
  fi
fi

# --- queued execution with process group isolation ---
# Uses perl for two things macOS bash can't do natively:
#   1. flock (macOS has no flock CLI)
#   2. setpgid (put cargo in its own process group for clean kill)
#
# Architecture:
#   perl (parent) ─── flock ──→ fork ──→ child: setpgid(0,0) + exec cargo
#                                │                 └─ rustc, linker inherit pgid
#                                │
#                          waitpid + SIGTERM/SIGINT handler
#                          on signal: kill(-TERM, -$child_pgid)
#
# The parent stays in the ORIGINAL process group (avoids self-kill footgun).
# The child becomes its own group leader. kill with negative PID kills the group.
exec perl -MFcntl=:flock -MPOSIX=:sys_wait_h -e '
  my $lock_file = shift;
  my $cargo = shift;

  # --- acquire flock (queue) ---
  open(my $fh, ">", $lock_file) or die "Cannot open lock $lock_file: $!\n";
  unless (flock($fh, LOCK_EX | LOCK_NB)) {
    my $holder = "";
    if (open(my $pf, "<", $lock_file)) {
      $holder = <$pf> // "";
      chomp $holder;
      close $pf;
    }
    my $msg = "⏳ cargo queue: waiting for another build";
    $msg .= " ($holder)" if $holder;
    print STDERR "$msg\n";
    flock($fh, LOCK_EX) or die "flock: $!\n";
  }

  # Write our identity for other waiters
  truncate($fh, 0);
  seek($fh, 0, 0);
  print $fh join(" ", @ARGV) . "\n";

  # --- fork + process group ---
  my $child = fork();
  die "fork failed: $!\n" unless defined $child;

  if ($child == 0) {
    # Child: new process group, then become cargo
    POSIX::setpgid(0, 0);
    exec($cargo, @ARGV) or die "exec $cargo: $!\n";
  }

  # Parent: forward SIGTERM/SIGINT to child process group
  my $killed = 0;
  for my $sig ("TERM", "INT", "HUP") {
    $SIG{$sig} = sub {
      return if $killed;
      $killed = 1;
      kill("TERM", -$child);  # negative = process group
    };
  }

  # Wait for cargo to finish
  waitpid($child, 0);
  my $status = $?;

  # Reap any stragglers (rustc that ignored SIGTERM)
  kill("TERM", -$child) unless $killed;
  # Brief grace then force-kill
  my $reaped = waitpid(-$child, WNOHANG);
  if (defined $reaped && $reaped == 0) {
    select(undef, undef, undef, 0.5);
    kill("KILL", -$child);
  }

  # Exit with cargo exit code
  if (POSIX::WIFEXITED($status)) {
    exit(POSIX::WEXITSTATUS($status));
  }
  exit(1);
' "$LOCK_FILE" "$CARGO" "$@"
