#!/usr/bin/env bash
# Task 273 — one-time JSONL stash-composite cleanup.
#
# For each `<uuid>.<unix-ms>.jsonl` file under a CLAUDE_STATE_ROOT/projects
# directory (top-level and archive/ subdir), pair it with the canonical
# `<uuid>.jsonl`. Keep the larger of the two as the canonical; move the
# smaller (with its sidecar) to QUARANTINE_ROOT, preserving the original
# basename. If only the timestamped sibling exists (no canonical), promote
# it to the canonical name. The quarantine path sits outside any directory
# the fs-watcher scans, so quarantined files do not appear in the sidebar.
#
# Safety rails:
#   1. Refuse to run if any PID file exists under CLAUDE_STATE_ROOT/sessions/.
#      A live PTY holding one of the affected UUIDs is the failure mode this
#      check guards against.
#   2. Refuse to overwrite an existing quarantine target — if the same
#      timestamped basename already sits in quarantine from a prior run,
#      surface the conflict and skip rather than clobber.
#   3. Sidecar `.meta.json` files follow the same disposition as their JSONL.
#   4. A `.log` audit trail records every rename and quarantine move with
#      from=, to=, bytes=, mtime=. The transcript is the operator's
#      audit before approving permanent deletion in a follow-up task.
#
# Usage:
#   stash-cleanup.sh <claude-state-root> [--dry-run]
#     e.g. ~/.realagent-code/.claude
#
#   stash-cleanup.sh --self-test
#     Seeds a temp dir with collision pairs, runs the cleanup against it,
#     asserts post-conditions, and cleans up. Exits non-zero on failure.

set -euo pipefail

usage() {
  cat <<USAGE
Task 273 JSONL stash-cleanup.

Usage:
  $0 <claude-state-root> [--dry-run]
  $0 --self-test

Arguments:
  <claude-state-root>  e.g. ~/.realagent-code/.claude

Flags:
  --dry-run    Plan-only: log what would happen, change nothing on disk.
  --self-test  Run the embedded smoke test; exits 0 on PASS.
USAGE
}

log() { printf '[stash-cleanup] %s\n' "$*"; }
err() { printf '[stash-cleanup] ERROR: %s\n' "$*" >&2; }

# Returns 0 if the basename matches `<uuid>.<unix-ms>.jsonl`. UUID is
# 36 chars including dashes; ms timestamp is exactly 13 digits.
is_stash_composite_basename() {
  local name="$1"
  [[ "$name" =~ ^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.[0-9]{13}\.jsonl$ ]]
}

# Extract the canonical `<uuid>.jsonl` basename from a stash composite.
canonical_basename_for() {
  local stash="$1"
  printf '%s.jsonl\n' "${stash:0:36}"
}

bytes_of() {
  if [[ -f "$1" ]]; then
    stat -f%z "$1" 2>/dev/null || stat -c%s "$1"
  else
    printf '0\n'
  fi
}

mtime_of() {
  if [[ -f "$1" ]]; then
    stat -f%m "$1" 2>/dev/null || stat -c%Y "$1"
  else
    printf '0\n'
  fi
}

# Move a JSONL file (and its sidecar if present) to quarantine, preserving
# the original basename. Refuses to clobber.
move_to_quarantine() {
  local src="$1" qdir="$2" reason="$3"
  local bn dst sidecar_src sidecar_dst
  bn=$(basename "$src")
  dst="$qdir/$bn"

  if [[ -e "$dst" ]]; then
    err "quarantine target already exists, skipping: $dst"
    return 1
  fi

  local sz mt
  sz=$(bytes_of "$src")
  mt=$(mtime_of "$src")
  if [[ "$DRY_RUN" == "yes" ]]; then
    log "DRY quarantine reason=$reason from=$src to=$dst bytes=$sz mtime=$mt"
  else
    mkdir -p "$qdir"
    mv "$src" "$dst"
    log "quarantine reason=$reason from=$src to=$dst bytes=$sz mtime=$mt"
  fi

  # Sidecar follows: `<base>.meta.json` where <base> is the JSONL path minus
  # the trailing `.jsonl`.
  sidecar_src="${src%.jsonl}.meta.json"
  sidecar_dst="${dst%.jsonl}.meta.json"
  if [[ -f "$sidecar_src" ]]; then
    if [[ -e "$sidecar_dst" ]]; then
      err "sidecar quarantine target already exists, skipping: $sidecar_dst"
      return 1
    fi
    local ssz smt
    ssz=$(bytes_of "$sidecar_src")
    smt=$(mtime_of "$sidecar_src")
    if [[ "$DRY_RUN" == "yes" ]]; then
      log "DRY quarantine-sidecar from=$sidecar_src to=$sidecar_dst bytes=$ssz mtime=$smt"
    else
      mv "$sidecar_src" "$sidecar_dst"
      log "quarantine-sidecar from=$sidecar_src to=$sidecar_dst bytes=$ssz mtime=$smt"
    fi
  fi
}

# Promote `src` to `dst` by renaming. Sidecar follows.
promote_in_place() {
  local src="$1" dst="$2" reason="$3"
  local sidecar_src sidecar_dst
  local sz mt
  sz=$(bytes_of "$src")
  mt=$(mtime_of "$src")
  if [[ "$DRY_RUN" == "yes" ]]; then
    log "DRY promote reason=$reason from=$src to=$dst bytes=$sz mtime=$mt"
  else
    mv "$src" "$dst"
    log "promote reason=$reason from=$src to=$dst bytes=$sz mtime=$mt"
  fi

  sidecar_src="${src%.jsonl}.meta.json"
  sidecar_dst="${dst%.jsonl}.meta.json"
  if [[ -f "$sidecar_src" ]]; then
    local ssz smt
    ssz=$(bytes_of "$sidecar_src")
    smt=$(mtime_of "$sidecar_src")
    if [[ "$DRY_RUN" == "yes" ]]; then
      log "DRY promote-sidecar from=$sidecar_src to=$sidecar_dst bytes=$ssz mtime=$smt"
    else
      mv "$sidecar_src" "$sidecar_dst"
      log "promote-sidecar from=$sidecar_src to=$sidecar_dst bytes=$ssz mtime=$smt"
    fi
  fi
}

# Process one directory (top-level or archive/). For each stash composite,
# pair with canonical; resolve.
process_dir() {
  local dir="$1" qdir="$2"
  [[ -d "$dir" ]] || { log "skip: $dir does not exist"; return 0; }

  local handled=0
  shopt -s nullglob
  for path in "$dir"/*.jsonl; do
    local bn
    bn=$(basename "$path")
    is_stash_composite_basename "$bn" || continue
    handled=$((handled + 1))

    local canonical_bn canonical_path
    canonical_bn=$(canonical_basename_for "$bn")
    canonical_path="$dir/$canonical_bn"

    if [[ -f "$canonical_path" ]]; then
      local stash_sz canonical_sz
      stash_sz=$(bytes_of "$path")
      canonical_sz=$(bytes_of "$canonical_path")
      log "pair dir=$dir uuid=${bn:0:8} stash=$bn stash-bytes=$stash_sz canonical-bytes=$canonical_sz"

      if (( stash_sz > canonical_sz )); then
        # Stash wins. Quarantine canonical first (preserving its name),
        # then promote stash to canonical.
        local canonical_quarantine_name="$canonical_bn.canonical"
        local canonical_qdst="$qdir/$canonical_quarantine_name"
        if [[ -e "$canonical_qdst" ]]; then
          err "canonical quarantine target exists, skipping pair: $canonical_qdst"
          continue
        fi
        local csz cmt
        csz=$(bytes_of "$canonical_path")
        cmt=$(mtime_of "$canonical_path")
        if [[ "$DRY_RUN" == "yes" ]]; then
          log "DRY quarantine reason=smaller-canonical from=$canonical_path to=$canonical_qdst bytes=$csz mtime=$cmt"
        else
          mkdir -p "$qdir"
          mv "$canonical_path" "$canonical_qdst"
          log "quarantine reason=smaller-canonical from=$canonical_path to=$canonical_qdst bytes=$csz mtime=$cmt"
        fi
        # Canonical sidecar follows with the same suffix.
        local csc_src="${canonical_path%.jsonl}.meta.json"
        local csc_dst="${canonical_qdst%.jsonl}.meta.json"
        if [[ -f "$csc_src" ]]; then
          if [[ "$DRY_RUN" == "yes" ]]; then
            log "DRY quarantine-sidecar from=$csc_src to=$csc_dst"
          else
            mv "$csc_src" "$csc_dst"
            log "quarantine-sidecar from=$csc_src to=$csc_dst"
          fi
        fi
        promote_in_place "$path" "$canonical_path" "stash-larger-than-canonical"
      else
        # Canonical wins (or equal). Quarantine stash with its original
        # timestamped basename.
        move_to_quarantine "$path" "$qdir" "smaller-stash" || true
      fi
    else
      # No canonical on disk. Promote the stash to canonical.
      log "lone-stash dir=$dir uuid=${bn:0:8} stash=$bn"
      promote_in_place "$path" "$canonical_path" "no-canonical-present"
    fi
  done
  shopt -u nullglob

  log "summary dir=$dir composites-handled=$handled"
}

# Walks every project directory under <state-root>/projects/ and processes
# top-level + archive/ for each.
run_cleanup() {
  local state_root="$1"
  local projects_root="$state_root/projects"
  local sessions_root="$state_root/sessions"
  local quarantine_root="$state_root/quarantine"

  [[ -d "$projects_root" ]] || { err "projects root not found: $projects_root"; return 2; }

  # Pre-flight: no live PTY may be running. The sessions/ directory holds
  # one PID file per live PTY (Task 199 contract); empty = no live PTYs.
  if [[ -d "$sessions_root" ]]; then
    shopt -s nullglob
    local pids=("$sessions_root"/*.json)
    shopt -u nullglob
    if (( ${#pids[@]} > 0 )); then
      err "live PTY detected — ${#pids[@]} PID file(s) under $sessions_root; refuse to touch on-disk state"
      err "stop the affected sessions first, then retry"
      return 3
    fi
  fi
  log "pre-flight pid-files=0 sessions-root=$sessions_root"

  shopt -s nullglob
  local project_dirs=("$projects_root"/*/)
  shopt -u nullglob
  log "projects-found count=${#project_dirs[@]}"

  for project_dir in "${project_dirs[@]}"; do
    local slug
    slug=$(basename "$project_dir")
    local qdir="$quarantine_root/$slug"
    log "project-begin slug=$slug"
    process_dir "${project_dir%/}" "$qdir"
    process_dir "${project_dir%/}/archive" "$qdir/archive"
    log "project-end slug=$slug"
  done

  log "done state-root=$state_root quarantine-root=$quarantine_root dry-run=$DRY_RUN"
}

# ----- self-test --------------------------------------------------------

self_test() {
  local tmp
  tmp=$(mktemp -d)
  # Expand at trap-set time so the path survives function-local scope.
  trap "rm -rf '$tmp'" EXIT

  local state="$tmp/state"
  mkdir -p "$state/projects/slug-A" "$state/projects/slug-A/archive" "$state/sessions"

  # Pair 1: timestamped sibling is larger; should win.
  local uuid1="4bfe4274-1111-4111-8111-111111111111"
  local big="payload-big"
  local small="x"
  printf '%s' "$big" > "$state/projects/slug-A/${uuid1}.1779477697566.jsonl"
  printf '%s' "$small" > "$state/projects/slug-A/${uuid1}.jsonl"

  # Pair 2: canonical is larger; sibling loses.
  local uuid2="9c26cc66-2222-4222-8222-222222222222"
  printf '%s' "big-canonical" > "$state/projects/slug-A/${uuid2}.jsonl"
  printf '%s' "tiny" > "$state/projects/slug-A/${uuid2}.1779477700000.jsonl"

  # Pair 3 (archive): canonical wins (matches the be23f93a archive-pair shape).
  local uuid3="654f5fe5-3333-4333-8333-333333333333"
  printf '%s' "archived-big-history" > "$state/projects/slug-A/archive/${uuid3}.jsonl"
  printf '%s' "tiny" > "$state/projects/slug-A/archive/${uuid3}.1779477705000.jsonl"

  # Pair 4: lone stash, no canonical present.
  local uuid4="abcdef01-4444-4444-8444-444444444444"
  printf '%s' "lone-stash-content" > "$state/projects/slug-A/${uuid4}.1779477710000.jsonl"

  # Sidecar on uuid1's stash (the winner) — should travel with the rename
  # to canonical. Sidecar on uuid2's stash (the loser) — should follow to
  # quarantine.
  printf '{"meta":"win"}' > "$state/projects/slug-A/${uuid1}.1779477697566.meta.json"
  printf '{"meta":"lose"}' > "$state/projects/slug-A/${uuid2}.1779477700000.meta.json"

  DRY_RUN=no run_cleanup "$state" >/tmp/stash-cleanup-selftest.log 2>&1 \
    || { cat /tmp/stash-cleanup-selftest.log; err "self-test: run_cleanup failed"; exit 1; }

  local fail=0
  local q="$state/quarantine/slug-A"
  local qa="$state/quarantine/slug-A/archive"

  # uuid1: canonical now holds "payload-big"; old canonical lives in
  # quarantine with the `.canonical` suffix.
  if [[ "$(cat "$state/projects/slug-A/${uuid1}.jsonl" 2>/dev/null)" != "$big" ]]; then
    err "uuid1 canonical did not receive stash content"; fail=1
  fi
  if [[ -e "$state/projects/slug-A/${uuid1}.1779477697566.jsonl" ]]; then
    err "uuid1 stash sibling still on disk after promotion"; fail=1
  fi
  if [[ "$(cat "$q/${uuid1}.jsonl.canonical" 2>/dev/null)" != "$small" ]]; then
    err "uuid1 old canonical not in quarantine"; fail=1
  fi
  # Sidecar followed promotion.
  if [[ "$(cat "$state/projects/slug-A/${uuid1}.meta.json" 2>/dev/null)" != '{"meta":"win"}' ]]; then
    err "uuid1 sidecar did not follow promotion"; fail=1
  fi

  # uuid2: canonical untouched; stash sibling in quarantine.
  if [[ "$(cat "$state/projects/slug-A/${uuid2}.jsonl" 2>/dev/null)" != "big-canonical" ]]; then
    err "uuid2 canonical altered"; fail=1
  fi
  if [[ ! -f "$q/${uuid2}.1779477700000.jsonl" ]]; then
    err "uuid2 stash sibling not quarantined"; fail=1
  fi
  if [[ "$(cat "$q/${uuid2}.1779477700000.meta.json" 2>/dev/null)" != '{"meta":"lose"}' ]]; then
    err "uuid2 sidecar did not follow to quarantine"; fail=1
  fi

  # uuid3 (archive): canonical wins; stash sibling in archive-quarantine.
  if [[ "$(cat "$state/projects/slug-A/archive/${uuid3}.jsonl" 2>/dev/null)" != "archived-big-history" ]]; then
    err "uuid3 archive canonical altered"; fail=1
  fi
  if [[ ! -f "$qa/${uuid3}.1779477705000.jsonl" ]]; then
    err "uuid3 archive stash sibling not quarantined under archive/"; fail=1
  fi

  # uuid4: lone stash promoted in place; no quarantine.
  if [[ "$(cat "$state/projects/slug-A/${uuid4}.jsonl" 2>/dev/null)" != "lone-stash-content" ]]; then
    err "uuid4 lone stash not promoted to canonical"; fail=1
  fi
  if [[ -e "$state/projects/slug-A/${uuid4}.1779477710000.jsonl" ]]; then
    err "uuid4 stash file still on disk after promotion"; fail=1
  fi

  # No <uuid>.<unix-ms>.jsonl siblings remain in the projects scan scope.
  local residual
  residual=$(find "$state/projects" -type f -name '*.jsonl' \
    | awk -F/ '{ print $NF }' \
    | grep -cE '^[a-f0-9-]{36}\.[0-9]{13}\.jsonl$' || true)
  if (( residual > 0 )); then
    err "self-test: $residual timestamped siblings still present"; fail=1
  fi

  # Live-PTY guard: seed a PID file and confirm the script refuses.
  printf '{"pid":1234}' > "$state/sessions/1234.json"
  if DRY_RUN=no run_cleanup "$state" >>/tmp/stash-cleanup-selftest.log 2>&1; then
    err "self-test: live-PTY guard did not refuse"; fail=1
  fi
  rm -f "$state/sessions/1234.json"

  if (( fail > 0 )); then
    cat /tmp/stash-cleanup-selftest.log
    err "self-test: FAILED"
    exit 1
  fi

  log "self-test: PASS"
}

# ----- entry point ------------------------------------------------------

DRY_RUN=no
case "${1:-}" in
  -h|--help|"")
    usage
    exit 0
    ;;
  --self-test)
    self_test
    exit 0
    ;;
  *)
    STATE_ROOT="${1:-}"
    shift || true
    while (( $# > 0 )); do
      case "$1" in
        --dry-run) DRY_RUN=yes ;;
        *) err "unknown flag: $1"; usage; exit 2 ;;
      esac
      shift
    done
    [[ -n "$STATE_ROOT" ]] || { usage; exit 2; }
    # Expand ~ since the caller may pass it.
    STATE_ROOT="${STATE_ROOT/#\~/$HOME}"
    run_cleanup "$STATE_ROOT" || exit $?
    ;;
esac
