#!/usr/bin/env bash
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
# The help text lives in usage()/help_cmd(), not in this comment.
set -euo pipefail

root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0

die() { echo "claudebox: $*" >&2; exit 1; }         # 1 = it went wrong
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; }  # 2 = you asked wrong
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }

COMMANDS="new list info shell exec snapshot restore down start rm status help"
is_command() { case " $COMMANDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }

synopsis_of() {
  case "$1" in
    new)      echo "claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container]" ;;
    list)     echo "claudebox list [--json]" ;;
    info)     echo "claudebox info <box> [--json]" ;;
    shell)    echo "claudebox shell <box>" ;;
    exec)     echo "claudebox exec <box> -- <cmd...>" ;;
    snapshot) echo "claudebox snapshot <box> [<label>]" ;;
    restore)  echo "claudebox restore <box> <snapshot>" ;;
    down)     echo "claudebox down <box>" ;;
    start)    echo "claudebox start <box>" ;;
    rm)       echo "claudebox rm <box> [--force]" ;;
    status)   echo "claudebox status" ;;
    help)     echo "claudebox help [<command>]" ;;
  esac
}

# Nearest command by edit distance — a typo should point somewhere, not just fail.
suggest() {
  printf '%s' "$COMMANDS" | tr ' ' '\n' | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
    function dist(a, b,   la, lb, i, j, c, prev, cur) {
      la = length(a); lb = length(b)
      for (j = 0; j <= lb; j++) prev[j] = j
      for (i = 1; i <= la; i++) {
        cur[0] = i
        for (j = 1; j <= lb; j++) {
          c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
          cur[j] = prev[j] + 1
          if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
          if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
        }
        for (j = 0; j <= lb; j++) prev[j] = cur[j]
      }
      return prev[lb]
    }
    BEGIN { best = 99 }
    { d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
    END { if (best <= 2) print hit }'
}

unknown_command() {
  local hint; hint="$(suggest "$1")"
  if [ -n "$hint" ]; then
    echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2
  else
    echo "claudebox: unknown command: $1" >&2
  fi
  echo "try 'claudebox help' for the command list." >&2
  exit 2
}

usage() {
  cat <<'EOF'
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free.

USAGE
  claudebox <command> [<args>] [options]

COMMANDS
  new        Mint a box: fresh from cloud-init, or --from an existing box/snapshot
  list       List your boxes
  info       One box: state, type, IP, and its snapshot labels
  shell      Open a shell in a box, as the claude user
  exec       Run a command inside a box
  snapshot   Checkpoint a box (label defaults to manual-<epoch>)
  restore    Roll a box back to one of its snapshots
  down       Stop a box, keeping its state ('start' resumes it)
  start      Start a stopped box
  rm         Delete a box and its snapshots — irreversible, and it asks first
  status     Deprecated alias for 'list'
  help       This help, or 'claudebox help <command>' for one command

OPTIONS
  --name <box>          Name for the new box                          (new)
  --from <src>[/<snap>] Clone from box <src>, or from its snapshot    (new)
  --vm                  Force VM mode: the trust-less target          (new)
  --container           Force container mode: weaker isolation,       (new)
                          dev/test only. Default where /dev/kvm is absent.
  --json                Emit Incus JSON instead of a table     (list, info)
  --force, -f           Delete without the confirmation prompt        (rm)
  --remote <r>          Act on Incus remote <r>                       (any)
  --help, -h            Help; after a command, help for that command
  --version, -V         Print the claudebox version

  Options come after the command: 'claudebox list --json', not 'claudebox --json list'.

EXAMPLES
  # mint a box and log in inside it — the tool never handles your token
  claudebox new --name work
  claudebox shell work                    # then: run 'claude', then /login

  # log in once, reuse forever: checkpoint the authed box, clone from it
  claudebox snapshot work authed
  claudebox new --name feature --from work/authed

  # what have I got, and what can I clone?
  claudebox list
  claudebox info work

  # run something without opening a shell
  claudebox exec work -- git -C project pull

EXIT STATUS
  0  ok
  1  it went wrong (Incus failed, no such box, aborted at a prompt)
  2  you asked wrong (unknown command, bad flag, destructive act without --force)

THE MODEL
  A box carries NO credentials. You authenticate interactively inside it
  ('claude' then /login; 'gh auth login'); claudebox never stores or injects a
  secret. A box reaches the public internet and nothing else — there is no
  inbound path. Destroying a box loses nothing you didn't push.

  Docs: https://github.com/heavy-duty/claudebox
EOF
}

help_cmd() {
  echo "usage: $(synopsis_of "$1")"
  echo
  case "$1" in
    new) cat <<'EOF'
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
(~10 min cold) with Claude Code installed and NO credentials. With --from,
clones an existing box or one of its snapshots — Claude login, git creds and
clones carry over, isolation is preserved.

  --name <box>           Required. The box's name.
  --from <src>[/<snap>]  Clone src's live state, or its snapshot <snap>.
  --vm | --container     Force the mode. VM is the trust boundary and the
                         default wherever /dev/kvm exists; container mode
                         (security.nesting=true) is the fallback for hosts
                         without nested virt — weaker isolation, dev/test only.

  claudebox new --name work
  claudebox new --name feature --from work/authed
EOF
;;
    list) cat <<'EOF'
List the boxes claudebox minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'claudebox info <box>'.

  --json   Incus's JSON, straight through, for scripting.

  claudebox list
EOF
;;
    info) cat <<'EOF'
Show one box: state, type, IP address, and — the reason this exists — the
labels of its snapshots, with the --from line to clone one.

  --json   Incus's JSON, straight through, for scripting.

  claudebox info work
EOF
;;
    shell) cat <<'EOF'
Open an interactive shell in a running box as the 'claude' user. This is the
only entry path — there is no SSH and no inbound route to a box.

  claudebox shell work
EOF
;;
    exec) cat <<'EOF'
Run a command inside a box as the 'claude' user. Everything after -- is passed
through untouched; the -- is required, or claudebox will read your command's
flags as its own.

  claudebox exec work -- git -C project pull
  claudebox exec work -- claude --version
EOF
;;
    snapshot) cat <<'EOF'
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
label defaults to manual-<epoch>; 'claudebox info <box>' shows the labels you
have.

  claudebox snapshot work authed
EOF
;;
    restore) cat <<'EOF'
Roll a box back to one of its snapshots, in place. Anything in the box since
that snapshot is lost. 'claudebox info <box>' lists the labels.

  claudebox restore work authed
EOF
;;
    down) cat <<'EOF'
Stop a box. Its disk and state are kept — 'claudebox start' resumes it, Claude
login and all.

  claudebox down work
EOF
;;
    start) cat <<'EOF'
Start a stopped box.

  claudebox start work
EOF
;;
    rm) cat <<'EOF'
Delete a box and every snapshot it has. This cannot be undone, so it asks for
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
(a script, a pipe), it refuses unless --force is given.

  claudebox rm work
  claudebox rm work --force
EOF
;;
    status) cat <<'EOF'
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
still works, and forwards to 'list'.
EOF
;;
    help) cat <<'EOF'
Print the general help, or the help for one command.

  claudebox help
  claudebox help new
EOF
;;
  esac
}

show_help() {   # "" → general help
  case "${1:-}" in
    ""|help) usage ;;
    *) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
  esac
}

cmd="${1:-help}"; shift || true
case "$cmd" in
  -h|--help)    usage; exit 0 ;;
  -V|--version) version; exit 0 ;;
  -*)           usage_error "options come after the command — try 'claudebox <command> $cmd ...'" ;;
esac

args=()
while [ $# -gt 0 ]; do
  case "$1" in
    --name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
    --from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
    --remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
    --vm) mode=vm; shift ;;
    --container) mode=container; shift ;;
    --force|-f) force=1; shift ;;
    --json) json=1; shift ;;
    --help|-h) want_help=1; shift ;;
    --version|-V) version; exit 0 ;;
    --) shift; args+=("$@"); break ;;
    # An unrecognized flag used to be swallowed as a positional — so a typo'd
    # --labl silently became a snapshot's label. Say so instead.
    -*)
      if [ "$cmd" = exec ]; then
        usage_error "unknown option: $1 — a command's own flags go after --, as in 'claudebox exec <box> -- <cmd...>'"
      fi
      usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
    *) args+=("$1"); shift ;;
  esac
done

if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi

iname_of() { echo "$remote$1"; }   # instance name = box name; claudebox tags them with user.claudebox=1
need_name() {
  if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
    usage_error "usage: $(synopsis_of "$cmd")"
  fi
}

confirm() {   # $1 = prompt. --force, or a TTY to ask on, or we refuse.
  if [ "$force" -eq 1 ]; then return 0; fi
  [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
  local reply
  printf 'claudebox: %s? this cannot be undone. [y/N] ' "$1"
  read -r reply
  case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}

pick_mode() {
  if [ "$mode" != auto ]; then echo "$mode"; return; fi
  if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
    echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
    echo container
  fi
}

wait_agent() {
  local n="$1" i
  echo "claudebox: waiting for instance agent..."
  for i in $(seq 1 90); do
    if incus exec "$n" -- true >/dev/null 2>&1; then return; fi
    [ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)"
    sleep 2
  done
}

new() {
  [ -n "$name" ] || die "usage: claudebox new --name <box> [--from <src>[/<snap>]]"
  local instance; instance="$(iname_of "$name")"
  if [ -n "$from" ]; then
    local src="${from%%/*}" snap="" srcref
    case "$from" in */*) snap="${from#*/}" ;; esac
    srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
    incus copy "$srcref" "$instance"
    incus start "$instance"
    wait_agent "$instance"
    echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source."
  else
    local m extra=(); m="$(pick_mode)"
    # shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument
    if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi
    incus launch images:debian/13/cloud "$instance" --profile claude-dev \
      --config user.claudebox=1 \
      --config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \
      "${extra[@]}"
    wait_agent "$instance"
    echo "claudebox: waiting for phase-1 (cloud-init)..."
    incus exec "$instance" -- cloud-init status --wait
  fi
  echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login."
}

# Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the
# only thing that makes them ours, so every read below is filtered by it and we
# never report on (or touch) an instance claudebox didn't mint.
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
# several addresses and Incus quotes them across lines. It's fetched separately.)
boxes_csv() {
  incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
}

box_ipv4() {   # first address only; strips Incus's " (iface)" suffix. "-" if none.
  incus list "$(iname_of "$1")" --format csv --columns 4 2>/dev/null \
    | tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
    | grep . || echo "-"
}

# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
short_type() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    virtual-machine|virtualmachine) echo VM ;;
    container) echo CT ;;
    *) echo "$1" ;;
  esac
}

list_all() {
  local rows; rows="$(boxes_csv)"
  if [ -z "$rows" ]; then
    echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2
    return 0
  fi
  {
    echo "NAME,STATE,TYPE,SNAPSHOTS"
    while IFS=, read -r n s t snaps; do
      [ -n "$n" ] || continue
      echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
    done <<<"$rows"
  } | awk -F, '
    { for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
    END { for (r = 1; r <= n; r++) { line = ""
            for (i = 1; i <= 4; i++) line = line sprintf("%-*s  ", w[i], cell[r, i])
            sub(/ +$/, "", line); print line } }'
}

info() {
  local box="$1" row
  # Same tagged set as list_all: an untagged instance is not a box, it's someone
  # else's VM, and we say "no such box" rather than reaching into it.
  row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
  [ -n "$row" ] || die "no such box: $box (see 'claudebox list')"

  local state type snaps
  IFS=, read -r _ state type snaps <<<"$row"
  printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
    IPV4 "$(box_ipv4 "$box")"

  echo
  case "${snaps:-0}" in
    ''|0)
      echo "SNAPSHOTS  (none)"
      echo
      echo "Take one:   claudebox snapshot $box authed"
      return 0 ;;
  esac
  echo "SNAPSHOTS"
  local first=""
  while IFS=, read -r sname taken _; do
    [ -n "$sname" ] || continue
    [ -n "$first" ] || first="$sname"
    printf '  %-14s%s\n' "$sname" "$taken"
  done < <(incus snapshot list "$(iname_of "$box")" --format csv 2>/dev/null)
  echo
  echo "Clone one:  claudebox new --name <new> --from $box/${first:-<snapshot>}"
}

# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
# guess we can answer, not a surprise: point at the command that does want one.
list() {
  if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
    die "list takes no box — for one box, use: claudebox info ${args[0]}"
  fi
  if [ "$json" -eq 1 ]; then
    incus list ${remote:+"$remote"} "user.claudebox=1" --format json
  else
    list_all
  fi
}

case "$cmd" in
  new)      new ;;
  snapshot) need_name; label="${args[1]:-manual-$(date +%s)}"; incus snapshot create "$(iname_of "${args[0]}")" "$label"; echo "$label" ;;
  restore)  need_name; [ -n "${args[1]:-}" ] || die "usage: claudebox restore <box> <snapshot>"; incus restore "$(iname_of "${args[0]}")" "${args[1]}" ;;
  shell)    need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i ;;
  exec)     need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i "${args[@]:1}" ;;
  down)     need_name; incus stop "$(iname_of "${args[0]}")" ;;
  start)    need_name; incus start "$(iname_of "${args[0]}")" ;;
  # -f is how Incus deletes a *running* instance; it is not our confirmation.
  # The prompt is: 'rm' destroys the box and every snapshot on it.
  rm)       need_name; inst="$(iname_of "${args[0]}")"
            confirm "delete $inst and all its snapshots"
            incus delete -f "$inst"; echo "claudebox: removed $inst" ;;
  list)     list ;;
  info)     need_name
            if [ "$json" -eq 1 ]; then incus list "$(iname_of "${args[0]}")" --format json; else info "${args[0]}"; fi ;;
  status)   echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2; list_all ;;
  help)     show_help "${args[0]:-}" ;;
  *)        unknown_command "$cmd" ;;
esac
