#!/usr/bin/env bash
# asandbox — run any CLI coding agent inside Docker with host FS integration
# Works on macOS (Docker Desktop / OrbStack) and Linux.
set -euo pipefail

VERSION="1.1.0"
# Program root = folder where this script lives (e.g. /Users/macbook/asandbox)
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE_NAME="${ASANDBOX_IMAGE:-asandbox:latest}"
SHARE_DIR="${ASANDBOX_SHARE:-${ROOT_DIR}}"
CONFIG_DIR="${ASANDBOX_CONFIG_DIR:-${ROOT_DIR}/config}"
CONFIG_FILE="${CONFIG_DIR}/config.env"
DEFAULT_NETWORK="${ASANDBOX_NETWORK:-bridge}"
DEFAULT_MEMORY="${ASANDBOX_MEMORY:-4g}"
DEFAULT_CPUS="${ASANDBOX_CPUS:-4}"

# ── colors ──────────────────────────────────────────────────────────
if [[ -t 2 ]]; then
  C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YLW=$'\033[33m'
  C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'; C_BLD=$'\033[1m'
else
  C_RED=; C_GRN=; C_YLW=; C_BLU=; C_DIM=; C_RST=; C_BLD=
fi

die()  { echo "${C_RED}asandbox:${C_RST} $*" >&2; exit 1; }
info() { echo "${C_BLU}asandbox:${C_RST} $*" >&2; }
ok()   { echo "${C_GRN}asandbox:${C_RST} $*" >&2; }
warn() { echo "${C_YLW}asandbox:${C_RST} $*" >&2; }

# ── usage ───────────────────────────────────────────────────────────
usage() {
  cat <<'EOF'
asandbox — run CLI AI agents in Docker with host filesystem integration

USAGE
  asandbox <command> [options] [--] [agent-args...]

COMMANDS
  run <agent|cmd...>   Run an agent or arbitrary command in the sandbox
  shell                Interactive bash inside the sandbox
  installagent <name>  Install an agent inside the sandbox (claude, cursor, …)
  build                Build (or rebuild) the sandbox Docker image
  agents               List known agent presets / installagent catalog
  config               Show / create config
  doctor               Check Docker, image, mounts
  version              Print version
  help                 Show this help

OPTIONS (run / shell)
  -w, --workdir DIR    Host directory to mount (default: git root or cwd)
  -n, --name NAME      Container name (default: auto)
  --network MODE       none | bridge | host  (default: bridge)
  --memory SIZE        Memory limit (default: 4g)
  --cpus N             CPU limit (default: 4)
  --read-only          Read-only root filesystem (+tmpfs /tmp)
  --no-mount-home      Do not mount agent config dirs from host home
  --mount-ssh          Mount ~/.ssh (read-only) for git over SSH
  --mount-gitconfig    Mount ~/.gitconfig (read-only)
  --mount PATH[:CONT]  Extra bind mount (repeatable). CONT defaults to PATH
  --env KEY=VAL        Pass env var (repeatable)
  --env-file FILE      Pass env file
  --pass-env KEY       Pass host env KEY if set (repeatable)
  --image NAME         Override image (default: asandbox:latest)
  --rm / --keep        Remove container on exit (default) / keep it
  --detach, -d         Run in background
  -q, --quiet          Less noise
  --dry-run            Print docker command and exit

AGENT PRESETS / installagent
  claude, codex, gemini, antigravity (agy), cursor (agent),
  opencode, aider, qwen, amp, goose, continue, bash
  Inside shell:  installagent list | installagent claude
  Or any command:  asandbox run -- node script.js

EXAMPLES
  asandbox build
  asandbox shell
  asandbox installagent list
  asandbox installagent claude
  asandbox installagent cursor
  asandbox installagent antigravity
  asandbox installagent opencode
  asandbox run claude
  asandbox run claude "explain this project"
  asandbox run -w ~/proj codex
  asandbox run --network none --read-only -- rg "TODO"
  asandbox run --mount-ssh -- git pull
  asandbox run --env ANTHROPIC_API_KEY=sk-... claude

SECURITY
  • No Docker socket is mounted (agents cannot escape via Docker).
  • Runs as non-root user inside the container.
  • Capabilities dropped; no-new-privileges enabled.
  • Host project directory is bind-mounted (same absolute path on Mac).
  • Agent configs (~/.claude, ~/.codex, …) mounted so auth/state persist.
  • Use --network none for fully offline runs when possible.

CONFIG
  Program folder:  same directory as this script (default ~/asandbox)
  config/config.env   optional defaults
  Dockerfile, installagent, persist/  live next to the script

EOF
}

# ── config ──────────────────────────────────────────────────────────
load_config() {
  if [[ -f "$CONFIG_FILE" ]]; then
    # shellcheck disable=SC1090
    set -a; source "$CONFIG_FILE"; set +a
  fi
}

write_default_config() {
  mkdir -p "$CONFIG_DIR"
  if [[ -f "$CONFIG_FILE" ]]; then
    info "config already exists: $CONFIG_FILE"
    return 0
  fi
  cat > "$CONFIG_FILE" <<'EOF'
# asandbox defaults (bash-compatible env file)
# ASANDBOX_IMAGE=asandbox:latest
# ASANDBOX_NETWORK=bridge
# ASANDBOX_MEMORY=4g
# ASANDBOX_CPUS=4
# ASANDBOX_MOUNT_SSH=0
# ASANDBOX_MOUNT_GITCONFIG=1
# ASANDBOX_QUIET=0

# Extra env keys always forwarded when set on host:
# ASANDBOX_PASS_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GOOGLE_API_KEY XAI_API_KEY"
EOF
  ok "wrote $CONFIG_FILE"
}

# ── helpers ─────────────────────────────────────────────────────────
require_docker() {
  command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker Desktop or OrbStack."
  docker info >/dev/null 2>&1 || die "Docker daemon not running. Start Docker Desktop / OrbStack."
}

image_exists() {
  docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
}

resolve_workdir() {
  local dir="${1:-}"
  if [[ -n "$dir" ]]; then
    (cd "$dir" && pwd -P)
    return
  fi
  # Prefer git root when inside a repo
  if git rev-parse --show-toplevel >/dev/null 2>&1; then
    git rev-parse --show-toplevel
    return
  fi
  pwd -P
}

# Map agent name → argv inside container
resolve_agent_cmd() {
  local agent="$1"
  shift || true
  case "$agent" in
    claude|claude-code)
      echo "claude"; printf '%s\n' "$@"
      ;;
    codex|openai-codex)
      echo "codex"; printf '%s\n' "$@"
      ;;
    gemini|gemini-cli)
      echo "gemini"; printf '%s\n' "$@"
      ;;
    antigravity|agy|antigravity-cli)
      echo "agy"; printf '%s\n' "$@"
      ;;
    cursor|cursor-cli|cursor-agent)
      echo "agent"; printf '%s\n' "$@"
      ;;
    opencode|open-code)
      echo "opencode"; printf '%s\n' "$@"
      ;;
    aider|aider-chat)
      echo "aider"; printf '%s\n' "$@"
      ;;
    qwen|qwen-code)
      echo "qwen"; printf '%s\n' "$@"
      ;;
    amp)
      echo "amp"; printf '%s\n' "$@"
      ;;
    goose)
      echo "goose"; printf '%s\n' "$@"
      ;;
    continue|cn)
      echo "cn"; printf '%s\n' "$@"
      ;;
    installagent|install-agent)
      echo "installagent"; printf '%s\n' "$@"
      ;;
    bash|sh|shell)
      if [[ $# -eq 0 ]]; then
        echo "bash"; echo "-l"
      else
        echo "bash"; echo "-lc"; echo "$*"
      fi
      ;;
    *)
      # Treat first token as command
      echo "$agent"; printf '%s\n' "$@"
      ;;
  esac
}

# Known host config dirs to bind-mount (host → container path under $HOME)
agent_home_mounts() {
  # format: host_relpath
  cat <<'EOF'
.claude
.codex
.config/gh
.gemini
.grok
.cursor
.local/share/claude
.npm
.antigravity
.agy
.config/opencode
.config/aider
EOF
}

# Persistent install roots (survive --rm containers)
ensure_persist_dirs() {
  mkdir -p \
    "${SHARE_DIR}/persist/.local/bin" \
    "${SHARE_DIR}/persist/.npm-global/bin" \
    "${SHARE_DIR}/persist/.cache"
}

# Env keys commonly needed by agents
default_pass_env_keys() {
  echo "ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL GEMINI_API_KEY GOOGLE_API_KEY GOOGLE_GENAI_API_KEY XAI_API_KEY GROK_API_KEY CURSOR_API_KEY GITHUB_TOKEN GH_TOKEN TERM COLORTERM LANG LC_ALL EDITOR"
}

# ── build ───────────────────────────────────────────────────────────
cmd_build() {
  require_docker
  local no_cache=0 force=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --no-cache) no_cache=1; shift ;;
      --force|-f) force=1; shift ;;
      -h|--help) echo "Usage: asandbox build [--no-cache] [--force]"; return 0 ;;
      *) die "unknown build option: $1" ;;
    esac
  done

  [[ -f "$SHARE_DIR/Dockerfile" ]] || die "Dockerfile not found at $SHARE_DIR/Dockerfile"

  local args=(build -t "$IMAGE_NAME" -f "$SHARE_DIR/Dockerfile" "$SHARE_DIR")
  if [[ $no_cache -eq 1 ]]; then
    args+=(--no-cache)
  fi

  # Pass host UID/GID so files created in mounts match macOS user
  args+=(--build-arg "USER_UID=$(id -u)" --build-arg "USER_GID=$(id -g)")

  info "building image ${C_BLD}${IMAGE_NAME}${C_RST} from $SHARE_DIR"
  docker "${args[@]}"
  ok "image ready: $IMAGE_NAME"
}

# ── doctor ──────────────────────────────────────────────────────────
cmd_doctor() {
  echo "${C_BLD}asandbox doctor${C_RST} (v$VERSION)"
  echo

  if command -v docker >/dev/null 2>&1; then
    ok "docker CLI: $(command -v docker)"
  else
    die "docker CLI missing"
  fi

  if docker info >/dev/null 2>&1; then
    local ctx
    ctx=$(docker context show 2>/dev/null || echo unknown)
    ok "docker daemon: running (context: $ctx)"
  else
    die "docker daemon not running"
  fi

  if image_exists; then
    ok "image: $IMAGE_NAME present"
  else
    warn "image: $IMAGE_NAME missing — run: asandbox build"
  fi

  echo
  echo "paths:"
  echo "  share:  $SHARE_DIR"
  echo "  config: $CONFIG_FILE"
  echo "  home:   $HOME"
  echo "  cwd:    $(pwd -P)"
  if git rev-parse --show-toplevel >/dev/null 2>&1; then
    echo "  git:    $(git rev-parse --show-toplevel)"
  fi
  echo
  echo "platform: $(uname -s)/$(uname -m)"
  echo "uid/gid:  $(id -u):$(id -g)"

  if [[ -f "$SHARE_DIR/Dockerfile" ]]; then
    ok "Dockerfile found"
  else
    warn "Dockerfile missing at $SHARE_DIR"
  fi
}

# ── agents list ─────────────────────────────────────────────────────
cmd_agents() {
  cat <<'EOF'
Agent presets (run via asandbox <name> or asandbox run <name>):

  claude         Anthropic Claude Code
  codex          OpenAI Codex CLI
  gemini         Google Gemini CLI
  antigravity    Google Antigravity CLI  (binary: agy)
  cursor         Cursor Agent CLI        (binary: agent)
  opencode       OpenCode
  aider          Aider
  qwen           Qwen Code
  amp            Sourcegraph Amp
  goose          Block Goose
  continue       Continue CLI            (binary: cn)
  bash           Interactive shell

Install inside sandbox (persisted under <program>/persist):

  asandbox installagent list
  asandbox installagent claude
  asandbox installagent cursor
  asandbox installagent antigravity
  asandbox installagent opencode
  asandbox installagent all

  # or inside shell:
  asandbox shell
  $ installagent list
  $ installagent gemini

Host config dirs auto-mounted (if present):
  ~/.claude  ~/.codex  ~/.gemini  ~/.grok  ~/.cursor  ~/.config/gh
  ~/.antigravity  ~/.agy  ~/.config/opencode
EOF
}

# ── installagent (host wrapper → container) ─────────────────────────
cmd_installagent() {
  if [[ $# -eq 0 ]]; then
    set -- list
  fi
  # Run installagent inside sandbox with network (needed to download)
  cmd_run -q --network bridge -- installagent "$@"
}

# ── run / shell ─────────────────────────────────────────────────────
cmd_run() {
  require_docker

  local workdir="" container_name="" network="$DEFAULT_NETWORK"
  local memory="$DEFAULT_MEMORY" cpus="$DEFAULT_CPUS"
  local read_only=0 mount_home=1 mount_ssh=0 mount_gitconfig=1
  local rm_flag=1 detach=0 quiet=0 dry_run=0
  local -a extra_mounts=() env_args=() pass_envs=() env_files=()
  local -a raw_cmd=()

  # defaults from env/config
  [[ "${ASANDBOX_MOUNT_SSH:-0}" == "1" ]] && mount_ssh=1
  [[ "${ASANDBOX_MOUNT_GITCONFIG:-1}" == "0" ]] && mount_gitconfig=0
  [[ "${ASANDBOX_QUIET:-0}" == "1" ]] && quiet=1

  # Parse ASANDBOX_PASS_ENV
  if [[ -n "${ASANDBOX_PASS_ENV:-}" ]]; then
    # shellcheck disable=SC2206
    pass_envs+=($ASANDBOX_PASS_ENV)
  fi
  # shellcheck disable=SC2206
  pass_envs+=($(default_pass_env_keys))

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -w|--workdir) workdir="${2:-}"; shift 2 ;;
      -n|--name) container_name="${2:-}"; shift 2 ;;
      --network) network="${2:-}"; shift 2 ;;
      --memory) memory="${2:-}"; shift 2 ;;
      --cpus) cpus="${2:-}"; shift 2 ;;
      --read-only) read_only=1; shift ;;
      --no-mount-home) mount_home=0; shift ;;
      --mount-ssh) mount_ssh=1; shift ;;
      --mount-gitconfig) mount_gitconfig=1; shift ;;
      --no-mount-gitconfig) mount_gitconfig=0; shift ;;
      --mount)
        extra_mounts+=("${2:-}")
        shift 2
        ;;
      --env|-e)
        env_args+=(--env "${2:-}")
        shift 2
        ;;
      --env-file)
        env_files+=(--env-file "${2:-}")
        shift 2
        ;;
      --pass-env)
        pass_envs+=("${2:-}")
        shift 2
        ;;
      --image) IMAGE_NAME="${2:-}"; shift 2 ;;
      --rm) rm_flag=1; shift ;;
      --keep) rm_flag=0; shift ;;
      -d|--detach) detach=1; shift ;;
      -q|--quiet) quiet=1; shift ;;
      --dry-run) dry_run=1; shift ;;
      -h|--help) usage; return 0 ;;
      --) shift; raw_cmd+=("$@"); break ;;
      -*)
        die "unknown option: $1 (try --help)"
        ;;
      *)
        raw_cmd+=("$@")
        break
        ;;
    esac
  done

  if ! image_exists; then
    warn "image $IMAGE_NAME not found — building now..."
    cmd_build
  fi

  local host_wd
  host_wd=$(resolve_workdir "$workdir")
  [[ -d "$host_wd" ]] || die "workdir does not exist: $host_wd"

  # Resolve command
  local -a cmd=()
  if [[ ${#raw_cmd[@]} -eq 0 ]]; then
    cmd=(bash)
  else
    # read resolve_agent_cmd output into array
    local line
    while IFS= read -r line; do
      cmd+=("$line")
    done < <(resolve_agent_cmd "${raw_cmd[@]}")
  fi

  # Unique name
  if [[ -z "$container_name" ]]; then
    local base slug
    base=$(basename "$host_wd" | tr -c 'a-zA-Z0-9._-' '-' | tr '[:upper:]' '[:lower:]')
    slug=$(echo "${cmd[0]}" | tr -c 'a-zA-Z0-9' '-')
    container_name="asandbox-${base}-${slug}-$$"
    # docker name length limit
    container_name="${container_name:0:60}"
  fi

  local -a docker_args=(
    run
    --name "$container_name"
    --hostname "asandbox"
    --network "$network"
    --memory "$memory"
    --cpus "$cpus"
    --security-opt no-new-privileges:true
    --cap-drop ALL
    --cap-add CHOWN
    --cap-add SETUID
    --cap-add SETGID
    --cap-add DAC_OVERRIDE
    --cap-add FOWNER
    --cap-add NET_BIND_SERVICE
    # Interactive when stdin is a TTY and not detached
  )

  if [[ $rm_flag -eq 1 ]]; then
    docker_args+=(--rm)
  fi
  if [[ $detach -eq 1 ]]; then
    docker_args+=(-d)
  else
    docker_args+=(-i)
    if [[ -t 0 && -t 1 ]]; then
      docker_args+=(-t)
    fi
  fi
  if [[ $read_only -eq 1 ]]; then
    docker_args+=(--read-only --tmpfs /tmp:rw,exec,nosuid,size=1g --tmpfs /home/agent:rw,exec,nosuid,size=512m)
  fi

  # ── filesystem integration ──────────────────────────────────────
  # 1) Mount workspace at the SAME absolute path (critical for Mac path fidelity)
  docker_args+=(-v "${host_wd}:${host_wd}:rw")
  # 2) Also expose as /workspace for convenience
  docker_args+=(-v "${host_wd}:/workspace:rw")
  docker_args+=(-w "$host_wd")
  docker_args+=(-e "ASANDBOX_WORKDIR=$host_wd")
  docker_args+=(-e "ASANDBOX_HOST_OS=$(uname -s)")
  docker_args+=(-e "ASANDBOX_QUIET=$quiet")

  # 2b) Persist installagent packages across container runs
  ensure_persist_dirs
  docker_args+=(-v "${SHARE_DIR}/persist/.local:/home/agent/.local:rw")
  docker_args+=(-v "${SHARE_DIR}/persist/.npm-global:/home/agent/.npm-global:rw")
  docker_args+=(-v "${SHARE_DIR}/persist/.cache:/home/agent/.cache:rw")

  # 3) Agent config / state from host home
  if [[ $mount_home -eq 1 ]]; then
    local rel host_path cont_path
    while IFS= read -r rel; do
      [[ -z "$rel" ]] && continue
      host_path="${HOME}/${rel}"
      cont_path="/home/agent/${rel}"
      if [[ -e "$host_path" ]]; then
        # ensure parent exists in container via mount of the path itself
        docker_args+=(-v "${host_path}:${cont_path}:rw")
      fi
    done < <(agent_home_mounts)
  fi

  # 4) SSH keys (optional, read-only)
  if [[ $mount_ssh -eq 1 && -d "${HOME}/.ssh" ]]; then
    docker_args+=(-v "${HOME}/.ssh:/home/agent/.ssh:ro")
  fi

  # 5) gitconfig
  if [[ $mount_gitconfig -eq 1 && -f "${HOME}/.gitconfig" ]]; then
    docker_args+=(-v "${HOME}/.gitconfig:/home/agent/.gitconfig:ro")
  fi

  # 6) Extra mounts
  local m host_m cont_m
  for m in "${extra_mounts[@]+"${extra_mounts[@]}"}"; do
    if [[ "$m" == *:* ]]; then
      host_m="${m%%:*}"
      cont_m="${m#*:}"
    else
      host_m="$m"
      cont_m="$m"
    fi
    # resolve host side if relative
    if [[ "$host_m" != /* ]]; then
      host_m="$(cd "$(dirname "$host_m")" && pwd -P)/$(basename "$host_m")"
    fi
    docker_args+=(-v "${host_m}:${cont_m}")
  done

  # 7) Pass through env (bash 3.2-compatible dedupe)
  local key seen_env=" "
  for key in "${pass_envs[@]+"${pass_envs[@]}"}"; do
    [[ -z "$key" ]] && continue
    case "$seen_env" in
      *" ${key} "*) continue ;;
    esac
    seen_env+="${key} "
    # indirect expansion works in bash 3.2
    if [[ -n "${!key:-}" ]]; then
      docker_args+=(-e "${key}=${!key}")
    fi
  done

  # Git author from host
  local gname gemail
  gname=$(git config --global user.name 2>/dev/null || true)
  gemail=$(git config --global user.email 2>/dev/null || true)
  [[ -n "$gname" ]] && docker_args+=(-e "GIT_AUTHOR_NAME=$gname" -e "GIT_COMMITTER_NAME=$gname")
  [[ -n "$gemail" ]] && docker_args+=(-e "GIT_AUTHOR_EMAIL=$gemail" -e "GIT_COMMITTER_EMAIL=$gemail")

  docker_args+=("${env_args[@]+"${env_args[@]}"}")
  docker_args+=("${env_files[@]+"${env_files[@]}"}")

  # Label for cleanup
  docker_args+=(--label "asandbox=1" --label "asandbox.workdir=${host_wd}")

  docker_args+=("$IMAGE_NAME")
  docker_args+=("${cmd[@]}")

  if [[ $quiet -eq 0 ]]; then
    info "workdir  ${C_DIM}${host_wd}${C_RST}"
    info "network  ${C_DIM}${network}${C_RST}  memory=${memory} cpus=${cpus}"
    info "command  ${C_DIM}${cmd[*]}${C_RST}"
  fi

  if [[ $dry_run -eq 1 ]]; then
    echo "docker $(printf '%q ' "${docker_args[@]}")"
    return 0
  fi

  # Never mount docker.sock — explicit safety check
  local a
  for a in "${docker_args[@]}"; do
    if [[ "$a" == *docker.sock* ]]; then
      die "refusing to mount docker.sock"
    fi
  done

  exec docker "${docker_args[@]}"
}

cmd_shell() {
  cmd_run "$@" -- bash
}

cmd_config() {
  case "${1:-show}" in
    show|cat)
      if [[ -f "$CONFIG_FILE" ]]; then
        echo "# $CONFIG_FILE"
        cat "$CONFIG_FILE"
      else
        warn "no config yet — run: asandbox config init"
      fi
      ;;
    init|create)
      write_default_config
      ;;
    path)
      echo "$CONFIG_FILE"
      ;;
    *)
      die "usage: asandbox config [show|init|path]"
      ;;
  esac
}

# ── main ────────────────────────────────────────────────────────────
main() {
  load_config

  local cmd="${1:-}"
  if [[ -z "$cmd" ]]; then
    usage
    exit 0
  fi
  shift || true

  case "$cmd" in
    run)     cmd_run "$@" ;;
    shell|sh|bash) cmd_shell "$@" ;;
    installagent|install-agent|ia)
      cmd_installagent "$@"
      ;;
    build)   cmd_build "$@" ;;
    agents|list) cmd_agents "$@" ;;
    config)  cmd_config "$@" ;;
    doctor)  cmd_doctor "$@" ;;
    version|--version|-V) echo "asandbox $VERSION" ;;
    help|-h|--help) usage ;;
    # Convenience: asandbox claude ... → asandbox run claude ...
    claude|claude-code|codex|gemini|gemini-cli|aider|antigravity|agy|cursor|opencode|qwen|amp|goose)
      cmd_run "$cmd" "$@"
      ;;
    *)
      # Unknown subcommand: treat as agent/command name
      cmd_run "$cmd" "$@"
      ;;
  esac
}

main "$@"
