#!/usr/bin/env bash
# rp-isolate.sh — REPO-LEVEL isolation for a run-plan run (the --isolate flag).
# audience: AI coding agents first.
#
# WHY: run-plan already isolates per TASK (worktrees) but a whole RUN shares the repo's one .git
# (worktree registry, packed-refs) and its land step mutates the shared base. Two runs against the
# same repo at once therefore contend, and a merge-to-main land by one breaks the other's fast-forward.
# --isolate runs a plan in a SEPARATE CLONE (its own .git) and freezes that clone's land method to
# `pr` — so it never touches the primary's base. The clone is discovered by run-plan's own `load`
# via RUN_PLAN_PROJECTS, so the ENTIRE engine (setup/lease/commit/ship) targets the clone unchanged.
#
# SUBCOMMANDS (fail-closed; one JSON line on stdout, diagnostics on stderr):
#   setup <slug> [--testcmd CMD]   clone+seed+freeze-pr; idempotent (reuse a valid existing clone).
#                                  Emits {"clone","projects","jsonl","base","reused"}.
#   teardown <slug>                rm -rf the slug's iso dir — HARD-guarded (path prefix + sentinel).
#   gc [--days N]                  rm valid iso dirs older than N days (default 7); each guarded.
#
# The clone lives at  ~/tmp/rp-iso/<slug>/repo  ; RUN_PLAN_PROJECTS for the run = ~/tmp/rp-iso/<slug>.
# A sentinel  ~/tmp/rp-iso/<slug>/.rp-isolate-sentinel  marks the dir ours — teardown/gc REFUSE without it.
# ISO_ROOT is deliberately OUTSIDE any single repo (it names its own clone, pre-dating which repo it's
# for) — never /tmp though (a full/tmpfs /tmp crashed the desktop before); ~/tmp is the correct fallback.
set -uo pipefail

LIB="/home/user/.claude/workflows/lib/run-plan-lib.sh"
SHIP_INIT="/home/user/.claude/workflows/lib/ship-init.sh"
ISO_ROOT="${RP_ISOLATE_ROOT:-$HOME/tmp/rp-iso}"
SENTINEL_NAME=".rp-isolate-sentinel"

die()  { echo "rp-isolate: $1" >&2; exit "${2:-3}"; }
jstr() { local s=${1//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/ }; printf '%s' "$s"; }

# slug must be a single safe path segment — no traversal, no separators (it names a /tmp dir + a glob).
_validate_slug() {
  local slug=$1
  [[ -n "$slug" ]] || die "slug required"
  [[ "$slug" =~ ^[A-Za-z0-9._-]+$ ]] || die "unsafe slug '$slug' (allowed: A-Za-z0-9._-)"
  [[ "$slug" != "." && "$slug" != ".." ]] || die "unsafe slug '$slug'"
}

# Resolve the iso dir for a slug and assert it is a path we are willing to rm -rf.
# Guards (ALL must hold): canonical path is under ISO_ROOT, basename == slug, sentinel present + matches.
# Echoes the canonical iso dir on stdout + returns 0 on success; prints reason to stderr + returns 1 otherwise.
# MUST return (not exit/die) — it runs inside $(...) where an exit would only kill the subshell, hiding the
# failure from the caller and letting an unguarded rm proceed.
_assert_iso_dir() {
  local slug=$1 dir=$2 real
  real=$(cd "$dir" 2>/dev/null && pwd -P) || { echo "rp-isolate: iso dir not found: $dir" >&2; return 1; }
  case "$real/" in
    "$ISO_ROOT"/*) ;;
    *) echo "rp-isolate: refuse rm: '$real' is not under $ISO_ROOT" >&2; return 1 ;;
  esac
  [[ "$(basename "$real")" == "$slug" ]] || { echo "rp-isolate: refuse rm: '$real' basename != slug '$slug'" >&2; return 1; }
  [[ -f "$real/$SENTINEL_NAME" ]] || { echo "rp-isolate: refuse rm: no sentinel at '$real' — not an rp-isolate dir" >&2; return 1; }
  grep -qx "slug=$slug" "$real/$SENTINEL_NAME" || { echo "rp-isolate: refuse rm: sentinel slug mismatch at '$real'" >&2; return 1; }
  printf '%s' "$real"
}

# ── setup ─────────────────────────────────────────────────────────────────────
setup() {
  local slug="" testcmd=""
  while [[ $# -gt 0 ]]; do case "$1" in
    --testcmd) testcmd=${2:-}; shift 2;;
    -*) die "setup: unknown arg $1";;
    *) [[ -z "$slug" ]] && { slug=$1; shift; } || die "setup: unexpected arg $1";;
  esac; done
  _validate_slug "$slug"
  [[ -f "$LIB" ]]       || die "missing $LIB"
  [[ -f "$SHIP_INIT" ]] || die "missing $SHIP_INIT"

  local isodir="$ISO_ROOT/$slug" clone="$ISO_ROOT/$slug/repo" sentinel="$ISO_ROOT/$slug/$SENTINEL_NAME"

  # ── idempotent reuse: a valid existing clone is REUSED verbatim (never re-seeded — re-seeding would
  #    clobber in-clone task statuses the run wrote, breaking resume). Foreign dir → refuse.
  if [[ -e "$isodir" ]]; then
    if [[ -f "$sentinel" ]] && grep -qx "slug=$slug" "$sentinel" && [[ -d "$clone/.git" ]]; then
      local cj
      cj=$(find "$clone/docs/plans" -maxdepth 1 -name "*-${slug}.jsonl" 2>/dev/null | sort | tail -1)
      [[ -n "$cj" ]] || die "reuse: clone present but jsonl missing at $clone/docs/plans (teardown $slug and retry)"
      local cbase; cbase=$(grep -m1 -oE '"base_branch":"[^"]*"' "$cj" | cut -d'"' -f4)
      printf '{"clone":"%s","projects":"%s","jsonl":"%s","base":"%s","reused":true}\n' \
        "$(jstr "$clone")" "$(jstr "$isodir")" "$(jstr "$cj")" "$(jstr "${cbase:-main}")"
      return 0
    fi
    die "refuse: '$isodir' exists but is not a valid rp-isolate clone (run 'rp-isolate teardown $slug' if it is stale)"
  fi

  # ── resolve primary facts via run-plan's OWN load (DRY: same discovery + .git-dir repoRoot resolution).
  local manifest; manifest=$(bash "$LIB" load "$slug") || die "load failed for slug $slug"
  local primary_root primary_jsonl base
  primary_root=$(printf '%s' "$manifest"  | grep -oE '"repoRoot":"[^"]*"'  | head -1 | cut -d'"' -f4)
  primary_jsonl=$(printf '%s' "$manifest" | grep -oE '"jsonlPath":"[^"]*"' | head -1 | cut -d'"' -f4)
  [[ -n "$primary_root" ]]  || die "load returned empty repoRoot for slug $slug (no plan / no .git-dir ancestor)"
  [[ -d "$primary_root/.git" ]] || die "primary repoRoot has no .git directory: $primary_root"
  [[ -f "$primary_jsonl" ]] || die "primary jsonl not found: $primary_jsonl"
  base=$(printf '%s' "$manifest" | grep -oE '"base_branch":"[^"]*"' | head -1 | cut -d'"' -f4)
  base=${base:-main}
  base=${base#origin/}   # plans store base_branch as "origin/<name>"; the actual branch name is <name>

  local origin_url; origin_url=$(git -C "$primary_root" remote get-url origin 2>/dev/null) \
    || die "primary repo has no 'origin' remote — isolation lands via PR against origin, which is required"
  [[ -n "$origin_url" ]] || die "primary 'origin' remote URL is empty"

  # ── fail-closed: refuse to isolate when local base is AHEAD of origin (unpushed commits). The clone
  #    builds against origin/<base>; silently dropping local-only commits would ship an incomplete base.
  local local_tip remote_tip
  local_tip=$(git -C "$primary_root" rev-parse --verify "refs/heads/$base" 2>/dev/null) \
    || die "primary has no local branch '$base' to compare against origin"
  remote_tip=$(git -C "$primary_root" ls-remote origin "refs/heads/$base" 2>/dev/null | cut -f1)
  [[ -n "$remote_tip" ]] || die "origin has no branch '$base' — push it before isolating (PR base must exist on origin)"
  if [[ "$local_tip" != "$remote_tip" ]]; then
    if git -C "$primary_root" merge-base --is-ancestor "$remote_tip" "$local_tip" 2>/dev/null; then
      die "local '$base' is AHEAD of origin/$base (unpushed commits). Push first: git -C $primary_root push origin $base — the isolated run builds against origin/$base."
    fi
    # else origin ahead / diverged → fine: the clone gets origin's (newer) base.
  fi

  # ── resolve the PR-land testcmd (ship-init --mode pr REQUIRES one; this repo may have no sniffable test):
  #    explicit arg  >  meta.land_testcmd in the plan  >  inherit primary ship.sh TESTCMD  >  ship-init sniff.
  if [[ -z "$testcmd" ]]; then
    testcmd=$(grep -m1 -oE '"land_testcmd":"([^"\\]|\\.)*"' "$primary_jsonl" \
                | sed -E 's/^"land_testcmd":"//; s/"$//' | sed 's/\\"/"/g')
  fi
  if [[ -z "$testcmd" && -f "$primary_root/.claude/scripts/ship.sh" ]]; then
    # ship-init now bakes TESTCMD single-quoted (shq); accept either quote style, reverse shq's '\'' escape.
    testcmd=$(sed -nE "s/^TESTCMD=\"(.*)\"\$/\1/p; s/^TESTCMD='(.*)'\$/\1/p" "$primary_root/.claude/scripts/ship.sh" | head -1)
    testcmd=${testcmd//\'\\\'\'/\'}
  fi

  # ── clone from ORIGIN (zero contact with the primary's live .git — no contention with a concurrent run).
  mkdir -p "$isodir" || die "cannot create $isodir"
  if ! git clone --quiet --origin origin "$origin_url" "$clone" 2>/dev/null; then
    rm -rf -- "$isodir"; die "git clone failed ($origin_url → $clone)"
  fi
  if ! git -C "$clone" checkout -q "$base" 2>/dev/null; then
    rm -rf -- "$isodir"; die "clone has no base branch '$base'"
  fi

  # ── seed the UNCOMMITTED plan artifacts (jsonl + md + every anchor path) from the primary working tree.
  #    A github clone has none of these (they are local-only). Each is copied to the SAME relative path.
  local rel
  for f in "$primary_jsonl" "${primary_jsonl%.jsonl}.md"; do
    [[ -f "$f" ]] || continue
    rel=${f#"$primary_root"/}
    mkdir -p "$clone/$(dirname "$rel")" && cp -f "$f" "$clone/$rel" || { rm -rf -- "$isodir"; die "seed failed: $rel"; }
  done
  # anchor paths (spec etc.) — parse the jsonl directly (load exposes only one specPath; plans may have many).
  local anchors
  anchors=$(grep -oE '"type":"anchor"[^}]*"path":"[^"]*"' "$primary_jsonl" | grep -oE '"path":"[^"]*"' | cut -d'"' -f4)
  while IFS= read -r rel; do
    [[ -n "$rel" ]] || continue
    [[ "$rel" = /* ]] && continue                     # only repo-relative anchors are seedable
    [[ -f "$primary_root/$rel" ]] || continue         # seed only what exists in the primary tree
    mkdir -p "$clone/$(dirname "$rel")" && cp -f "$primary_root/$rel" "$clone/$rel" \
      || { rm -rf -- "$isodir"; die "seed anchor failed: $rel"; }
  done <<< "$anchors"

  # ── surface uncommitted tracked changes in the primary that are NOT seeded: the clone builds against
  #    ORIGIN, so a local-only edit to a file a plan task READS (not an anchor) is silently absent. Loud,
  #    non-fatal — origin is the reproducible base by design; declare such a file an anchor to seed it.
  local jrel mrel seeded warn=""
  jrel=${primary_jsonl#"$primary_root"/}; mrel=${jrel%.jsonl}.md
  seeded=$(printf '%s\n%s\n%s\n' "$jrel" "$mrel" "$anchors")
  while IFS= read -r rel; do
    [[ -n "$rel" ]] || continue
    grep -qxF "$rel" <<< "$seeded" && continue
    warn+=" $rel"
  done < <(git -C "$primary_root" status --porcelain --untracked-files=no 2>/dev/null \
             | sed -E 's/^.{3}//; s/^.* -> //')
  [[ -n "$warn" ]] && echo "rp-isolate: WARNING — primary has uncommitted tracked changes NOT seeded into the clone (the isolated build uses origin's version):$warn — if a plan task reads one, declare it an anchor so setup seeds it." >&2

  # ── force the clone plan's land_mode to "pr" so step-4's freeze ladder (which reads meta.land_mode from
  #    THIS jsonl) can never regenerate a merge-to-main wrapper and silently land on the shared base.
  local clone_jsonl="$clone/${primary_jsonl#"$primary_root"/}"
  RP_J="$clone_jsonl" python3 - <<'PY' || { rm -rf -- "$isodir"; die "land_mode flip failed"; }
import os, json
p=os.environ['RP_J']
with open(p, encoding='utf-8') as f: lines=f.readlines()
out=[]; hits=0
for ln in lines:
    s=ln.rstrip('\n')
    if not s.strip(): out.append(ln); continue
    try: o=json.loads(s)
    except Exception: out.append(ln); continue
    if isinstance(o,dict) and o.get('type')=='meta':
        hits+=1; o['land_mode']='pr'
        out.append(json.dumps(o,separators=(',',':'),ensure_ascii=False)+('\n' if ln.endswith('\n') else ''))
    else: out.append(ln)
assert hits==1, f"expected 1 meta line, found {hits}"
with open(p,'w',encoding='utf-8') as f: f.writelines(out)
PY

  # ── freeze the clone's land wrapper to pr (ship-init validates the anchor via `drift`; needs a testcmd).
  local si_args=(--mode pr --base "$base")
  [[ -n "$testcmd" ]] && si_args+=(--testcmd "$testcmd")
  if ! bash "$SHIP_INIT" "$clone" "${si_args[@]}" >/dev/null 2>&1; then
    local err; err=$(bash "$SHIP_INIT" "$clone" "${si_args[@]}" 2>&1 >/dev/null)
    rm -rf -- "$isodir"
    die "ship-init --mode pr failed for clone: ${err:-unknown}. If 'no test command sniffed', set meta.land_testcmd in the plan or pass --testcmd."
  fi

  printf 'slug=%s\nbase=%s\norigin=%s\nclone=%s\n' "$slug" "$base" "$origin_url" "$clone" > "$sentinel"

  printf '{"clone":"%s","projects":"%s","jsonl":"%s","base":"%s","reused":false}\n' \
    "$(jstr "$clone")" "$(jstr "$isodir")" "$(jstr "$clone_jsonl")" "$(jstr "$base")"
}

# ── teardown ──────────────────────────────────────────────────────────────────
teardown() {
  local slug=${1:-}
  _validate_slug "$slug"
  local isodir="$ISO_ROOT/$slug"
  [[ -e "$isodir" ]] || { printf '{"ok":true,"removed":false,"reason":"absent"}\n'; return 0; }
  local real; real=$(_assert_iso_dir "$slug" "$isodir") || die "refuse teardown of '$isodir' (see reason above)"
  [[ -n "$real" ]] || die "refuse teardown of '$isodir'"
  rm -rf -- "$real" || die "rm failed: $real"
  printf '{"ok":true,"removed":true,"path":"%s"}\n' "$(jstr "$real")"
}

# ── gc ────────────────────────────────────────────────────────────────────────
gc() {
  local days=7
  while [[ $# -gt 0 ]]; do case "$1" in
    --days) days=${2:-7}; shift 2;;
    *) die "gc: unknown arg $1";;
  esac; done
  [[ "$days" =~ ^[0-9]+$ ]] || die "gc: --days must be an integer"
  [[ -d "$ISO_ROOT" ]] || { printf '{"ok":true,"removed":[]}\n'; return 0; }
  local removed=() d slug real
  shopt -s nullglob
  for d in "$ISO_ROOT"/*/; do
    d=${d%/}; slug=$(basename "$d")
    [[ -f "$d/$SENTINEL_NAME" ]] || continue                       # skip foreign dirs
    grep -qx "slug=$slug" "$d/$SENTINEL_NAME" || continue
    [[ -n "$(find "$d" -maxdepth 0 -mtime "+$days" 2>/dev/null)" ]] || continue
    real=$(_assert_iso_dir "$slug" "$d") || continue
    rm -rf -- "$real" && removed+=("$real")
  done
  shopt -u nullglob
  local list=""; for real in "${removed[@]:-}"; do [[ -n "$real" ]] && list+="\"$(jstr "$real")\","; done
  printf '{"ok":true,"removed":[%s]}\n' "${list%,}"
}

# ── dispatch (only when executed, not when sourced by the test harness) ─────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  cmd=${1:-}; shift || true
  case "$cmd" in
    setup)    setup "$@" ;;
    teardown) teardown "$@" ;;
    gc)       gc "$@" ;;
    *) die "usage: rp-isolate.sh {setup <slug> [--testcmd CMD]|teardown <slug>|gc [--days N]}" 2 ;;
  esac
fi
