#!/usr/bin/env bash
# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
#
# DevCage – macOS / Linux launcher
#
# Usage:
#   ./devcage.sh                       # bash shell inside sandbox
#   ./devcage.sh claude                # launch Claude Code
#   ./devcage.sh gemini                # launch Gemini CLI
#   ./devcage.sh codex                 # launch Codex CLI
#   ./devcage.sh aider                 # launch Aider
#   ./devcage.sh python3 script.py     # run arbitrary command
#   ./devcage.sh --host-agents-config rw # mount agent configs read-write (persist)
#   ./devcage.sh --gpu nvidia           # force NVIDIA GPU passthrough
#   ./devcage.sh --no-gpu              # disable GPU even if detected
#   ./devcage.sh --embed-docker        # enable Docker-in-Docker sidecar
#   ./devcage.sh --apt-packages "vim"  # pre-install apt packages at startup
#   ./devcage.sh --memory 16g          # override memory limit (default: auto)
#   ./devcage.sh --cpus 8              # override CPU limit (default: auto)
#   ./devcage.sh --script /path/to/init  # additional init script (sourced last)
#   ./devcage.sh --path ~/my-project    # use ~/my-project as workspace
#   ./devcage.sh -p 3000,5173          # forward ports to host (same port)
#   ./devcage.sh -p 5080:80            # map container port 80 to host port 5080
#   ./devcage.sh --bind 0.0.0.0        # bind forwarded ports to all interfaces
#   ./devcage.sh --no-cache-volumes    # disable persistent package caches
#   ./devcage.sh --yolo-config           # auto-approve project-level config (init.sh, crontab)
#   ./devcage.sh --env FOO=bar         # inject env var (repeatable)
#   ./devcage.sh --volume ~/data:/data # mount host dir into container
#   ./devcage.sh --verbose             # trace entrypoint execution
#   ./devcage.sh --skip-build          # skip Docker image build
#   ./devcage.sh --rebuild             # force full rebuild (no cache)
#   ./devcage.sh --clean               # tear down all containers & volumes
#   ./devcage.sh --help                # print this help and exit
#
# Can also be invoked without the execute bit:
#   bash /path/to/devcage.sh
#
# Security model:
#   - Only the current working directory is mounted read-write on the host.
#   - Agent config dirs are mounted READ-ONLY by default (copied into container).
#     Pass --host-agents-config rw to make them read-write so settings persist to host.
#   - Container runs as unprivileged user with ALL capabilities dropped.
#   - Docker-in-Docker uses rootless mode (no --privileged, user-namespaced).
#   - no-new-privileges prevents setuid/setgid escalation inside the sandbox.
#   - LAN access is blocked; only Docker-internal and Internet traffic allowed.
#   - The container user's UID is matched to yours automatically.
#
# No admin / root / sudo required — only Docker access.
#   macOS / Windows : Docker Desktop (any user that can launch it)
#   Linux           : user must be in the "docker" group
#
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEVCAGE_VERSION="$(tr -d '\r' < "${SCRIPT_DIR}/VERSION.txt" 2>/dev/null || echo "unknown")"
COMPOSE_FILE="${SCRIPT_DIR}/sandbox/docker-compose.yml"

# ── Detect container engine (docker preferred, podman fallback) ──
if command -v docker >/dev/null 2>&1; then
    CONTAINER_ENGINE="docker"
elif command -v podman >/dev/null 2>&1; then
    CONTAINER_ENGINE="podman"
else
    CONTAINER_ENGINE=""
fi

# ── Usage ────────────────────────────────────────────────────────
usage() {
    cat <<'EOF'
DevCage – macOS / Linux launcher

Usage: ./devcage.sh [OPTIONS] [COMMAND...]

Options:
  -v, --version   Print version and exit
  --help          Show this help message and exit
  -yc, --yolo-config   Auto-approve project-level config (init.sh, crontab)
  --gpu [TYPE]    Enable GPU passthrough (nvidia or amd). Auto-detected if type omitted
  --no-gpu        Disable GPU passthrough even if a GPU is detected
  -d, --embed-docker  Enable the Docker-in-Docker sidecar (off by default)
  -ncv, --no-cache-volumes  Disable persistent per-workspace package caches
  --cache-dirs LIST   Comma-separated directory names cached on Docker volumes for
                      native ext4 performance (default: node_modules). Auto-detects
                      nested locations via package.json. Set to "" to disable.
                      Example: --cache-dirs "node_modules,target"
  -p, --port, --ports  Forward container ports to host (comma-separated)
                       Supports standard Docker syntax:
                         3000              → same port on host (with --bind address)
                         5080:80           → host:container mapping
                         0.0.0.0:5080:80   → bind:host:container (overrides --bind)
                       Examples: 3000,5173 or 5080:80,8443:443
  --bind ADDR     Bind address for forwarded ports (default: 127.0.0.1)
                  Use 0.0.0.0 to expose ports on all interfaces (LAN accessible)
  -git, --git-auth   Inherit host git config (~/.gitconfig) and credentials
                     (~/.git-credentials, GH_TOKEN, GITHUB_TOKEN)
  -e, --env KEY=VALUE  Inject environment variable into container (repeatable)
  -V, --volume SOURCE:DEST[:MODE]
                  Mount a host path into the container (repeatable)
                  MODE is 'ro' (default) or 'rw' (read-write)
  --verbose       Trace entrypoint execution (set -x) for debugging
  -hac, --host-agents-config MODE
                  Agent config mount mode: ro (default), rw (persist to host), no (skip)
  --memory SIZE   Container memory limit (default: auto = engine maximum)
                  Accepts absolute (e.g. 8g), percentage (e.g. 50%), or auto
  --cpus N        Container CPU limit (default: auto = engine maximum)
                  Accepts absolute (e.g. 4), percentage (e.g. 50%), or auto
  -apt, --apt-packages  Comma/space-separated list of apt packages to install (as root)
  -brew, --brew-packages  Comma/space-separated list of Homebrew packages to install
  -snap, --snap-packages  Comma/space-separated list of snap packages to install (--devmode)
  -s, --script PATH      Additional init script (sourced last, after hierarchy scripts)
  -P, --profile, --profiles  Activate config profile(s)
                             Single: -P test  or  Multiple: --profiles test,prod
  --path DIR      Use DIR as the workspace directory instead of the current directory
  --no-reuse      Disable container reuse (create ephemeral container with --rm)
  --stop          Stop the workspace container without removing volumes
  --recreate      Stop and remove existing container, then create fresh
  --sessions      Show active exec sessions for the current workspace
  --idle-timeout N  Idle timeout in seconds before stopping reusable container (default: 300; 0 = stop immediately when last session exits)
  --skip-build    Skip Docker image build (use existing image)
  --rebuild       Force full image rebuild (ignore cache)
  --clean         Tear down all containers, volumes, and package caches and exit
  --clean-global  Remove ALL DevCage containers, images, volumes, and builder cache
  --skip-sandbox-warning  Suppress the Linux sandbox security warning
  -l, --launch NAME  Load a launch configuration from .devcage/launches/NAME.conf
  -L, --lang, --langs LANGS
                  Comma-separated languages to include (case-insensitive)
                  Valid: java, php, go, rust, cpp, python, javascript, dotnet
                  Aliases: c/c++ (cpp), .net/csharp/vb/f# (dotnet), py (python),
                           ts/typescript (javascript), golang (go)
                  Use "all" to explicitly enable all (overrides config)
                  Use "none" to disable all language toolsets
                  Omit to include all languages (default)
                  Note: python/javascript/cpp control dev tools only —
                        Python3, Node.js, and gcc are always available

AI tools (lazy-installed on first use):
  claude          Claude Code (Anthropic)
  gemini          Gemini CLI (Google)
  codex           Codex CLI (OpenAI)
  aider           Aider
  claude-yolo     Claude Code (auto-approve)
  gemini-yolo     Gemini CLI (auto-approve)
  codex-yolo      Codex CLI (auto-approve)
  aider-yolo      Aider (auto-approve)
  copilot         GitHub Copilot CLI (GitHub)
  goose           Goose
  copilot-yolo    GitHub Copilot CLI (auto-approve)
  goose-yolo      Goose (auto-approve)

Config hierarchy (each level overrides the previous):
  1. Global:  config/*             (next to this script)
  2. User:    ~/.devcage/config/*  (current user, all projects)
  3. Project: <workspace>/.devcage/config/*  (current project only)
  Each level can contain: switches.conf, versions.conf, init.sh, .env
  CLI flags override all config levels.

Profiles (named config variants layered on top of base config):
  Sibling directories named config.{profile} at each scope level.
  -P test or --profile test loads config.test/ at each scope.
  --profiles test,prod loads config.test/, config.prod/, config.test.cluster1/
  Multi-profile dirs (config.test.cluster1/) require ALL named profiles to be active.

Examples:
  ./devcage.sh                         # bash shell inside sandbox
  ./devcage.sh claude                  # launch Claude Code
  ./devcage.sh gemini                  # launch Gemini CLI
  ./devcage.sh codex                   # launch Codex CLI
  ./devcage.sh aider                   # launch Aider
  ./devcage.sh claude-yolo             # Claude Code (auto-approve)
  ./devcage.sh gemini-yolo             # Gemini CLI (auto-approve)
  ./devcage.sh claude -p "explain this"  # pass args to Claude Code
  ./devcage.sh python3 --version       # run arbitrary command
  ./devcage.sh -p 3000,5173            # forward dev server ports to host
  ./devcage.sh -p 5080:80              # map container port 80 to host port 5080
  ./devcage.sh -p 3000 --bind 0.0.0.0  # forward port, LAN accessible
  ./devcage.sh -p 0.0.0.0:5080:80      # full Docker syntax (overrides --bind)
  ./devcage.sh --gpu nvidia             # force NVIDIA GPU passthrough
  ./devcage.sh --no-gpu                 # disable GPU even if detected
  ./devcage.sh --memory 4g              # 4 GB memory limit (DinD off by default)
  ./devcage.sh --memory 50% --cpus 50% # half of host resources
  ./devcage.sh --env DATABASE_URL=postgres://localhost/db  # inject env var
  ./devcage.sh --verbose              # trace entrypoint for debugging
  ./devcage.sh --git-auth               # inherit host git config & credentials
  ./devcage.sh --volume ~/datasets:/data      # mount host dir read-only
  ./devcage.sh -V ~/models:/models:rw         # mount host dir read-write
  ./devcage.sh --host-agents-config rw  # persist config changes to host
  ./devcage.sh -hac no                 # skip mounting host agent configs
  ./devcage.sh --embed-docker          # enable Docker-in-Docker sidecar
  ./devcage.sh --apt-packages "vim htop strace"  # pre-install OS packages (root)
  ./devcage.sh --brew-packages "gh lazygit"      # pre-install Homebrew packages
  ./devcage.sh --snap-packages "lxd node"        # pre-install snap packages (--devmode)
  ./devcage.sh --script /tmp/my-init.sh  # additional init script (sourced last)
  ./devcage.sh --skip-build            # skip build, use cached image
  ./devcage.sh --rebuild              # force full rebuild (no cache)
  ./devcage.sh -P test                 # activate "test" profile
  ./devcage.sh --profiles test,prod    # activate multiple profiles
  ./devcage.sh --path ~/my-project claude  # use ~/my-project as workspace
  ./devcage.sh --clean                 # remove current workspace resources
  ./devcage.sh --clean-global          # remove ALL DevCage resources system-wide
EOF
}

# ── Source modules ────────────────────────────────────────────────
# Load in dependency order: constants/utils first, then config, then
# resources/volumes/build/container/logging (all can reference each other
# after sourcing since they're in the same shell).
_LIB="${SCRIPT_DIR}/sandbox/lib/sh"
# shellcheck source=sandbox/lib/sh/constants.sh
. "${_LIB}/constants.sh"
# shellcheck source=sandbox/lib/sh/utils.sh
. "${_LIB}/utils.sh"
# shellcheck source=sandbox/lib/sh/config.sh
. "${_LIB}/config.sh"
# shellcheck source=sandbox/lib/sh/resources.sh
. "${_LIB}/resources.sh"
# shellcheck source=sandbox/lib/sh/volumes.sh
. "${_LIB}/volumes.sh"
# shellcheck source=sandbox/lib/sh/build.sh
. "${_LIB}/build.sh"
# shellcheck source=sandbox/lib/sh/container.sh
. "${_LIB}/container.sh"
# shellcheck source=sandbox/lib/sh/logging.sh
. "${_LIB}/logging.sh"

# ── Config directory paths ─────────────────────────────────────────
# Sets GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, and canonical dedup globals.
_init_config_dirs

# ── Pass 1: load global + user switches.conf to discover WORKSPACE_PATH ──
# CLI flags parse next and naturally overwrite anything set here.
ENV_VARS=()
PROFILES_FROM_CONF=""
ACTIVE_PROFILES=()
VERSION_BUILD_ARGS=()
_discover_config

# ── Variable defaults (may be overridden by switches.conf or CLI) ──
HOST_AGENTS_CONFIG="${HOST_AGENTS_CONFIG:-ro}"
EMBED_DOCKER="${EMBED_DOCKER:-0}"
NO_CACHE_VOLUMES="${NO_CACHE_VOLUMES:-0}"
CACHE_DIRS="${CACHE_DIRS:-node_modules}"
SKIP_BUILD="${SKIP_BUILD:-0}"
REBUILD=0
MEMORY_LIMIT="${MEMORY_LIMIT:-auto}"
CPU_LIMIT="${CPU_LIMIT:-auto}"
APT_PACKAGES="${APT_PACKAGES:-}"
BREW_PACKAGES="${BREW_PACKAGES:-}"
SNAP_PACKAGES="${SNAP_PACKAGES:-}"
PORT_LIST="${PORT_LIST:-}"
BIND_ADDR="${BIND_ADDR:-127.0.0.1}"
DNS_SERVERS="${DNS_SERVERS:-}"
VERBOSE="${VERBOSE:-0}"
GPU_TYPE="${GPU_TYPE:-}"
NO_GPU="${NO_GPU:-1}"
ENV_VARS=("${ENV_VARS[@]+"${ENV_VARS[@]}"}")
CUSTOM_VOLUMES=("${CUSTOM_VOLUMES[@]+"${CUSTOM_VOLUMES[@]}"}")
YOLO_CONFIG="${YOLO_CONFIG:-0}"
GIT_AUTH="${GIT_AUTH:-0}"
EXTRA_SCRIPT_SCRIPT="${EXTRA_SCRIPT_SCRIPT:-}"
SKIP_SANDBOX_WARNING="${SKIP_SANDBOX_WARNING:-0}"
LANGS="${LANGS:-}"
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
NO_REUSE="${NO_REUSE:-0}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-300}"
_STOP_ONLY=0
_RECREATE=0
_SHOW_SESSIONS=0
_LAUNCH_AGENT="${_LAUNCH_AGENT:-}"
PROFILES_FROM_CONF="${PROFILES_FROM_CONF:-}"
CLI_PROFILES=()
ACTIVE_PROFILES=()

# ── Pre-scan for --path (needed before --clean which exits early) ─
for (( _pi=1; _pi <= $#; _pi++ )); do
    if [[ "${!_pi}" == "--path" ]]; then
        _pn=$(( _pi + 1 ))
        [[ ${_pn} -le $# ]] && WORKSPACE_PATH="${!_pn}"
        break
    fi
done

# ── CLI argument parsing ──────────────────────────────────────────
while [[ $# -gt 0 ]]; do
    case "$1" in
        -v|--version)
            echo "DevCage ${DEVCAGE_VERSION}"
            exit 0
            ;;
        --help)
            usage
            exit 0
            ;;
        --clean)
            if [ "${WORKSPACE_PATH:-}" != "" ]; then
                WORKSPACE="$(_normalize_workspace_path "$WORKSPACE_PATH")"
            else
                WORKSPACE="$PWD"
            fi
            _compute_workspace_slug
            echo "Tearing down DevCage containers and volumes…" >&2
            if [ "$CONTAINER_ENGINE" = "" ]; then
                echo "Error: Neither docker nor podman found in PATH." >&2; exit 1
            fi
            # Stop and remove reuse-mode containers for this workspace
            _reuse_cid=$("$CONTAINER_ENGINE" ps -aq \
                --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
            if [ "$_reuse_cid" != "" ]; then
                "$CONTAINER_ENGINE" rm -f "$_reuse_cid" >/dev/null 2>&1 || true
                echo "Removed reuse-mode container" >&2
            fi
            # Remove all containers created inside the DinD sidecar
            # shellcheck disable=SC2016  # $() must expand inside DinD sh, not outer bash
            "$CONTAINER_ENGINE" compose -f "$COMPOSE_FILE" exec -T dind \
                sh -c 'docker ps -aq 2>/dev/null | xargs docker rm -f 2>/dev/null' 2>/dev/null || true
            "$CONTAINER_ENGINE" compose -f "$COMPOSE_FILE" down -v --remove-orphans
            # Remove per-workspace cache volumes and agent config volumes
            for _vol in "devcage_${WORKSPACE_SLUG:-default}_cache" "devcage_${WORKSPACE_SLUG:-default}_local" devcage-claude-config devcage-gemini-config devcage-codex-config devcage-copilot-config devcage-goose-config; do
                if "$CONTAINER_ENGINE" volume rm "$_vol" 2>/dev/null; then
                    echo "Removed cache volume: ${_vol}" >&2
                fi
            done
            # Remove fallback dircache volumes (used when engine lacks volume-subpath)
            "$CONTAINER_ENGINE" volume ls --format '{{.Name}}' 2>/dev/null | \
                grep "^devcage_${WORKSPACE_SLUG:-default}_dircache_" | while read -r _vol; do
                if "$CONTAINER_ENGINE" volume rm "$_vol" 2>/dev/null; then
                    echo "Removed dircache volume: ${_vol}" >&2
                fi
            done
            exit 0
            ;;
        --clean-global)
            echo "Removing ALL DevCage containers, images, volumes, and builder cache…" >&2
            if [ "$CONTAINER_ENGINE" = "" ]; then
                echo "Error: Neither docker nor podman found in PATH." >&2; exit 1
            fi
            # Stop and remove all running/stopped containers with "devcage" in the name
            "$CONTAINER_ENGINE" ps -a --filter "name=devcage" --format '{{.ID}}' 2>/dev/null | \
                xargs -r "$CONTAINER_ENGINE" rm -f 2>/dev/null || true
            # Remove all devcage volumes (hyphen-prefixed: shared agent-config etc.)
            "$CONTAINER_ENGINE" volume ls --filter "name=devcage-" --format '{{.Name}}' 2>/dev/null | while read -r _vol; do
                if "$CONTAINER_ENGINE" volume rm "$_vol" 2>/dev/null; then
                    echo "Removed volume: ${_vol}" >&2
                fi
            done
            # Remove all per-workspace devcage volumes (underscore naming)
            "$CONTAINER_ENGINE" volume ls --filter "name=devcage_" --format '{{.Name}}' 2>/dev/null | while read -r _vol; do
                if "$CONTAINER_ENGINE" volume rm "$_vol" 2>/dev/null; then
                    echo "Removed volume: ${_vol}" >&2
                fi
            done
            # Remove all devcage images (shared + per-workspace)
            "$CONTAINER_ENGINE" images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null | \
                grep '^devcage-' | awk '{print $2}' | sort -u | \
                xargs -r "$CONTAINER_ENGINE" rmi -f 2>/dev/null || true
            # Remove DinD sidecar images (current default + any previously pinned versions)
            "$CONTAINER_ENGINE" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | \
                grep '^docker:.*-dind$' | \
                xargs -r "$CONTAINER_ENGINE" rmi -f 2>/dev/null || true
            # Prune builder cache
            "$CONTAINER_ENGINE" builder prune -f 2>/dev/null || true
            echo "Global cleanup complete." >&2
            exit 0
            ;;
        --host-agents-config|-hac)
            if [[ -n "${2:-}" && "${2}" =~ ^(ro|rw|no|disabled)$ ]]; then
                HOST_AGENTS_CONFIG="$2"
                shift 2
            else
                echo "Error: --host-agents-config requires a mode: ro, rw, no, or disabled" >&2
                exit 1
            fi
            ;;
        --editable-config)
            HOST_AGENTS_CONFIG="rw"
            shift
            ;;
        --host-agents-config=*)
            _hac_val="${1#*=}"
            if [[ ! "$_hac_val" =~ ^(ro|rw|no|disabled)$ ]]; then
                echo "Error: --host-agents-config value '$_hac_val' must be ro, rw, no, or disabled" >&2
                exit 1
            fi
            HOST_AGENTS_CONFIG="$_hac_val"
            shift
            ;;
        --gpu)
            NO_GPU=0
            if [[ -n "${2:-}" && "${2}" =~ ^(nvidia|amd)$ ]]; then
                GPU_TYPE="$2"
                shift 2
            else
                # No type specified — auto-detect will run later
                GPU_TYPE="auto"
                shift
            fi
            ;;
        --no-gpu)
            NO_GPU=1
            GPU_TYPE=""
            shift
            ;;
        -d|--embed-docker)
            EMBED_DOCKER=1
            shift
            ;;
        -ncv|--no-cache-volumes)
            NO_CACHE_VOLUMES=1
            shift
            ;;
        --cache-dirs)
            [[ $# -ge 2 ]] || { echo "Error: --cache-dirs requires a value (use \"\" to disable)" >&2; exit 1; }
            CACHE_DIRS="$2"; shift 2
            ;;
        -p|--port|--ports)
            [[ -n "${2:-}" ]] || { echo "Error: -p/--port/--ports requires a value (e.g. 3000,5173 or 5080:80)" >&2; exit 1; }
            PORT_LIST="$2"
            shift 2
            ;;
        --bind)
            [[ -n "${2:-}" ]] || { echo "Error: --bind requires an IP address (e.g. 127.0.0.1 or 0.0.0.0)" >&2; exit 1; }
            if ! _valid_ipv4 "$2"; then
                echo "Error: --bind value '$2' is not a valid IPv4 address" >&2
                exit 1
            fi
            BIND_ADDR="$2"
            if [ "$BIND_ADDR" = "0.0.0.0" ]; then
                echo "WARNING: --bind 0.0.0.0 will expose forwarded ports to ALL network interfaces (LAN-accessible)." >&2
            fi
            shift 2
            ;;
        -apt|--apt-packages)
            [[ -n "${2:-}" ]] || { echo "Error: --apt-packages requires a value" >&2; exit 1; }
            # Normalize comma-separated to space-separated
            read -ra _pkgs <<< "${2//,/ }"
            for _pkg in "${_pkgs[@]}"; do
                if ! [[ "$_pkg" =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*(:[a-zA-Z0-9]+)?$ ]]; then
                    echo "Error: invalid package name '${_pkg}' in --apt-packages" >&2; exit 1
                fi
            done
            APT_PACKAGES="$2"
            shift 2
            ;;
        -brew|--brew-packages)
            [[ -n "${2:-}" ]] || { echo "Error: --brew-packages requires a value" >&2; exit 1; }
            read -ra _pkgs <<< "${2//,/ }"
            for _pkg in "${_pkgs[@]}"; do
                if ! [[ "$_pkg" =~ ^[a-zA-Z0-9][a-zA-Z0-9_./-]*$ ]]; then
                    echo "Error: invalid package name '${_pkg}' in --brew-packages" >&2; exit 1
                fi
            done
            BREW_PACKAGES="$2"
            shift 2
            ;;
        -snap|--snap-packages)
            [[ -n "${2:-}" ]] || { echo "Error: --snap-packages requires a value" >&2; exit 1; }
            read -ra _pkgs <<< "${2//,/ }"
            for _pkg in "${_pkgs[@]}"; do
                if ! [[ "$_pkg" =~ ^[a-z][a-z0-9-]*$ ]]; then
                    echo "Error: invalid snap name '${_pkg}' in --snap-packages" >&2; exit 1
                fi
            done
            SNAP_PACKAGES="$2"
            shift 2
            ;;
        -s|--script)
            [[ -n "${2:-}" ]] || { echo "Error: --script requires a path" >&2; exit 1; }
            EXTRA_SCRIPT_SCRIPT="$2"
            shift 2
            ;;
        -e|--env)
            [[ -n "${2:-}" ]] || { echo "Error: --env requires KEY=VALUE" >&2; exit 1; }
            if [[ ! "$2" =~ ^[a-zA-Z_][a-zA-Z0-9_]*= ]]; then
                echo "Error: --env value must be KEY=VALUE (got '$2')" >&2; exit 1
            fi
            ENV_VARS+=("$2")
            shift 2
            ;;
        -V|--volume)
            [[ -n "${2:-}" ]] || { echo "Error: --volume requires SOURCE:DEST[:MODE]" >&2; exit 1; }
            CUSTOM_VOLUMES+=("$2")
            shift 2
            ;;
        -yc|--yolo-config)
            YOLO_CONFIG=1
            shift
            ;;
        -git|--git-auth)
            GIT_AUTH=1
            shift
            ;;
        --verbose)
            VERBOSE=1
            shift
            ;;
        --no-reuse)
            NO_REUSE=1
            shift
            ;;
        --stop)
            _STOP_ONLY=1
            shift
            ;;
        --recreate)
            _RECREATE=1
            shift
            ;;
        --sessions)
            _SHOW_SESSIONS=1
            shift
            ;;
        --idle-timeout)
            [[ -n "${2:-}" ]] || { echo "Error: --idle-timeout requires a value in seconds" >&2; exit 1; }
            if ! [[ "$2" =~ ^[0-9]+$ ]]; then
                echo "Error: --idle-timeout must be a non-negative integer (got '$2')" >&2; exit 1
            fi
            IDLE_TIMEOUT="$2"
            shift 2
            ;;
        --skip-build)
            SKIP_BUILD=1
            shift
            ;;
        --rebuild)
            REBUILD=1
            SKIP_BUILD=0
            shift
            ;;
        -L|--lang|--langs)
            [[ -n "${2:-}" ]] || { echo "Error: $1 requires comma-separated languages (or 'none')" >&2; exit 1; }
            LANGS="$2"
            shift 2
            ;;
        --dns)
            [[ -n "${2:-}" ]] || { echo "Error: --dns requires comma-separated DNS server IPs" >&2; exit 1; }
            IFS=',' read -ra _dns_items <<< "$2"
            for _dns_ip in "${_dns_items[@]}"; do
                _dns_ip="$(echo "$_dns_ip" | tr -d '[:space:]')"
                _valid_ipv4 "$_dns_ip" || { echo "Error: --dns value '${_dns_ip}' is not a valid IPv4 address" >&2; exit 1; }
            done
            DNS_SERVERS="$2"
            shift 2
            ;;
        --browse)
            echo "Warning: --browse is Windows-only, ignoring" >&2
            shift
            ;;
        --skip-sandbox-warning)
            SKIP_SANDBOX_WARNING=1
            shift
            ;;
        -P|--profile|--profiles)
            [[ -n "${2:-}" ]] || { echo "Error: $1 requires a name or comma-separated names" >&2; exit 1; }
            IFS=',' read -ra _plist <<< "$2"
            for _pname in "${_plist[@]}"; do
                _pname="$(echo "$_pname" | tr -d '[:space:]')"
                [ "$_pname" = "" ] && continue
                _validate_profile_name "$_pname"
                CLI_PROFILES+=("$_pname")
            done
            shift 2
            ;;
        --path)
            [[ -n "${2:-}" ]] || { echo "Error: --path requires a directory path" >&2; exit 1; }
            WORKSPACE_PATH="$2"
            shift 2
            ;;
        --path-browse)
            echo "Warning: --path-browse is only supported on Windows. Ignoring." >&2
            shift
            ;;
        --memory)
            [[ -n "${2:-}" ]] || { echo "Error: --memory requires a value" >&2; exit 1; }
            MEMORY_LIMIT="$2"
            shift 2
            ;;
        --cpus)
            [[ -n "${2:-}" ]] || { echo "Error: --cpus requires a value" >&2; exit 1; }
            CPU_LIMIT="$2"
            shift 2
            ;;
        --launch|-l)
            [[ -n "${2:-}" ]] || { echo "Error: --launch requires a name" >&2; exit 1; }
            _LAUNCH_NAME="$2"
            if [[ "$_LAUNCH_NAME" =~ [/\\:*?\"\'\<\>\|] ]] || [ "$_LAUNCH_NAME" = "." ] || [ "$_LAUNCH_NAME" = ".." ]; then
                echo "Error: invalid launch name '${_LAUNCH_NAME}'" >&2; exit 1
            fi
            shift 2
            ;;
        --)
            shift
            break
            ;;
        *)
            break
            ;;
    esac
done

# ── Load launch configuration (--launch NAME) ──────────────────────
if [ "${_LAUNCH_NAME:-}" != "" ]; then
    _LAUNCH_FOUND=0
    _CWD="${WORKSPACE_PATH:-$(pwd)}"
    for _ldir in "${_CWD}/.devcage/local/launches" "${_CWD}/.devcage/launches"; do
        if [ -f "${_ldir}/${_LAUNCH_NAME}.conf" ]; then
            if [[ "$_ldir" == *"/local/launches" ]]; then
                _apply_switches "launch:${_LAUNCH_NAME}" < <(_parse_switches "${_ldir}/${_LAUNCH_NAME}.conf")
            else
                _apply_switches "launch:${_LAUNCH_NAME}" "skip_workspace" < <(_parse_switches "${_ldir}/${_LAUNCH_NAME}.conf")
            fi
            _LAUNCH_FOUND=1
            break
        fi
    done
    [ "$_LAUNCH_FOUND" -eq 1 ] || { echo "Error: launch '${_LAUNCH_NAME}' not found in .devcage/launches/ or .devcage/local/launches/" >&2; exit 1; }
fi

# ── Inject launch agent into positional args if needed ─────────────
if [ $# -eq 0 ] && [ "${_LAUNCH_AGENT:-}" != "" ]; then
    set -- "$_LAUNCH_AGENT"
fi

# ── Quiet mode: suppress informational output when a command is given ──
_HAS_CMD=0; [ $# -gt 0 ] && _HAS_CMD=1
_QUIET=0
if [ "$_HAS_CMD" -eq 1 ] && [ "$VERBOSE" != "1" ]; then
    _QUIET=1
fi

_info "Starting DevCage v${DEVCAGE_VERSION}…"

# ── Resolve active profiles ────────────────────────────────────────
_resolve_active_profiles

# ── Preflight: ensure container engine is usable ───────────────────
_ensure_engine_running

# ── Resource limits (auto fair-share or explicit) ─────────────────
_resolve_resources

# ── Host environment (Linux warning, HOST_UID, _HOST_OS, _host_path) ─
_setup_host_env

# ── GPU auto-detection ────────────────────────────────────────────
_detect_gpu

# ── Workspace resolution ──────────────────────────────────────────
_resolve_workspace

# ── Workspace slug + container name ───────────────────────────────
_compute_workspace_slug

# ── Handle --stop (early exit) ────────────────────────────────────
if [ "$_STOP_ONLY" = "1" ]; then
    if [ "$CONTAINER_ENGINE" = "" ]; then
        echo "Error: Neither docker nor podman found in PATH." >&2; exit 1
    fi
    _existing=$("$CONTAINER_ENGINE" ps -q \
        --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
    if [ "$_existing" != "" ]; then
        echo "Stopping container ${CONTAINER_NAME}…" >&2
        "$CONTAINER_ENGINE" stop "$_existing" >/dev/null 2>&1
        echo "Container stopped." >&2
    else
        echo "No running container found for workspace: ${WORKSPACE}" >&2
    fi
    exit 0
fi

# ── Handle --sessions (early exit) ────────────────────────────────
if [ "$_SHOW_SESSIONS" = "1" ]; then
    if [ "$CONTAINER_ENGINE" = "" ]; then
        echo "Error: Neither docker nor podman found in PATH." >&2; exit 1
    fi
    _existing=$("$CONTAINER_ENGINE" ps -q \
        --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
    if [ "$_existing" != "" ]; then
        echo "Active sessions in container ${CONTAINER_NAME}:" >&2
        "$CONTAINER_ENGINE" exec "$_existing" \
            find /tmp/.devcage-sessions -type f -printf '%f\n' 2>/dev/null || echo "(none)"
    else
        echo "No running container found for workspace: ${WORKSPACE}" >&2
    fi
    exit 0
fi

# ── Config hierarchy: second pass (project level + profiles) ──────
# Sets PROJECT_CONFIG_DIR (with dedup) then re-applies all non-Pass-1 files.
_set_project_config_dir
_full_config_apply

# ── Load and merge versions.conf (all 3 levels + profiles) ────────
_load_versions

# ── Resolve image name (shared vs workspace-specific) ─────────────
_resolve_image_name

# ── Terminal ──────────────────────────────────────────────────────
export TERM="${TERM:-xterm-256color}"

# ── Clean up stale sandbox temp dirs from previous runs (e.g. SIGKILL) ──
# -P: never follow symlinks (mitigates TOCTOU symlink race).
find -P "${TMPDIR:-/tmp}" -maxdepth 1 -name "devcage-*" -type d \
    -user "$(id -u)" -mmin +30 -exec rm -rf {} + 2>/dev/null || true

# ── DinD crash cleanup ────────────────────────────────────────────
# If a DinD sidecar survived a previous crash, tear it down before starting fresh.
if [ "$EMBED_DOCKER" = "1" ]; then
    _compose_cleanup -f "$(_host_path "$COMPOSE_FILE")"
fi

# ── Per-sandbox temp directory ────────────────────────────────────
# Each sandbox gets its own TMPDIR so Docker Compose and child processes
# never collide or hit permission errors on shared directories.
_ORIGINAL_TMPDIR="${TMPDIR:-/tmp}"
SANDBOX_TMPDIR="$(mktemp -d "${_ORIGINAL_TMPDIR}/devcage-XXXXXXXX")" || {
    echo "Error: failed to create temporary directory" >&2; exit 1
}
export TMPDIR="$SANDBOX_TMPDIR"

# Skip orphan container checks — saves 1-3s on WSL2
export COMPOSE_IGNORE_ORPHANS=1

# ── Export resource env vars (SANDBOX_MEMORY, SANDBOX_CPUS, DIND_*) ─
_export_resource_env

# ── Compose file list + GPU overlay + progress flags ──────────────
_setup_compose_files

# ── Fingerprint cache state dir ───────────────────────────────────
# Per-workspace .devcage/local/ stores the fingerprint cache (and gitignored).
_state_dir="${WORKSPACE}/.devcage/local"
mkdir -p "$_state_dir" 2>/dev/null || true
if [ ! -f "${_state_dir}/.gitignore" ]; then
    printf '*\n' > "${_state_dir}/.gitignore" 2>/dev/null || true
fi
_FP_CACHE_FILE="${_state_dir}/fingerprint.cache"

# ── Image name + build lock file ─────────────────────────────────
_IMAGE_NAME="${DEVCAGE_IMAGE:-devcage-workspace}"
_IMAGE_NAME_SAFE="$(printf '%s' "$_IMAGE_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g')"
_BUILD_LOCK_FILE="${_ORIGINAL_TMPDIR}/devcage-build-${_IMAGE_NAME_SAFE}.lock"

# ── Build all volume arrays ───────────────────────────────────────
# Sets *_VOLUME_ARGS, _SANDBOX_PROJECT_INIT, _SANDBOX_PROJECT_CRONTAB,
# _DIR_CACHE_COUNT, _has_subpath, _dc_paths.
# Note: _load_env_files is called separately below after _build_run_args.
_build_all_volumes

# ── Initialize RUN_ARGS (base docker run args) ────────────────────
# Must come after _build_all_volumes (which sets _SANDBOX_PROJECT_* flags)
# and before _init_session_log / _load_env_files (which append to RUN_ARGS).
_build_run_args

# ── Session log ───────────────────────────────────────────────────
# Creates log file, adds DEVCAGE_SESSION_LOG to RUN_ARGS, writes header.
_init_session_log "$@"

# ── Env files and custom env vars → RUN_ARGS ──────────────────────
# Must come after _build_run_args (which resets RUN_ARGS).
_load_env_files

# ── Port forwarding args → RUN_ARGS ───────────────────────────────
_build_port_args

# ── Common docker run args (_DR_ARGS) ─────────────────────────────
_build_common_run_args

# ── Per-session exec env args (_EXEC_ENV_ARGS) ────────────────────
_build_exec_env_args

# ── Final volume arg arrays ───────────────────────────────────────
_build_all_volume_args

# ── Build decision (image fingerprint → build or skip) ────────────
# Acquires build lock if needed, re-checks after lock, builds image,
# then pre-creates dircache dirs in the cache volume.
_make_build_decision

# ── Interactive vs batch command detection ────────────────────────
_detect_interactive_cmd "$@"

# ── EXIT trap: cleanup regardless of success or error ─────────────
trap 'cleanup_on_exit' EXIT

# ── Run the container ─────────────────────────────────────────────
_run_container "$@"

# ── Session footer + exit diagnostics ─────────────────────────────
_write_session_footer "$EXIT_CODE"
_print_exit_diagnostics "$EXIT_CODE" "$RESOLVED_MEMORY" "$SANDBOX_MEMORY_SWAP"

exit "$EXIT_CODE"
