#!/usr/bin/env bash
# debian1-primary CI policy. GitHub has no runner priority: any online runner
# carrying the workflow's label is an equal candidate. So eligibility is gated by
# the LABEL, not by the process — the laptop runner holds the gate label only
# while no remote runner can serve, and revoking it cannot disturb a running job.
set -uo pipefail

REPO="${CI_FALLBACK_REPO:-alexcodeplace/multideal}"
UNIT="${CI_FALLBACK_UNIT:-multideal-runner.service}"
LOCAL_RUNNER="${CI_FALLBACK_LOCAL_RUNNER:-multideal-devbox}"
GATE_LABEL="${CI_FALLBACK_GATE_LABEL:-multideal-gate}"
STATE_DIR="${CI_FALLBACK_STATE_DIR:-$HOME/.claude/run/ci-fallback}"
LOG_FILE="${CI_FALLBACK_LOG:-$HOME/.claude/ci-fallback.log}"
START_AFTER="${CI_FALLBACK_START_AFTER:-2}"
STOP_AFTER="${CI_FALLBACK_STOP_AFTER:-10}"
REDUNDANT_AFTER="${CI_FALLBACK_REDUNDANT_AFTER:-2}"

mkdir -p "$STATE_DIR"
log() { printf '%s %s\n' "$(date -Is)" "$*" >> "$LOG_FILE"; }
bump() { local f="$STATE_DIR/$1" n; n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 )); printf '%s' "$n" > "$f"; echo "$n"; }
reset() { printf 0 > "$STATE_DIR/$1"; }

# A stopped unit does not prove a released session: the runner kept reporting online
# from an orphaned listener and every later start died with SessionConflict. The unit
# is KillMode=control-group now, so this should find nothing -- it is the assertion
# that the silent failure cannot come back, not the primary fix. Bracket keeps the
# pattern off this script's own command line.
LISTENER='/home/user/actions-runner-multideal/bin/[R]unner\.Listener'
release_local() {
  local pid tries=0
  while pgrep -f "$LISTENER" >/dev/null 2>&1 && [ "$tries" -lt 10 ]; do
    pid=$(pgrep -f "$LISTENER" | head -1); [ -n "$pid" ] || break
    if [ "$tries" -lt 5 ]; then kill -TERM "$pid" 2>/dev/null
    else log "REAP SIGKILL orphan listener pid=$pid"; kill -KILL "$pid" 2>/dev/null; fi
    tries=$((tries + 1)); sleep 1
  done
  pgrep -f "$LISTENER" >/dev/null 2>&1 && log "WARN orphan listener survives; runner still holds the session"
  return 0
}

# decide <queued> <remote_up> <local_busy> <label_held> -> start|stop|hold
#
# The recruit signal is a queue that PERSISTS, not debian1's busy flag (user
# directive 2026-07-17: "spill after 2 polls, i don't want queue building up").
# debian1 stays primary by construction: a burst it drains inside the START_AFTER
# window never recruits the laptop, so only work still waiting after that spills.
# The laptop's own jobs run under ci.slice caps and stay remote-first, so the spill
# is bounded rather than the peer-to-peer load balancing that melted it before.
decide() {
  local queued="$1" remote_up="$2" local_busy="$3" active="$4"
  if [ "$queued" -gt 0 ]; then
    reset idle; reset redundant
    if [ "$active" != "active" ] && [ "$(bump pressure)" -ge "$START_AFTER" ]; then echo start; return; fi
    echo hold; return
  fi
  reset pressure
  # Queue drained -> retire the laptop, fast when debian1 is alive to catch the next
  # burst, slower when it is not (nothing else would serve). Never mid-job.
  if [ "$active" = "active" ] && [ "$local_busy" -eq 0 ]; then
    if [ "$remote_up" -gt 0 ]; then
      if [ "$(bump redundant)" -ge "$REDUNDANT_AFTER" ]; then reset redundant; reset idle; echo stop; return; fi
    else
      reset redundant
      if [ "$(bump idle)" -ge "$STOP_AFTER" ]; then reset idle; echo stop; return; fi
    fi
  else
    reset idle; reset redundant
  fi
  echo hold
}

main() {
  local runners queued queued_jobs remote_up local_busy active verdict id n local_id
  runners=$(gh api "repos/$REPO/actions/runners" 2>/dev/null) || { log "SKIP runners query failed"; exit 0; }
  queued=$(gh api "repos/$REPO/actions/runs?status=queued&per_page=50" --jq '.workflow_runs | length' 2>/dev/null) || { log "SKIP runs query failed"; exit 0; }
  queued_jobs=0
  for id in $(gh api "repos/$REPO/actions/runs?status=in_progress&per_page=10" --jq '.workflow_runs[].id' 2>/dev/null); do
    n=$(gh api "repos/$REPO/actions/runs/$id/jobs?per_page=100" --jq '[.jobs[] | select(.status=="queued")] | length' 2>/dev/null) && queued_jobs=$((queued_jobs + n))
  done
  queued=$((queued + queued_jobs))
  # A remote runner can only serve the gate if it carries the gate label. Busy
  # counts as up: work waits for debian1 rather than spilling onto the laptop.
  remote_up=$(jq -r --arg local "$LOCAL_RUNNER" --arg lbl "$GATE_LABEL" \
    '[.runners[] | select(.name != $local and .status == "online" and ([.labels[].name] | index($lbl)))] | length' <<<"$runners")
  local_busy=$(jq -r --arg local "$LOCAL_RUNNER" '[.runners[] | select(.name == $local and .busy)] | length' <<<"$runners")
  local_id=$(jq -r --arg local "$LOCAL_RUNNER" '.runners[] | select(.name == $local) | .id' <<<"$runners")
  [ -n "$local_id" ] || { log "SKIP local runner $LOCAL_RUNNER not registered"; exit 0; }
  if jq -e --arg local "$LOCAL_RUNNER" --arg lbl "$GATE_LABEL" \
    '.runners[] | select(.name == $local) | [.labels[].name] | index($lbl)' <<<"$runners" >/dev/null 2>&1; then
    active=active
  else
    active=inactive
  fi
  verdict=$(decide "$queued" "$remote_up" "$local_busy" "$active")
  case "$verdict" in
    start)
      systemctl --user start "$UNIT" || { log "SKIP $UNIT failed to start"; exit 0; }
      gh api -X POST "repos/$REPO/actions/runners/$local_id/labels" -f "labels[]=$GATE_LABEL" >/dev/null 2>&1 \
        && log "GRANT $GATE_LABEL -> $LOCAL_RUNNER queued=$queued remote_up=$remote_up" \
        || log "SKIP label grant failed" ;;
    stop)
      gh api -X DELETE "repos/$REPO/actions/runners/$local_id/labels/$GATE_LABEL" >/dev/null 2>&1 \
        && log "REVOKE $GATE_LABEL <- $LOCAL_RUNNER remote_up=$remote_up" \
        || log "SKIP label revoke failed"
      [ "$local_busy" -eq 0 ] && { systemctl --user stop "$UNIT"; release_local; } ;;
  esac
}

[ "${CI_FALLBACK_NO_MAIN:-0}" = "1" ] || main
