import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";

export const GC_DEFAULTS = { gc_interval_hours: 1, gc_retention_days: 7, gc_min_free_gb: 15, gc_repo_retention_days: 30, gc_protect_minutes: 60, gc_jobs_retention_days: 7, gc_container_retention_hours: 24, gc_container_max_runtime_hours: 24, gc_image_retention_days: 7 };

// Shell fragment: fills $busy with the mirror paths held by active rb-*.service units.
const BUSY_PROBE = `busy=""
for unit in $(systemctl --user list-units --state=active --no-legend 'rb-*.service' 2>/dev/null | awk '{print $1}'); do
  id="\${unit#rb-}"; id="\${id%.service}"
  meta="$HOME/.rb/jobs/$id/meta.json"
  [ -f "$meta" ] || continue
  busy="$busy$(grep -o '"mirror":"[^"]*"' "$meta" | head -1 | cut -d'"' -f4)
"
done`;

function gcStampFile(stateDir, host) { return join(stateDir, `gc-${host}.stamp`); }

export function gcDue(stateDir, host, intervalHours, now = Date.now()) {
  try { return now - Number(readFileSync(gcStampFile(stateDir, host), "utf8").trim()) >= intervalHours * 3600 * 1000; }
  catch { return true; }
}

function markGcRan(stateDir, host, now) {
  mkdirSync(stateDir, { recursive: true });
  writeFileSync(gcStampFile(stateDir, host), String(now));
}

// Mirror liveness = .rb-epoch mtime (stamped by every syncPush). Unmarked dirs are
// interrupted first-syncs or pre-marker leftovers; a 1-day grace covers any sync in
// flight. Floor pass evicts oldest idle mirrors, then idle .repos mirrors, until min free
// space is restored. Idle = no active rb-*.service holds the mirror and it has not been
// touched within the protect window.
export function gcScript(remoteRoot, { retentionDays, minFreeGb, repoRetentionDays, protectMinutes = GC_DEFAULTS.gc_protect_minutes }) {
  return `set -u
root=${JSON.stringify(remoteRoot)}
now=$(date +%s)
cd "$root" 2>/dev/null || exit 0
for d in */; do
  d="\${d%/}"
  [ -d "$d" ] || continue
  [ "$d" = ".repos" ] && continue
  if [ -f "$d/.rb-epoch" ]; then
    [ $(( now - $(stat -c %Y "$d/.rb-epoch") )) -gt $(( ${retentionDays} * 86400 )) ] && rm -rf -- "$d" && echo "gc-stale $d"
  else
    [ $(( now - $(stat -c %Y "$d") )) -gt 86400 ] && rm -rf -- "$d" && echo "gc-unmarked $d"
  fi
done
if [ -d .repos ]; then
  for r in .repos/*/; do
    r="\${r%/}"
    [ -d "$r" ] || continue
    [ $(( now - $(stat -c %Y "$r") )) -gt $(( ${repoRetentionDays} * 86400 )) ] && rm -rf -- "$r" && echo "gc-repo $r"
  done
fi
${BUSY_PROBE}
idle() {
  case "$busy" in *"$root/$1
"*) return 1 ;; esac
  stamp="$1"; [ -f "$1/.rb-epoch" ] && stamp="$1/.rb-epoch"
  [ $(( now - $(stat -c %Y "$stamp") )) -gt $(( ${protectMinutes} * 60 )) ]
}
while [ "$(df -BG --output=avail "$root" | tail -1 | tr -dc 0-9)" -lt ${minFreeGb} ]; do
  oldest=""; oldest_ts=""
  for d in */; do
    d="\${d%/}"
    [ -d "$d" ] && [ "$d" != ".repos" ] && [ -f "$d/.rb-epoch" ] || continue
    idle "$d" || continue
    ts=$(stat -c %Y "$d/.rb-epoch")
    if [ -z "$oldest_ts" ] || [ "$ts" -lt "$oldest_ts" ]; then oldest="$d"; oldest_ts="$ts"; fi
  done
  if [ -z "$oldest" ]; then
    for r in .repos/*/; do
      r="\${r%/}"
      [ -d "$r" ] || continue
      idle "$r" || continue
      ts=$(stat -c %Y "$r")
      if [ -z "$oldest_ts" ] || [ "$ts" -lt "$oldest_ts" ]; then oldest="$r"; oldest_ts="$ts"; fi
    done
    [ -n "$oldest" ] || break
    rm -rf -- "$oldest" && echo "gc-repo-floor $oldest"
    continue
  fi
  rm -rf -- "$oldest" && echo "gc-floor $oldest"
done
exit 0`;
}

// Ephemeral local roots (lander candidates, disposable worktrees) mint a mirror and a bare repo
// that nothing on the box can tell apart from a live one. syncPush stamps the originating local
// path into .rb-origin; the laptop is the only side that can answer whether that path still
// exists, so orphan reaping runs from here.
export function orphanScanScript(remoteRoot) {
  return `set -u
root=${JSON.stringify(remoteRoot)}
cd "$root" 2>/dev/null || exit 0
for f in */.rb-origin .repos/*/.rb-origin; do
  [ -f "$f" ] || continue
  d="\${f%/.rb-origin}"
  origin=""
  IFS= read -r origin < "$f" || true
  [ -n "$origin" ] || continue
  printf '%s\\t%s\\t%s\\n' "$d" "$(stat -c %Y "$d")" "$origin"
done
exit 0`;
}

function validateRel(rel) {
  if (typeof rel !== "string" || rel === "" || rel === "." || rel === ".." || rel === ".repos") return false;
  if (/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(rel)) return !rel.includes("..");
  const repo = /^\.repos\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git$/.exec(rel);
  return Boolean(repo) && !repo[1].includes("..");
}

export function orphanDropScript(remoteRoot, rels) {
  const safe = rels.filter(validateRel);
  if (!safe.length) return null;
  return `set -u
root=${JSON.stringify(remoteRoot)}
cd "$root" 2>/dev/null || exit 0
${BUSY_PROBE}
for d in ${safe.map((r) => JSON.stringify(r)).join(" ")}; do
  [ -d "$d" ] || continue
  case "$busy" in *"$root/$d
"*) continue ;; esac
  rm -rf -- "$d" && echo "gc-orphan $d"
done
exit 0`;
}

export function reapOrphanMirrors({ cfg, ssh, log = () => {}, now = Date.now(), exists = existsSync }) {
  const protectMs = Number(cfg.gc_protect_minutes ?? GC_DEFAULTS.gc_protect_minutes) * 60 * 1000;
  const scan = ssh(cfg, orphanScanScript(cfg.remote_root), { timeoutSec: 120 });
  if (scan.status !== 0) { log(`mirror reap scan failed host=${cfg.host} status=${scan.status}`); return { ok: false, reaped: [] }; }
  const orphans = String(scan.stdout ?? "").split("\n").flatMap((line) => {
    const [rel, mtime, origin] = line.split("\t");
    if (!rel || !origin) return [];
    if (now - Number(mtime) * 1000 <= protectMs) return [];
    return exists(origin) ? [] : [rel];
  });
  const script = orphanDropScript(cfg.remote_root, orphans);
  if (!script) return { ok: true, reaped: [] };
  const drop = ssh(cfg, script, { timeoutSec: 300 });
  const reaped = String(drop.stdout ?? "").split("\n").filter((line) => line.startsWith("gc-orphan"));
  if (drop.status !== 0) { log(`mirror reap failed host=${cfg.host} status=${drop.status}`); return { ok: false, reaped }; }
  if (reaped.length) log(`mirror reap host=${cfg.host} removed=${reaped.length} [${reaped.join(", ")}]`);
  return { ok: true, reaped };
}

// Test stacks and seat containers exit without tearing down, so their containers, volumes
// and dangling layers outlive every job that made them; the pnpm store is append-only.
// Both are agent output, so the box reclaims them on the same timer as the mirrors.
export function storeReclaimScript(
  retentionHours,
  maxRuntimeHours = GC_DEFAULTS.gc_container_max_runtime_hours,
  imageRetentionDays = GC_DEFAULTS.gc_image_retention_days,
) {
  if (!Number.isInteger(retentionHours) || retentionHours < 1) throw new Error(`invalid gc_container_retention_hours: ${retentionHours}`);
  if (!Number.isInteger(maxRuntimeHours) || maxRuntimeHours < 1) throw new Error(`invalid gc_container_max_runtime_hours: ${maxRuntimeHours}`);
  if (!Number.isInteger(imageRetentionDays) || imageRetentionDays < 1) throw new Error(`invalid gc_image_retention_days: ${imageRetentionDays}`);
  const until = `${retentionHours}h`;
  const maxRun = `${maxRuntimeHours}h`;
  const imageUntil = `${imageRetentionDays * 24}h`;
  // A remote job is capped at remote_job_timeout_sec (2h), so a container still up after
  // maxRuntimeHours has outlived every job that could own it and is stopped first — otherwise it
  // pins its whole pod. compose stacks become pods, and `container prune` refuses anything that
  // belongs to one, so the pods go next or nothing below them is ever reclaimable. `pod rm`
  // without -f leaves a pod that still has a running container alone. Images use -a on a much
  // longer window: only an image no container has claimed in a week is cold enough that re-pulling
  // it costs less than the gigabytes it holds.
  return `if command -v podman >/dev/null 2>&1; then
  stuck=$(podman ps -q --filter until=${maxRun} 2>/dev/null)
  if [ -n "$stuck" ]; then
    n=$(printf '%s\\n' $stuck | xargs -r podman stop -t 10 2>/dev/null | grep -c .)
    [ "\${n:-0}" -gt 0 ] && echo "gc-podman-stuck \${n}"
  fi
  pods=$(for s in exited stopped created; do podman pod ls --filter until=${until} --filter status=$s -q 2>/dev/null; done | sort -u)
  if [ -n "$pods" ]; then
    n=$(printf '%s\\n' $pods | xargs -r podman pod rm 2>/dev/null | grep -c .)
    [ "\${n:-0}" -gt 0 ] && echo "gc-podman-pods \${n}"
  fi
  n=$(podman container prune -f --filter until=${until} 2>/dev/null | grep -c .); [ "\${n:-0}" -gt 0 ] && echo "gc-podman-containers \${n}"
  n=$(podman volume prune -f --filter until=${until} 2>/dev/null | grep -c .); [ "\${n:-0}" -gt 0 ] && echo "gc-podman-volumes \${n}"
  n=$(podman image prune -af --filter until=${imageUntil} 2>/dev/null | grep -c .); [ "\${n:-0}" -gt 0 ] && echo "gc-podman-images \${n}"
fi
if command -v pnpm >/dev/null 2>&1; then
  pnpm store prune >/dev/null 2>&1 && echo "gc-pnpm-store"
fi`;
}

// The box runs the same retention/floor logic on a timer so the mirror root is bounded even when
// no laptop ever offloads to it again. Orphan reaping stays laptop-side: only it can resolve
// .rb-origin.
export function boxGcScript(cfg) {
  const jobsRetentionDays = Number(cfg.gc_jobs_retention_days ?? GC_DEFAULTS.gc_jobs_retention_days);
  if (!Number.isInteger(jobsRetentionDays) || jobsRetentionDays < 1) throw new Error(`invalid gc_jobs_retention_days: ${cfg.gc_jobs_retention_days}`);
  return `#!/bin/sh
[ -d "$HOME/.rb/jobs" ] && find "$HOME/.rb/jobs" -mindepth 1 -maxdepth 1 -type d -mtime +${jobsRetentionDays} -exec rm -rf {} + 2>/dev/null
${storeReclaimScript(
    Number(cfg.gc_container_retention_hours ?? GC_DEFAULTS.gc_container_retention_hours),
    Number(cfg.gc_container_max_runtime_hours ?? GC_DEFAULTS.gc_container_max_runtime_hours),
    Number(cfg.gc_image_retention_days ?? GC_DEFAULTS.gc_image_retention_days),
  )}
${gcScript(cfg.remote_root, {
    retentionDays: Number(cfg.gc_retention_days ?? GC_DEFAULTS.gc_retention_days),
    minFreeGb: Number(cfg.gc_min_free_gb ?? GC_DEFAULTS.gc_min_free_gb),
    repoRetentionDays: Number(cfg.gc_repo_retention_days ?? GC_DEFAULTS.gc_repo_retention_days),
    protectMinutes: Number(cfg.gc_protect_minutes ?? GC_DEFAULTS.gc_protect_minutes),
  })}
`;
}

export function sweepRemoteMirrors({ cfg, stateDir, ssh, log = () => {}, now = Date.now() }) {
  const host = cfg.host;
  const intervalHours = Number(cfg.gc_interval_hours ?? GC_DEFAULTS.gc_interval_hours);
  try {
    if (!gcDue(stateDir, host, intervalHours, now)) return { ran: false, reason: "throttled" };
    markGcRan(stateDir, host, now);
    const reap = reapOrphanMirrors({ cfg, ssh, log, now });
    const script = gcScript(cfg.remote_root, {
      retentionDays: Number(cfg.gc_retention_days ?? GC_DEFAULTS.gc_retention_days),
      minFreeGb: Number(cfg.gc_min_free_gb ?? GC_DEFAULTS.gc_min_free_gb),
      repoRetentionDays: Number(cfg.gc_repo_retention_days ?? GC_DEFAULTS.gc_repo_retention_days),
      protectMinutes: Number(cfg.gc_protect_minutes ?? GC_DEFAULTS.gc_protect_minutes),
    });
    const result = ssh(cfg, script, { timeoutSec: 300 });
    const removed = [...reap.reaped, ...String(result.stdout ?? "").split("\n").filter((line) => line.startsWith("gc-"))];
    if (result.status !== 0) { log(`mirror gc failed host=${host} status=${result.status}`); return { ran: true, ok: false, removed }; }
    if (removed.length) log(`mirror gc host=${host} removed=${removed.length} [${removed.join(", ")}]`);
    return { ran: true, ok: true, removed, reaped: reap.ok };
  } catch (error) {
    log(`mirror gc error host=${host} error=${error?.message ?? error}`);
    return { ran: false, reason: "error" };
  }
}
