#!/usr/bin/env bash
# swarm-state: minimal state, task and lock manager for tmux AI swarm workflows.
set -euo pipefail

SWARM_DIR="${AUTO_TMUX_SWARM_DIR:-/tmp/ai_swarm}"
STATUS_LOG="$SWARM_DIR/status.log"
TASKS_TSV="$SWARM_DIR/tasks.tsv"
DEPS_TSV="$SWARM_DIR/deps.tsv"
LOCKS_DIR="$SWARM_DIR/locks"
RESULTS_DIR="$SWARM_DIR/results"
STATE_LOCK_DIR="$SWARM_DIR/.state.lock.d"

usage() {
  cat <<'EOF'
swarm-state: tmux AI swarm state/task/lock helper

Usage:
  swarm-state.sh init [--dir DIR]
  swarm-state.sh log --target TARGET --status STATUS --message TEXT [--dir DIR]
  swarm-state.sh status [--dir DIR]
  swarm-state.sh task-add --text TEXT [--id ID] [--dir DIR]
  swarm-state.sh task-import --file FILE [--prefix PREFIX] [--dir DIR]
  swarm-state.sh task-list [--status STATUS] [--owner TARGET] [--dir DIR]
  swarm-state.sh task-depend --id ID --blocked-by ID [--dir DIR]
  swarm-state.sh task-ready [--dir DIR]
  swarm-state.sh task-next --owner TARGET [--dir DIR]
  swarm-state.sh task-claim --id ID --owner TARGET [--dir DIR]
  swarm-state.sh task-done --id ID --owner TARGET [--result TEXT] [--dir DIR]
  swarm-state.sh task-block --id ID --owner TARGET --reason TEXT [--dir DIR]
  swarm-state.sh task-fail --id ID --owner TARGET --reason TEXT [--dir DIR]
  swarm-state.sh task-reopen --id ID --owner TARGET --reason TEXT [--dir DIR]
  swarm-state.sh lock-acquire --name NAME --owner TARGET [--dir DIR]
  swarm-state.sh lock-release --name NAME --owner TARGET [--dir DIR] [--force]
  swarm-state.sh lock-list [--dir DIR]
  swarm-state.sh lock-prune --older-than SEC [--dir DIR] [--dry-run]
  swarm-state.sh report [--dir DIR]
  swarm-state.sh metrics [--dir DIR]
  swarm-state.sh validate [--dir DIR]

Environment:
  AUTO_TMUX_SWARM_DIR overrides the default /tmp/ai_swarm.
EOF
}

die() {
  printf 'error: %s\n' "$*" >&2
  exit 1
}

sanitize() {
  tr '\t\r\n' '   ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
}

set_dir() {
  local dir="$1"
  SWARM_DIR="$dir"
  STATUS_LOG="$SWARM_DIR/status.log"
  TASKS_TSV="$SWARM_DIR/tasks.tsv"
  DEPS_TSV="$SWARM_DIR/deps.tsv"
  LOCKS_DIR="$SWARM_DIR/locks"
  RESULTS_DIR="$SWARM_DIR/results"
  STATE_LOCK_DIR="$SWARM_DIR/.state.lock.d"
}

ensure_init() {
  mkdir -p "$LOCKS_DIR" "$RESULTS_DIR"
  touch "$STATUS_LOG" "$TASKS_TSV" "$DEPS_TSV"
  if [[ ! -s "$TASKS_TSV" ]]; then
    printf 'id\tstatus\towner\ttext\tresult\n' > "$TASKS_TSV"
  fi
  if [[ ! -s "$DEPS_TSV" ]]; then
    printf 'task_id\tblocked_by\n' > "$DEPS_TSV"
  fi
}

acquire_state_lock() {
  local timeout="${1:-10}"
  local start now
  start="$(date +%s)"
  while ! mkdir "$STATE_LOCK_DIR" 2>/dev/null; do
    now="$(date +%s)"
    if (( now - start >= timeout )); then
      die "timeout waiting for swarm state lock: $STATE_LOCK_DIR"
    fi
    sleep 0.2
  done
  printf '%s\n' "$$" > "$STATE_LOCK_DIR/pid"
  printf '%s\n' "$(date -Is)" > "$STATE_LOCK_DIR/created_at"
}

release_state_lock() {
  rm -rf "$STATE_LOCK_DIR"
}

cmd_init() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown init option: $1" ;;
    esac
  done
  ensure_init
  printf 'swarm initialized: %s\n' "$SWARM_DIR"
}

cmd_log() {
  local target="" status="" message=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --target) target="${2:-}"; shift 2 ;;
      --status) status="${2:-}"; shift 2 ;;
      --message) message="${2:-}"; shift 2 ;;
      *) die "unknown log option: $1" ;;
    esac
  done
  [[ -n "$target" ]] || die "missing --target"
  [[ -n "$status" ]] || die "missing --status"
  [[ -n "$message" ]] || die "missing --message"
  ensure_init
  message="$(printf '%s' "$message" | sanitize)"
  printf '[%s] [%s] [%s] %s\n' "$(date +%H:%M:%S)" "$target" "$status" "$message" >> "$STATUS_LOG"
}

cmd_status() {
  local lines=30
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      -n|--lines) lines="${2:-}"; shift 2 ;;
      *) die "unknown status option: $1" ;;
    esac
  done
  ensure_init
  tail -n "$lines" "$STATUS_LOG"
}

cmd_task_add() {
  local id="" text=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --text) text="${2:-}"; shift 2 ;;
      *) die "unknown task-add option: $1" ;;
    esac
  done
  [[ -n "$text" ]] || die "missing --text"
  ensure_init
  if [[ -z "$id" ]]; then
    id="task-$(date +%Y%m%d%H%M%S)-$$"
  fi
  text="$(printf '%s' "$text" | sanitize)"
  printf '%s\tTODO\t-\t%s\t-\n' "$id" "$text" >> "$TASKS_TSV"
  printf 'task added: %s\n' "$id"
}

cmd_task_import() {
  local file="" prefix=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --file) file="${2:-}"; shift 2 ;;
      --prefix) prefix="${2:-}"; shift 2 ;;
      *) die "unknown task-import option: $1" ;;
    esac
  done
  [[ -n "$file" ]] || die "missing --file"
  [[ -f "$file" ]] || die "task import file not found: $file"
  ensure_init
  if [[ -z "$prefix" ]]; then
    prefix="task-$(date +%Y%m%d%H%M%S)"
  fi

  local count=0 line text id
  while IFS= read -r line || [[ -n "$line" ]]; do
    text="$(printf '%s' "$line" | sed -E 's/\r$//; s/^[[:space:]]*[-*][[:space:]]+//; s/^[[:space:]]+//; s/[[:space:]]+$//')"
    [[ -n "$text" ]] || continue
    [[ "$text" == \#* ]] && continue
    count=$((count + 1))
    id="${prefix}-$(printf '%03d' "$count")"
    if awk -F '\t' -v id="$id" 'NR > 1 && $1 == id {found = 1} END {exit found ? 0 : 1}' "$TASKS_TSV"; then
      die "task id already exists: $id"
    fi
    text="$(printf '%s' "$text" | sanitize)"
    printf '%s\tTODO\t-\t%s\t-\n' "$id" "$text" >> "$TASKS_TSV"
  done < "$file"

  printf 'tasks imported: %s prefix=%s\n' "$count" "$prefix"
}

cmd_task_list() {
  local status_filter="" owner_filter=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --status) status_filter="${2:-}"; shift 2 ;;
      --owner) owner_filter="${2:-}"; shift 2 ;;
      *) die "unknown task-list option: $1" ;;
    esac
  done
  ensure_init
  awk -F '\t' -v OFS='\t' -v status="$status_filter" -v owner="$owner_filter" '
    NR == 1 {print; next}
    status != "" && $2 != status {next}
    owner != "" && $3 != owner {next}
    {print}
  ' "$TASKS_TSV" | column -t -s $'\t' 2>/dev/null || awk -F '\t' -v OFS='\t' -v status="$status_filter" -v owner="$owner_filter" '
    NR == 1 {print; next}
    status != "" && $2 != status {next}
    owner != "" && $3 != owner {next}
    {print}
  ' "$TASKS_TSV"
}

task_exists() {
  local id="$1"
  awk -F '\t' -v id="$id" 'NR > 1 && $1 == id {found = 1} END {exit found ? 0 : 1}' "$TASKS_TSV"
}

update_task() {
  local id="$1" status="$2" owner="$3" result="$4"
  local tmp
  tmp="$(mktemp)"
  awk -F '\t' -v OFS='\t' -v id="$id" -v status="$status" -v owner="$owner" -v result="$result" '
    NR == 1 {print; next}
    $1 == id {$2 = status; $3 = owner; if (result != "") $5 = result; found = 1}
    {print}
    END {if (!found) exit 42}
  ' "$TASKS_TSV" > "$tmp" || {
    local code=$?
    rm -f "$tmp"
    if [[ "$code" == "42" ]]; then
      die "task not found: $id"
    fi
    exit "$code"
  }
  mv "$tmp" "$TASKS_TSV"
}

cmd_task_depend() {
  local id="" blocked_by=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --blocked-by) blocked_by="${2:-}"; shift 2 ;;
      *) die "unknown task-depend option: $1" ;;
    esac
  done
  [[ -n "$id" ]] || die "missing --id"
  [[ -n "$blocked_by" ]] || die "missing --blocked-by"
  [[ "$id" != "$blocked_by" ]] || die "task cannot depend on itself: $id"
  ensure_init
  task_exists "$id" || die "task not found: $id"
  task_exists "$blocked_by" || die "blocked-by task not found: $blocked_by"
  if awk -F '\t' -v id="$id" -v blocked_by="$blocked_by" 'NR > 1 && $1 == id && $2 == blocked_by {found = 1} END {exit found ? 0 : 1}' "$DEPS_TSV"; then
    printf 'dependency already exists: %s blocked_by %s\n' "$id" "$blocked_by"
    return 0
  fi
  printf '%s\t%s\n' "$id" "$blocked_by" >> "$DEPS_TSV"
  printf 'dependency added: %s blocked_by %s\n' "$id" "$blocked_by"
}

cmd_task_ready() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown task-ready option: $1" ;;
    esac
  done
  ensure_init
  awk -F '\t' -v OFS='\t' '
    FILENAME == ARGV[1] {
      if (FNR == 1) {header = $0; next}
      status[$1] = $2
      row[$1] = $0
      order[++n] = $1
      next
    }
    FILENAME == ARGV[2] {
      if (FNR == 1) next
      if (status[$2] != "DONE") blocked[$1] = 1
      next
    }
    END {
      print header
      for (i = 1; i <= n; i++) {
        id = order[i]
        if (status[id] == "TODO" && !blocked[id]) print row[id]
      }
    }
  ' "$TASKS_TSV" "$DEPS_TSV" | column -t -s $'\t' 2>/dev/null || true
}

cmd_task_claim() {
  local id="" owner=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      *) die "unknown task-claim option: $1" ;;
    esac
  done
  [[ -n "$id" ]] || die "missing --id"
  [[ -n "$owner" ]] || die "missing --owner"
  ensure_init
  update_task "$id" "DOING" "$owner" ""
  cmd_log --target "$owner" --status "START" --message "claimed $id"
}

cmd_task_next() {
  local owner=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      *) die "unknown task-next option: $1" ;;
    esac
  done
  [[ -n "$owner" ]] || die "missing --owner"
  ensure_init

  acquire_state_lock
  local id tmp
  id="$(awk -F '\t' '
    FILENAME == ARGV[1] {
      if (FNR == 1) next
      status[$1] = $2
      order[++n] = $1
      next
    }
    FILENAME == ARGV[2] {
      if (FNR == 1) next
      if (status[$2] != "DONE") blocked[$1] = 1
      next
    }
    END {
      for (i = 1; i <= n; i++) {
        id = order[i]
        if (status[id] == "TODO" && !blocked[id]) {
          print id
          exit
        }
      }
    }
  ' "$TASKS_TSV" "$DEPS_TSV")"
  if [[ -z "$id" ]]; then
    release_state_lock
    printf 'no TODO task available\n'
    return 1
  fi

  tmp="$(mktemp)"
  awk -F '\t' -v OFS='\t' -v id="$id" -v owner="$owner" '
    NR == 1 {print; next}
    $1 == id {$2 = "DOING"; $3 = owner}
    {print}
  ' "$TASKS_TSV" > "$tmp"
  mv "$tmp" "$TASKS_TSV"
  release_state_lock

  cmd_log --target "$owner" --status "START" --message "claimed next $id"
  printf 'task claimed: %s\n' "$id"
}

cmd_task_done() {
  local id="" owner="" result="done"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      --result) result="${2:-}"; shift 2 ;;
      *) die "unknown task-done option: $1" ;;
    esac
  done
  [[ -n "$id" ]] || die "missing --id"
  [[ -n "$owner" ]] || die "missing --owner"
  ensure_init
  result="$(printf '%s' "$result" | sanitize)"
  update_task "$id" "DONE" "$owner" "$result"
  printf '%s\n' "$result" > "$RESULTS_DIR/$id.txt"
  cmd_log --target "$owner" --status "DONE" --message "$id $result"
}

cmd_task_terminal() {
  local status="$1"
  shift
  local id="" owner="" reason=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      --reason) reason="${2:-}"; shift 2 ;;
      *) die "unknown task-$status option: $1" ;;
    esac
  done
  [[ -n "$id" ]] || die "missing --id"
  [[ -n "$owner" ]] || die "missing --owner"
  [[ -n "$reason" ]] || die "missing --reason"
  ensure_init
  reason="$(printf '%s' "$reason" | sanitize)"
  update_task "$id" "$status" "$owner" "$reason"
  printf '%s\n' "$reason" > "$RESULTS_DIR/$id.txt"
  cmd_log --target "$owner" --status "$status" --message "$id $reason"
}

cmd_task_reopen() {
  local id="" owner="" reason=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --id) id="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      --reason) reason="${2:-}"; shift 2 ;;
      *) die "unknown task-reopen option: $1" ;;
    esac
  done
  [[ -n "$id" ]] || die "missing --id"
  [[ -n "$owner" ]] || die "missing --owner"
  [[ -n "$reason" ]] || die "missing --reason"
  ensure_init
  reason="$(printf '%s' "$reason" | sanitize)"
  update_task "$id" "TODO" "-" "reopened: $reason"
  cmd_log --target "$owner" --status "REOPEN" --message "$id $reason"
  printf 'task reopened: %s\n' "$id"
}

lock_path() {
  local name="$1"
  name="$(printf '%s' "$name" | sed -E 's#[^A-Za-z0-9_.-]+#_#g')"
  printf '%s/%s.lock.d' "$LOCKS_DIR" "$name"
}

cmd_lock_acquire() {
  local name="" owner=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --name) name="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      *) die "unknown lock-acquire option: $1" ;;
    esac
  done
  [[ -n "$name" ]] || die "missing --name"
  [[ -n "$owner" ]] || die "missing --owner"
  ensure_init
  local path
  path="$(lock_path "$name")"
  if mkdir "$path" 2>/dev/null; then
    printf '%s\n' "$owner" > "$path/owner"
    printf '%s\n' "$(date -Is)" > "$path/created_at"
    cmd_log --target "$owner" --status "LOCK" --message "acquired $name"
    printf 'lock acquired: %s\n' "$name"
  else
    printf 'lock busy: %s owner=' "$name" >&2
    cat "$path/owner" >&2 2>/dev/null || printf 'unknown\n' >&2
    exit 2
  fi
}

cmd_lock_release() {
  local name="" owner="" force="0"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --name) name="${2:-}"; shift 2 ;;
      --owner) owner="${2:-}"; shift 2 ;;
      --force) force="1"; shift ;;
      *) die "unknown lock-release option: $1" ;;
    esac
  done
  [[ -n "$name" ]] || die "missing --name"
  [[ -n "$owner" ]] || die "missing --owner"
  ensure_init
  local path current_owner
  path="$(lock_path "$name")"
  [[ -d "$path" ]] || die "lock not found: $name"
  current_owner="$(cat "$path/owner" 2>/dev/null || true)"
  if [[ "$force" != "1" && "$current_owner" != "$owner" ]]; then
    die "lock owner mismatch: current=$current_owner requested=$owner"
  fi
  rm -rf "$path"
  cmd_log --target "$owner" --status "UNLOCK" --message "released $name"
  printf 'lock released: %s\n' "$name"
}

cmd_lock_list() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown lock-list option: $1" ;;
    esac
  done
  ensure_init
  find "$LOCKS_DIR" -mindepth 1 -maxdepth 1 -type d -name '*.lock.d' -print | sort | while read -r path; do
    printf '%s\towner=%s\tcreated_at=%s\n' \
      "$(basename "$path" .lock.d)" \
      "$(cat "$path/owner" 2>/dev/null || printf unknown)" \
      "$(cat "$path/created_at" 2>/dev/null || printf unknown)"
  done
}

cmd_lock_prune() {
  local older_than="" dry_run="0"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      --older-than) older_than="${2:-}"; shift 2 ;;
      --dry-run) dry_run="1"; shift ;;
      *) die "unknown lock-prune option: $1" ;;
    esac
  done
  [[ -n "$older_than" ]] || die "missing --older-than SEC"
  [[ "$older_than" =~ ^[0-9]+$ ]] || die "--older-than must be seconds"
  ensure_init

  local now path created_at created_epoch age name
  now="$(date +%s)"
  find "$LOCKS_DIR" -mindepth 1 -maxdepth 1 -type d -name '*.lock.d' -print | sort | while read -r path; do
    created_at="$(cat "$path/created_at" 2>/dev/null || true)"
    created_epoch="$(date -d "$created_at" +%s 2>/dev/null || printf '0')"
    [[ "$created_epoch" != "0" ]] || continue
    age=$((now - created_epoch))
    name="$(basename "$path" .lock.d)"
    if (( age >= older_than )); then
      if [[ "$dry_run" == "1" ]]; then
        printf '[dry-run] would prune lock: %s age=%ss\n' "$name" "$age"
      else
        rm -rf "$path"
        cmd_log --target "swarm-state" --status "UNLOCK" --message "pruned stale lock $name age=${age}s"
        printf 'lock pruned: %s age=%ss\n' "$name" "$age"
      fi
    fi
  done
}

cmd_report() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown report option: $1" ;;
    esac
  done
  ensure_init
  printf '# swarm report\n\n'
  printf 'dir: %s\n\n' "$SWARM_DIR"
  printf '## tasks\n\n'
  cmd_task_list
  printf '\n## locks\n\n'
  cmd_lock_list || true
  printf '\n## dependencies\n\n'
  column -t -s $'\t' "$DEPS_TSV" 2>/dev/null || cat "$DEPS_TSV"
  printf '\n## recent status\n\n'
  cmd_status -n 20
}

cmd_metrics() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown metrics option: $1" ;;
    esac
  done
  ensure_init
  printf '# swarm metrics\n\n'
  printf 'dir: %s\n\n' "$SWARM_DIR"
  printf '## by status\n\n'
  awk -F '\t' 'NR > 1 {count[$2]++} END {for (status in count) print status "\t" count[status]}' "$TASKS_TSV" |
    sort | column -t -s $'\t' 2>/dev/null || true
  printf '\n## by owner\n\n'
  awk -F '\t' 'NR > 1 {count[$3]++} END {for (owner in count) print owner "\t" count[owner]}' "$TASKS_TSV" |
    sort | column -t -s $'\t' 2>/dev/null || true
  printf '\n## locks\n\n'
  find "$LOCKS_DIR" -mindepth 1 -maxdepth 1 -type d -name '*.lock.d' | wc -l | awk '{print "active_locks\t" $1}' |
    column -t -s $'\t' 2>/dev/null || true
}

cmd_validate() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) set_dir "${2:-}"; shift 2 ;;
      *) die "unknown validate option: $1" ;;
    esac
  done
  ensure_init

  local failures=0
  local expected_header=$'id\tstatus\towner\ttext\tresult'
  local expected_deps_header=$'task_id\tblocked_by'
  local actual_header
  actual_header="$(head -n 1 "$TASKS_TSV")"
  if [[ "$actual_header" == "$expected_header" ]]; then
    printf '[OK] tasks header\n'
  else
    printf '[FAIL] invalid tasks header: %s\n' "$actual_header" >&2
    failures=$((failures + 1))
  fi
  actual_header="$(head -n 1 "$DEPS_TSV")"
  if [[ "$actual_header" == "$expected_deps_header" ]]; then
    printf '[OK] deps header\n'
  else
    printf '[FAIL] invalid deps header: %s\n' "$actual_header" >&2
    failures=$((failures + 1))
  fi

  local awk_output
  if awk_output="$(awk -F '\t' '
    NR == 1 {next}
    NF != 5 {print "[FAIL] invalid field count at line " NR ": " NF; bad = 1; next}
    $1 == "" {print "[FAIL] empty task id at line " NR; bad = 1}
    seen[$1]++ {print "[FAIL] duplicate task id: " $1; bad = 1}
    $2 !~ /^(TODO|DOING|DONE|BLOCKED|FAIL)$/ {print "[FAIL] invalid status for " $1 ": " $2; bad = 1}
    END {exit bad ? 1 : 0}
  ' "$TASKS_TSV" 2>&1)"; then
    printf '[OK] tasks rows\n'
  else
    printf '%s\n' "$awk_output" >&2
    failures=$((failures + 1))
  fi

  local id status owner text result
  while IFS=$'\t' read -r id status owner text result; do
    [[ -n "$id" ]] || continue
    case "$status" in
      DONE|BLOCKED|FAIL)
        if [[ -s "$RESULTS_DIR/$id.txt" ]]; then
          printf '[OK] result exists: %s\n' "$id"
        else
          printf '[FAIL] missing result file: %s\n' "$id" >&2
          failures=$((failures + 1))
        fi
        ;;
    esac
  done < <(tail -n +2 "$TASKS_TSV")

  local task_id blocked_by
  while IFS=$'\t' read -r task_id blocked_by; do
    [[ -n "$task_id" ]] || continue
    if ! task_exists "$task_id"; then
      printf '[FAIL] dependency task missing: %s\n' "$task_id" >&2
      failures=$((failures + 1))
    fi
    if ! task_exists "$blocked_by"; then
      printf '[FAIL] dependency blocked_by missing: %s\n' "$blocked_by" >&2
      failures=$((failures + 1))
    fi
  done < <(tail -n +2 "$DEPS_TSV")

  local path name
  while IFS= read -r path; do
    [[ -n "$path" ]] || continue
    name="$(basename "$path" .lock.d)"
    if [[ ! -s "$path/owner" ]]; then
      printf '[FAIL] lock missing owner: %s\n' "$name" >&2
      failures=$((failures + 1))
    fi
    if [[ ! -s "$path/created_at" ]]; then
      printf '[FAIL] lock missing created_at: %s\n' "$name" >&2
      failures=$((failures + 1))
    fi
  done < <(find "$LOCKS_DIR" -mindepth 1 -maxdepth 1 -type d -name '*.lock.d' -print | sort)

  if (( failures > 0 )); then
    printf 'swarm state invalid: %s issue(s)\n' "$failures" >&2
    return 1
  fi
  printf 'swarm state valid: %s\n' "$SWARM_DIR"
}

main() {
  local cmd="${1:-help}"
  shift || true
  case "$cmd" in
    help|-h|--help) usage ;;
    init) cmd_init "$@" ;;
    log) cmd_log "$@" ;;
    status) cmd_status "$@" ;;
    task-add) cmd_task_add "$@" ;;
    task-import) cmd_task_import "$@" ;;
    task-list) cmd_task_list "$@" ;;
    task-depend) cmd_task_depend "$@" ;;
    task-ready) cmd_task_ready "$@" ;;
    task-next) cmd_task_next "$@" ;;
    task-claim) cmd_task_claim "$@" ;;
    task-done) cmd_task_done "$@" ;;
    task-block) cmd_task_terminal "BLOCKED" "$@" ;;
    task-fail) cmd_task_terminal "FAIL" "$@" ;;
    task-reopen) cmd_task_reopen "$@" ;;
    lock-acquire) cmd_lock_acquire "$@" ;;
    lock-release) cmd_lock_release "$@" ;;
    lock-list) cmd_lock_list "$@" ;;
    lock-prune) cmd_lock_prune "$@" ;;
    report) cmd_report "$@" ;;
    metrics) cmd_metrics "$@" ;;
    validate) cmd_validate "$@" ;;
    *) die "unknown command: $cmd" ;;
  esac
}

main "$@"
