#!/usr/bin/env bash
# wt-reaper — reclaim leaked mega-plan-harness engine worktrees.
#
# The engine (src/engine/lease.js / ship.js) creates two kinds of worktree in a
# target repo and only cleans them on the happy path:
#   <repo>/.wt-<slug>-int     integration worktree — NEVER removed (ship.js skips it)
#   <repo>/tmp/wt-<slug>--*   task worktree — swept only by the SAME slug's next run
# A crashed/aborted run, or any completed run, leaves these on disk permanently.
#
# This reaper removes them safely: an engine worktree is removed only when its
# working tree is CLEAN (no uncommitted changes). Dirty ones are kept and
# reported — they may hold another session's unmerged work.
#
# Usage:
#   wt-reaper.sh [--apply] [--root DIR] [--age-days N]
#     (default: dry-run; --apply performs removal)
#     --root      search root for repos (default: $HOME/Projects)
#     --age-days  only consider worktrees whose dir mtime is older than N days (default 0 = all)
set -uo pipefail   # NOT -e: one malformed worktree must not abort the whole sweep

ROOT="${WT_REAPER_ROOT:-$HOME/Projects}"
APPLY=0
AGE_DAYS="${WT_REAPER_AGE_DAYS:-0}"
SIZE=1
EXTRA_EXCLUDE=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    --apply) APPLY=1 ;;
    --no-size) SIZE=0 ;;
    --root) ROOT="$2"; shift ;;
    --age-days) AGE_DAYS="$2"; shift ;;
    --exclude-slug) EXTRA_EXCLUDE+=("$2"); shift ;;
    *) echo "wt-reaper: unknown arg: $1" >&2; exit 2 ;;
  esac
  shift
done

# Find candidate engine worktree dirs: <repo>/.wt-*-int and <repo>/tmp/wt-*
mapfile -t candidates < <(
  find "$ROOT" -maxdepth 3 -type d \
    \( -name '.wt-*-int' -o \( -name 'wt-*' -path '*/tmp/*' \) \) -prune 2>/dev/null | sort
)

if [[ "$AGE_DAYS" -gt 0 ]]; then
  mapfile -t candidates < <(
    for d in "${candidates[@]}"; do
      if [[ -z "$(find "$d" -maxdepth 0 -mtime +"$AGE_DAYS" 2>/dev/null)" ]]; then continue; fi
      printf '%s\n' "$d"
    done
  )
fi

# Active-run protection: never touch a worktree whose plan slug has a live run.
# Slugs are read from worktree paths in running process command lines plus any
# passed via --exclude-slug. A worktree's slug is embedded in its path:
#   <repo>/.wt-<slug>-int          <repo>/tmp/wt-<slug>--<taskid>
# Read cmdlines from /proc (NOT `ps`, which truncates to terminal width).
# A slug is active if any live process has a worktree path
# (.wt-<slug>-int | tmp/wt-<slug>--) anywhere in its cmdline.
declare -A ACTIVE_SLUGS
while IFS= read -r s; do [[ -n "$s" ]] && ACTIVE_SLUGS["$s"]=1; done < <(
  for c in /proc/[0-9]*/cmdline; do
    [[ -r "$c" ]] || continue                     # process may exit mid-scan
    cmd=$(tr '\0' ' ' < "$c" 2>/dev/null) || continue
    grep -oE '\.wt-[A-Za-z0-9._-]+-int|/tmp/wt-[A-Za-z0-9._-]+--' <<< "$cmd" \
      | sed -E 's#.*\.wt-##; s#-int$##; s#.*/tmp/wt-##; s#--$##'
  done
)
for s in "${EXTRA_EXCLUDE[@]:-}"; do [[ -n "$s" ]] && ACTIVE_SLUGS["$s"]=1; done

slug_of() {  # echoes the plan slug embedded in a worktree path
  local base; base=$(basename "$1")
  if [[ "$base" == .wt-*-int ]]; then base=${base#.wt-}; echo "${base%-int}"
  elif [[ "$base" == wt-*--* ]]; then base=${base#wt-}; echo "${base%%--*}"
  else echo ""; fi
}

# Fixed, unambiguous generated/build markers the harness or toolchain writes into
# every worktree. Extend only with things that are provably machine-generated.
GEN_RE='(^|/)\.astro/|^\.install-args$|^\.install-cwd$|^\.rb-epoch$|(^|/)node_modules/|\.tsbuildinfo$'

declare -A SEED_CACHE DEFBR_CACHE

default_branch() {  # echoes the repo's default branch name
  local repo="$1"
  if [[ -n "${DEFBR_CACHE[$repo]:-}" ]]; then echo "${DEFBR_CACHE[$repo]}"; return; fi
  local b
  b=$(git -C "$repo" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
  [[ -z "$b" ]] && { git -C "$repo" show-ref --quiet refs/heads/main && b=main; }
  [[ -z "$b" ]] && { git -C "$repo" show-ref --quiet refs/heads/master && b=master; }
  DEFBR_CACHE[$repo]="$b"; echo "$b"
}

seed_list() {  # echoes newline-separated seed entries for a repo (may be empty)
  local repo="$1"
  if [[ -n "${SEED_CACHE[$repo]+x}" ]]; then printf '%s' "${SEED_CACHE[$repo]}"; return; fi
  local f="$repo/.claude/worktree-seed-files.txt" data=""
  [[ -f "$f" ]] && data=$(grep -vE '^\s*(#|$)' "$f" 2>/dev/null)
  SEED_CACHE[$repo]="$data"; printf '%s' "$data"
}

# dirt_keep_reason WT REPO -> echoes "" if the dirty worktree is safe to reclaim
# (committed HEAD already in default branch AND every dirty path is seed/generated),
# otherwise echoes the reason it must be kept.
dirt_keep_reason() {
  local wt="$1" repo="$2" defbr path line rest seeds ok
  defbr=$(default_branch "$repo")
  [[ -z "$defbr" ]] && { echo "no-default-branch"; return; }
  git -C "$wt" merge-base --is-ancestor HEAD "$defbr" 2>/dev/null || { echo "unmerged-commits(HEAD not in $defbr)"; return; }
  seeds=$(seed_list "$repo")
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    rest=${line:3}                       # strip 2-char status + space
    path=${rest##* -> }                  # for renames, take the destination path
    [[ "$path" =~ $GEN_RE ]] && continue
    ok=0
    while IFS= read -r s; do
      [[ -z "$s" ]] && continue
      if [[ "$s" == */ ]]; then [[ "$path" == "$s"* ]] && { ok=1; break; }
      else [[ "$path" == "$s" ]] && { ok=1; break; }; fi
    done <<< "$seeds"
    [[ "$ok" -eq 0 ]] && { echo "non-seed-change:$path"; return; }
  done < <(git -C "$wt" status --porcelain 2>/dev/null)
  echo ""   # reclaimable
}

# Repo-level active guard. `git worktree remove`/`prune` are REPO-WIDE: a prune
# racing the orchestrator's concurrent `worktree add` in an active repo can corrupt
# the incoming worktree. So reap ONLY in repos with zero active slugs — defer the
# rest (their idle siblings included) to the daemon GC, which serializes under the
# engine's own worktree lock. This pre-pass maps each candidate to its repo and
# flags every repo that hosts a live slug.
declare -A CAND_REPO ACTIVE_REPOS
for wt in "${candidates[@]}"; do
  [[ -d "$wt" ]] || continue
  main=$(git -C "$wt" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
  [[ -z "$main" ]] && continue                       # BROKEN — handled in main loop
  repo=$(dirname "$main")
  CAND_REPO["$wt"]="$repo"
  wslug=$(slug_of "$wt")
  [[ -n "$wslug" && -n "${ACTIVE_SLUGS[$wslug]:-}" ]] && ACTIVE_REPOS["$repo"]=1
done

# safe_remove: plain (non-forced) removal. Without --force, git refuses if the
# worktree became dirty/locked in the TOCTOU window since we classified it CLEAN —
# so a slug that went live mid-sweep is protected. NO `rm -rf` fallback here: forcing
# past that refusal would discard live work. Only genuinely-BROKEN dirs get rm -rf.
safe_remove() {
  local repo="$1" wt="$2"
  git -C "$repo" worktree remove "$wt" 2>/dev/null || return 1
  git -C "$repo" worktree prune 2>/dev/null || true
  return 0
}

reaped=0 kept_dirty=0 kept_active=0 kept_active_repo=0 reaped_dirty=0 skipped_race=0 total_kib=0 reaped_kib=0
printf '%-13s %-9s %s\n' 'STATE' 'SIZE' 'WORKTREE'
for wt in "${candidates[@]}"; do
  [[ -d "$wt" ]] || continue
  wslug=$(slug_of "$wt")
  if [[ -n "$wslug" && -n "${ACTIVE_SLUGS[$wslug]:-}" ]]; then
    printf '%-13s %-9s %s  <slug %s has a live run>\n' 'ACTIVE-SKIP' '-' "$wt" "$wslug"
    kept_active=$((kept_active+1)); continue
  fi

  repo="${CAND_REPO[$wt]:-}"
  if [[ -z "$repo" ]]; then
    printf '%-13s %-9s %s\n' 'BROKEN' '-' "$wt"      # not a valid worktree (git metadata gone)
    if [[ "$APPLY" -eq 1 ]]; then rm -rf "$wt"; reaped=$((reaped+1)); fi
    continue
  fi
  if [[ -n "${ACTIVE_REPOS[$repo]:-}" ]]; then
    printf '%-13s %-9s %s  <repo has a live run — deferred to daemon GC>\n' 'ACTIVE-REPO' '-' "$wt"
    kept_active_repo=$((kept_active_repo+1)); continue
  fi

  if [[ "$SIZE" -eq 1 ]]; then
    kib=$(timeout 8 ionice -c3 nice -n19 du -sxk "$wt" 2>/dev/null | awk '{print $1}')
  fi
  kib=${kib:-0}
  total_kib=$((total_kib + kib))
  human=$(numfmt --to=iec --suffix=B $((kib * 1024)) 2>/dev/null || echo "${kib}K")

  # A lock file inside the worktree's admin dir means a process holds it.
  if git -C "$repo" worktree list --porcelain 2>/dev/null | awk -v p="$wt" '
      $1=="worktree"{cur=$2} $1=="locked" && cur==p {found=1} END{exit !found}'; then
    printf '%-13s %-9s %s\n' 'LOCKED' "$human" "$wt"; kept_active=$((kept_active+1)); continue
  fi

  if [[ -n "$(git -C "$wt" status --porcelain 2>/dev/null)" ]]; then
    reason=$(dirt_keep_reason "$wt" "$repo")
    if [[ -n "$reason" ]]; then
      printf '%-13s %-9s %s  <%s>\n' 'DIRTY-KEEP' "$human" "$wt" "$reason"; kept_dirty=$((kept_dirty+1)); continue
    fi
    printf '%-13s %-9s %s\n' 'DIRTY-RECLAIM' "$human" "$wt"
    if [[ "$APPLY" -eq 1 ]]; then
      if safe_remove "$repo" "$wt"; then
        reaped=$((reaped+1)); reaped_dirty=$((reaped_dirty+1)); reaped_kib=$((reaped_kib+kib))
      else
        printf '%-13s %-9s %s  <went dirty/locked mid-sweep — skipped>\n' 'SKIP-RACE' "$human" "$wt"; skipped_race=$((skipped_race+1))
      fi
    fi
    continue
  fi

  printf '%-13s %-9s %s\n' 'CLEAN' "$human" "$wt"
  if [[ "$APPLY" -eq 1 ]]; then
    if safe_remove "$repo" "$wt"; then
      reaped=$((reaped+1)); reaped_kib=$((reaped_kib+kib))
    else
      printf '%-13s %-9s %s  <went dirty/locked mid-sweep — skipped>\n' 'SKIP-RACE' "$human" "$wt"; skipped_race=$((skipped_race+1))
    fi
  fi
done

echo
echo "candidates:   ${#candidates[@]}   total: $(numfmt --to=iec --suffix=B $((total_kib*1024)) 2>/dev/null || echo ${total_kib}K)"
if [[ "$APPLY" -eq 1 ]]; then
  echo "reaped:       ${reaped} (of which dirty-safe: ${reaped_dirty})   reclaimed: $(numfmt --to=iec --suffix=B $((reaped_kib*1024)) 2>/dev/null || echo ${reaped_kib}K)"
  [[ "$skipped_race" -gt 0 ]] && echo "skipped race: ${skipped_race} (went dirty/locked mid-sweep)"
else
  echo "would reap:   CLEAN + DIRTY-RECLAIM rows above (run with --apply)"
fi
echo "kept dirty:   ${kept_dirty}   kept locked/active-slug: ${kept_active}   deferred (active repo): ${kept_active_repo}"
