#!/usr/bin/env bash
# gc-tmp.sh  -  remove leftover /tmp scratch files from past pipeline runs.
#
# Each run writes ephemeral scratch under /tmp: picker state, PR/review diffs,
# channel payloads, external-context bundles, and analysis drafts. These are
# never cleaned up automatically (review.md traps its own, the rest linger), so
# a long-lived machine accumulates them. This sweeps the known prefixes.
#
# SAFE BY DEFAULT:
#   - dry-run: lists what WOULD be removed and the space it would free, and
#     deletes nothing until you pass --yes.
#   - root guard: the scratch root must be an existing directory and is
#     refused when it resolves to /, $HOME, or a git work tree, so a stray
#     GC_TMP_ROOT can never sweep a home dir or a repo checkout.
#   - fresh-scratch grace: items touched in the last GC_TMP_GRACE_MIN minutes
#     (default 10) are spared, so a sweep during an in-flight run cannot pull
#     scratch out from under it. GC_TMP_GRACE_MIN=0 disables the grace.
#
# Usage:
#   gc-tmp.sh                      # dry-run: list matches + total size
#   gc-tmp.sh --yes                # actually delete
#   gc-tmp.sh --older-than=60      # only match items older than 60 minutes
#   gc-tmp.sh --older-than=60 --yes
#
# Env:
#   GC_TMP_ROOT       override the scratch root (default /tmp; tests set this)
#   GC_TMP_GRACE_MIN  fresh-scratch grace window in minutes (default 10, 0 = off)
#
# Exit: 0 on success, 2 on usage error or refused scratch root. Always exits 0
# when nothing matches.

set -uo pipefail

ROOT="${GC_TMP_ROOT:-/tmp}"
GRACE_MIN="${GC_TMP_GRACE_MIN:-10}"
DELETE=0
OLDER_MIN=0

usage() {
  grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
}

for arg in "$@"; do
  case "$arg" in
    --yes | --force) DELETE=1 ;;
    --older-than=*)
      OLDER_MIN="${arg#*=}"
      if ! printf '%s' "$OLDER_MIN" | grep -qE '^[0-9]+$'; then
        echo "gc-tmp: --older-than needs a whole number of minutes, got: $OLDER_MIN" >&2
        exit 2
      fi
      ;;
    -h | --help)
      usage
      exit 0
      ;;
    *)
      echo "gc-tmp: unknown argument: $arg" >&2
      exit 2
      ;;
  esac
done

if ! printf '%s' "$GRACE_MIN" | grep -qE '^[0-9]+$'; then
  echo "gc-tmp: GC_TMP_GRACE_MIN needs a whole number of minutes, got: $GRACE_MIN" >&2
  exit 2
fi

if [ ! -d "$ROOT" ]; then
  echo "gc-tmp: no scratch root at $ROOT  -  nothing to do"
  exit 0
fi

# Root guard: resolve symlinks (macOS /tmp -> /private/tmp), then refuse
# roots that can never be a scratch root. Sweeping $HOME by mistake would
# match real dirs like ~/multi-agent-pipeline via the name prefixes below.
ROOT="$(cd "$ROOT" 2>/dev/null && pwd -P)"
if [ -z "$ROOT" ]; then
  echo "gc-tmp: cannot resolve scratch root" >&2
  exit 2
fi
HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
if [ "$ROOT" = "/" ] || [ "$ROOT" = "$HOME_REAL" ] || [ -e "$ROOT/.git" ]; then
  echo "gc-tmp: refusing scratch root $ROOT (/, \$HOME, or a git work tree is never a scratch root)" >&2
  exit 2
fi

# Scratch prefixes the pipeline writes under $ROOT (see jira.md, review.md,
# update-issue-progress.sh, channels/*.md, external-context-injection.md,
# analysis.md). mindepth 1 so the root itself is never a candidate even when
# its own name matches a prefix; maxdepth 1 so we never descend into
# unrelated trees.
NAME_ARGS=(
  -name 'multi-agent-*'
  -o -name 'issue-progress-*'
  -o -name 'channels-*'
  -o -name 'context-links-*'
  -o -name 'context-by-type-*'
  -o -name 'analysis-*'
)

MTIME_ARGS=()
if [ "$OLDER_MIN" -gt 0 ]; then
  MTIME_ARGS=(-mmin "+$OLDER_MIN")
fi

matches=()
skipped_active=0
while IFS= read -r -d '' p; do
  # Fresh-scratch grace: spare items an in-flight run touched recently
  # (checks the item and, for dirs, anything inside it).
  if [ "$GRACE_MIN" -gt 0 ] &&
    find "$p" -mmin "-$GRACE_MIN" -print 2>/dev/null | head -1 | grep -q .; then
    skipped_active=$((skipped_active + 1))
    continue
  fi
  matches+=("$p")
done < <(find "$ROOT" -mindepth 1 -maxdepth 1 \( "${NAME_ARGS[@]}" \) ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)

if [ "${#matches[@]}" -eq 0 ]; then
  if [ "$skipped_active" -gt 0 ]; then
    echo "gc-tmp: only fresh scratch matched (touched in the last ${GRACE_MIN}m)  -  spared, nothing to do"
  else
    echo "gc-tmp: no leftover scratch under $ROOT  -  nothing to do"
  fi
  exit 0
fi

# Total size (KB), portable across BSD/GNU du.
total_kb=0
for p in "${matches[@]}"; do
  kb=$(du -sk "$p" 2>/dev/null | awk '{print $1}')
  total_kb=$((total_kb + ${kb:-0}))
done
human="${total_kb} KB"
[ "$total_kb" -ge 1024 ] && human="$((total_kb / 1024)) MB"

if [ "$DELETE" -eq 0 ]; then
  echo "DRY-RUN  -  would remove ${#matches[@]} item(s) under $ROOT (frees ~${human}):"
  for p in "${matches[@]}"; do echo "  $p"; done
  [ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active fresh item(s) touched in the last ${GRACE_MIN}m."
  echo "Re-run with --yes to delete."
  exit 0
fi

removed=0
for p in "${matches[@]}"; do
  # Containment guard: only ever delete direct children of the resolved root.
  case "$p" in
    "$ROOT"/*) ;;
    *)
      echo "gc-tmp: skipping path outside scratch root: $p" >&2
      continue
      ;;
  esac
  rm -rf "$p" 2>/dev/null && removed=$((removed + 1))
done
spared_note=""
[ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active fresh"
echo "══ garbage-collect: removed ${removed}/${#matches[@]} item(s)${spared_note}, freed ~${human} ══"
exit 0
