#!/usr/bin/env bash
# Machine-wide sweep for legacy ~/Projects/* checkout references.
# Emits sorted JSON [{file, line, old_path}] to stdout; default mode updates
# tools/ref-inventory.json. --assert verifies every inventoried old_path exists
# as a symlink or directory.
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
INVENTORY="${REF_INVENTORY:-$SCRIPT_DIR/ref-inventory.json}"

OLD_BASES=(
  mega-plan-harness
  systray-ai
  security-gate
  system-monitor
)

ASSERT=0
if [[ "${1:-}" == "--assert" ]]; then
  ASSERT=1
elif [[ -n "${1:-}" ]]; then
  echo "usage: $(basename "$0") [--assert]" >&2
  exit 2
fi

canonical_old_path() {
  local base="$1"
  printf '%s/Projects/%s' "$HOME" "$base"
}

# Append-only records of past agent conversations and runs, plus transient agent
# checkouts. Nothing resolves a checkout path by reading them, so a stale mention in one
# is not a reference the cutover can break.
EXCLUDED_COMPONENTS=(
  node_modules __pycache__ .cache cache caches history histories logs log
  sessions chats projects scratchpad .run memories backups history.jsonl
  tmp projects.json worktrees .worktrees .superpowers
)

# Only components below the scan root are tested. A root whose own prefix happens to name an
# excluded component — a checkout under /tmp, a home under /cache — would otherwise be discarded
# whole, and the sweep would report clean because it looked at nothing.
is_excluded_path() {
  local p="${1#"${2:-}"/}"
  local part excluded
  local IFS='/'
  for part in $p; do
    for excluded in "${EXCLUDED_COMPONENTS[@]}"; do
      [[ "$part" == "$excluded" ]] && return 0
    done
  done
  return 1
}

# A hit whose text physically lives inside a legacy checkout is that checkout referring to
# itself, not an external reference the cutover has to rewrite. Reached only because the
# walk follows symlinks into the checkout (e.g. an installed systemd unit link).
is_internal_to_old_checkout() {
  local real base
  real=$(readlink -f "$1" 2>/dev/null) || return 1
  for base in "${OLD_BASES[@]}"; do
    [[ "$real" == "$(canonical_old_path "$base")"/* ]] && return 0
  done
  return 1
}

require_readable_dir() {
  local dir="$1"
  if [[ -d "$dir" && ! -r "$dir" ]]; then
    echo "ref-sweep: unreadable directory: $dir" >&2
    exit 1
  fi
}

# Mirrors EXCLUDED_COMPONENTS so whole subtrees are pruned during the walk rather than
# after; is_excluded_path still re-checks every hit, so this only affects speed.
GREP_PRUNE=()
for _component in "${EXCLUDED_COMPONENTS[@]}"; do
  GREP_PRUNE+=("--exclude-dir=$_component")
done
unset _component

GREP_PATTERNS=()
for _base in "${OLD_BASES[@]}"; do
  GREP_PATTERNS+=(-e "~/Projects/${_base}" -e "\$HOME/Projects/${_base}" -e "$(canonical_old_path "$_base")")
done
unset _base

# One walk matches every base at once; the per-base attribution below re-tests only the
# handful of lines grep returned, so a line naming two bases still yields two records.
scan_path() {
  local root="$1"
  local file rest line content base old_path
  while IFS= read -r -d '' file; do
    IFS= read -r rest || true
    line="${rest%%:*}"
    content="${rest#*:}"
    is_excluded_path "$file" "$root" && continue
    is_internal_to_old_checkout "$file" && continue
    for base in "${OLD_BASES[@]}"; do
      old_path=$(canonical_old_path "$base")
      if [[ "$content" == *"~/Projects/${base}"* ]] \
        || [[ "$content" == *"\$HOME/Projects/${base}"* ]] \
        || [[ "$content" == *"${old_path}"* ]]; then
        printf '%s\t%s\t%s\n' "$file" "$line" "$old_path"
      fi
    done
  done < <(grep -RIHnZ -F "${GREP_PRUNE[@]}" "${GREP_PATTERNS[@]}" -- "$root" 2>/dev/null)
  return 0
}

scan_file_content() {
  scan_path "$1"
}

scan_file() {
  local file="$1"
  [[ -f "$file" && -r "$file" ]] || return 0
  scan_path "$file"
}

scan_tree() {
  local root="$1"
  [[ -e "$root" ]] || return 0
  require_readable_dir "$root"
  scan_path "$root"
}

scan_claude() {
  local claude="$HOME/.claude"
  [[ -e "$claude" ]] || return 0
  require_readable_dir "$claude"

  local sub
  for sub in skills hooks lib bin workflows commands; do
    scan_tree "$claude/$sub"
  done

  local settings
  shopt -s nullglob
  for settings in "$claude"/settings*; do
    scan_file "$settings"
  done
  shopt -u nullglob
}

scan_projects_claude() {
  local projects="$HOME/Projects"
  [[ -d "$projects" ]] || return 0
  require_readable_dir "$projects"

  local project
  shopt -s nullglob
  for project in "$projects"/*; do
    [[ -d "$project" ]] || continue
    scan_tree "$project/.claude"
  done
  shopt -u nullglob
}

scan_shell_rc() {
  local rc
  for rc in \
    "$HOME/.bashrc" \
    "$HOME/.bash_profile" \
    "$HOME/.profile" \
    "$HOME/.zshrc" \
    "$HOME/.zprofile" \
    "$HOME/.zshenv" \
    "$HOME/.config/fish/config.fish"; do
    scan_file "$rc"
  done
}

scan_crontab() {
  local tmp
  tmp=$(mktemp "${TMPDIR:-/tmp}/ref-sweep-cron.XXXXXX")
  if crontab -l 2>/dev/null >"$tmp"; then
    scan_file_content "$tmp"
  fi
  rm -f "$tmp"
}

run_sweep() {
  local hits
  hits=$(
    scan_claude
    scan_tree "$HOME/.codex"
    scan_tree "$HOME/.cursor"
    scan_tree "$HOME/.config/opencode"
    scan_tree "$HOME/.gemini"
    scan_tree "$HOME/.kiro"
    scan_tree "$HOME/.pi"
    scan_tree "$HOME/.antigravity"
    scan_shell_rc
    scan_tree "$HOME/.config/systemd/user"
    scan_crontab
    scan_projects_claude
  )

  if [[ -z "$hits" ]]; then
    printf '[]\n'
    return 0
  fi

  printf '%s\n' "$hits" | LC_ALL=C sort -t $'\t' -k1,1 -k2,2n -k3,3 | awk -F '\t' '
    BEGIN { print "[" }
    {
      gsub(/\\/, "\\\\", $1)
      gsub(/"/, "\\\"", $1)
      gsub(/\\/, "\\\\", $3)
      gsub(/"/, "\\\"", $3)
      if (NR > 1) printf ",\n"
      printf "  {\"file\": \"%s\", \"line\": %s, \"old_path\": \"%s\"}", $1, $2, $3
    }
    END { print "\n]" }
  '
}

assert_inventory() {
  local inv="$1"
  if [[ ! -f "$inv" ]]; then
    echo "ref-sweep: inventory missing: $inv" >&2
    exit 1
  fi

  local dangling=()
  local entry file line old_path
  while IFS= read -r entry; do
    [[ -n "$entry" ]] || continue
    file=$(jq -r '.file' <<<"$entry")
    line=$(jq -r '.line' <<<"$entry")
    old_path=$(jq -r '.old_path' <<<"$entry")
    if [[ ! -e "$old_path" ]] || { [[ ! -L "$old_path" ]] && [[ ! -d "$old_path" ]]; }; then
      dangling+=("${file}:${line}:${old_path}")
    fi
  done < <(jq -c '.[]' "$inv")

  if ((${#dangling[@]} > 0)); then
    echo "ref-sweep: dangling old_path references:" >&2
    printf '  %s\n' "${dangling[@]}" >&2
    exit 1
  fi
}

if [[ "$ASSERT" -eq 1 ]]; then
  assert_inventory "$INVENTORY"
  exit 0
fi

json=$(run_sweep)
printf '%s\n' "$json"
printf '%s\n' "$json" >"$INVENTORY"
