#!/usr/bin/env bash
# Bound the build mirror cache without guessing about live or recent work.
# Only direct children of ~/builds are eligible, and only after they have aged past
# CACHE_MIN_AGE_DAYS. Once over CACHE_MAX_BYTES, reclaim oldest eligible entries
# until CACHE_TARGET_BYTES rather than thrashing at the edge.
set -euo pipefail

ROOT="${BUILDBOX_BUILDS_ROOT:-$HOME/builds}"
# 30/24G caps budget the shared 110G scratch disk: builds must leave room for
# cdx-offload, runs, runner-work, pnpm-store, playwright and the 15G floor.
# test/prune-budget.test.sh fails if this and the unit's caps stop adding up.
MAX_BYTES="${BUILDBOX_CACHE_MAX_BYTES:-$((30 * 1024 * 1024 * 1024))}"
TARGET_BYTES="${BUILDBOX_CACHE_TARGET_BYTES:-$((24 * 1024 * 1024 * 1024))}"
MIN_AGE_DAYS="${BUILDBOX_CACHE_MIN_AGE_DAYS:-2}"

die() { printf 'buildbox-build-cache-prune: %s\n' "$*" >&2; exit 2; }
is_uint() { [[ "$1" =~ ^[0-9]+$ ]]; }

is_uint "$MAX_BYTES" || die "BUILDBOX_CACHE_MAX_BYTES must be an integer"
is_uint "$TARGET_BYTES" || die "BUILDBOX_CACHE_TARGET_BYTES must be an integer"
is_uint "$MIN_AGE_DAYS" || die "BUILDBOX_CACHE_MIN_AGE_DAYS must be an integer"
[ "$TARGET_BYTES" -lt "$MAX_BYTES" ] || die "target must be lower than max"
[ -d "$ROOT" ] || die "cache root is not a directory: $ROOT"

ROOT=$(readlink -f -- "$ROOT")
[ "$ROOT" != / ] || die "refusing filesystem root"
[ "$ROOT" != "$HOME" ] || die "refusing home directory"

# Agent trees carry jailed directories owned by root. du cannot read them and
# exits non-zero while still printing a total, so a strict check here disables
# the cap on exactly the trees that grow fastest. Unreadable subtrees are
# excluded from the total, which under-counts and never over-prunes.
cache_bytes() {
  local out
  out=$(du -sB1 --one-file-system -- "$ROOT" 2>/dev/null | awk '{print $1}')
  is_uint "${out:-}" || return 1
  printf '%s\n' "$out"
}
used=$(cache_bytes) || die "cannot measure cache root: $ROOT"
if [ "$used" -le "$MAX_BYTES" ]; then
  printf 'build-cache no-op used=%s max=%s root=%s\n' "$used" "$MAX_BYTES" "$ROOT"
  exit 0
fi

cutoff=$(( $(date +%s) - MIN_AGE_DAYS * 86400 ))
candidates=$(mktemp)
trap 'rm -f "$candidates"' EXIT
# The NUL-delimited sort preserves all legal path bytes. The tab is parsed only as
# the separator following the numeric mtime, so a tab in a filename stays in the path.
# Hidden entries are never build mirrors — a dot prefix marks deliberately
# stashed data (e.g. `.preserved-runs`) and is not a prune candidate.
find -P "$ROOT" -xdev -mindepth 1 -maxdepth 1 -not -name '.*' -printf '%T@\t%p\0' \
  | sort -z -n >"$candidates"

while IFS= read -r -d '' record; do
  mtime=${record%%$'\t'*}
  path=${record#*$'\t'}
  mtime=${mtime%%.*}
  [ "$mtime" -le "$cutoff" ] || continue
  # find -P plus this direct-child containment check prevents a malformed input from
  # turning this into a recursive cleanup outside the cache root.
  case "$path" in "$ROOT"/*) ;; *) die "candidate escaped cache root";; esac
  # Read-only directories the entry owns are ours to reopen; a subtree owned by
  # another user is not, and an entry that survives is reported and skipped
  # rather than retried forever.
  chmod -R u+rwX -- "$path" 2>/dev/null
  rm -rf --one-file-system -- "$path" 2>/dev/null
  if [ -e "$path" ]; then
    printf 'build-cache undeletable=%s (foreign-owned content left in place)\n' "$path" >&2
    continue
  fi
  used=$(cache_bytes) || die "cannot remeasure cache root: $ROOT"
  printf 'build-cache pruned=%s used=%s target=%s\n' "$path" "$used" "$TARGET_BYTES"
  [ "$used" -le "$TARGET_BYTES" ] && exit 0
done <"$candidates"

printf 'build-cache cap-unmet used=%s max=%s target=%s (no aged entries remain)\n' \
  "$used" "$MAX_BYTES" "$TARGET_BYTES" >&2
exit 1
