# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
# shellcheck shell=bash
#
# container.sh — Container lifecycle: run args, compose files, reuse, single-shot.
# Sourced by devcage.sh after all volume and resource setup.
#
# Depends on (must be set before calling):
#   CONTAINER_ENGINE, DEVCAGE_IMAGE, WORKSPACE, WORKSPACE_SLUG, CONTAINER_NAME,
#   SANDBOX_MEMORY, SANDBOX_MEMORY_SWAP, SANDBOX_CPUS, EMBED_DOCKER, NO_REUSE,
#   NO_GPU, GPU_TYPE, COMPOSE_FILE, SCRIPT_DIR, BIND_ADDR, PORT_LIST,
#   RESOLVED_MEMORY, RESOLVED_CPUS, HOST_UID, AGENT_CONFIG_RO, DNS_SERVERS,
#   _HOST_OS, _DIR_CACHE_COUNT, _INTERACTIVE_CMD, SESSION_LOG, SESSION_TS,
#   IDLE_TIMEOUT, MEMORY_LIMIT, CPU_LIMIT, APT_PACKAGES, BREW_PACKAGES,
#   SNAP_PACKAGES, VERBOSE, YOLO_CONFIG, _SANDBOX_PROJECT_INIT, _SANDBOX_PROJECT_CRONTAB,
#   _API_KEY_VARS, _API_KEY_VARS_CORE, RUN_ARGS, DEVCAGE_VERSION, _RECREATE,
#   SANDBOX_TMPDIR, LOG_KEEP, LOG_DIR,
#   All *_VOLUME_ARGS arrays (set by volumes.sh)

# ── DinD teardown ─────────────────────────────────────────────────
# Shared DinD teardown — accepts compose file args as "$@".
# Called before startup (pre-run crash cleanup) and from the EXIT trap.
_compose_cleanup() {
    # shellcheck disable=SC2016  # $() must expand inside DinD sh, not outer bash
    "$CONTAINER_ENGINE" compose "$@" 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 "$@" down --remove-orphans 2>/dev/null || true
}

# ── Compose file list + GPU overlay ──────────────────────────────
# Initializes COMPOSE_FILES array with base file and optional GPU overlay.
# Also sets BUILD_PROGRESS_ARGS and RUN_PROGRESS_ARGS (docker-only progress flags).
#
# Reads:  COMPOSE_FILE, SCRIPT_DIR, NO_GPU, GPU_TYPE, CONTAINER_ENGINE
# Writes: COMPOSE_FILES, BUILD_PROGRESS_ARGS, RUN_PROGRESS_ARGS
_setup_compose_files() {
    COMPOSE_FILES=(-f "$(_host_path "$COMPOSE_FILE")")

    if [ "$NO_GPU" = "0" ] && [ "$GPU_TYPE" != "" ]; then
        case "$GPU_TYPE" in
            nvidia)
                local _gpu_overlay="${SCRIPT_DIR}/sandbox/docker-compose.gpu-nvidia.yml"
                if [ -f "$_gpu_overlay" ]; then
                    COMPOSE_FILES+=(-f "$(_host_path "$_gpu_overlay")")
                    _info "GPU: NVIDIA runtime detected — enabling GPU passthrough."
                fi
                ;;
            amd)
                local _gpu_overlay="${SCRIPT_DIR}/sandbox/docker-compose.gpu-amd.yml"
                if [ -f "$_gpu_overlay" ]; then
                    COMPOSE_FILES+=(-f "$(_host_path "$_gpu_overlay")")
                    _info "GPU: AMD runtime detected — enabling GPU passthrough."
                fi
                ;;
        esac
    fi

    # Suppress orchestration noise (network/volume/container creation messages).
    # Errors and warnings still appear on stderr.
    # Docker-only: Podman compose variants may not support --progress.
    # shellcheck disable=SC2034  # consumed by the sourcing script (devcage.sh)
    BUILD_PROGRESS_ARGS=()
    RUN_PROGRESS_ARGS=()
    if [ "$CONTAINER_ENGINE" = "docker" ]; then
        # shellcheck disable=SC2034  # consumed by the sourcing script (devcage.sh)
        BUILD_PROGRESS_ARGS=(--progress quiet)
        RUN_PROGRESS_ARGS=(--progress quiet)
    fi
}

# ── Miscellaneous docker run args ─────────────────────────────────
# Initializes RUN_ARGS and appends auto-mode labels, package installs,
# entrypoint signal vars, and DinD-disable env vars.
# Note: session log DEVCAGE_SESSION_LOG is added by _init_session_log (logging.sh).
#
# Reads:  NO_REUSE, MEMORY_LIMIT, CPU_LIMIT, APT_PACKAGES, BREW_PACKAGES,
#         SNAP_PACKAGES, VERBOSE, YOLO_CONFIG, _SANDBOX_PROJECT_INIT,
#         _SANDBOX_PROJECT_CRONTAB, EMBED_DOCKER, _HOST_OS
# Writes: RUN_ARGS (initialized fresh)
_build_run_args() {
    RUN_ARGS=()

    if [ "$NO_REUSE" = "1" ]; then
        RUN_ARGS+=(--rm)
    fi

    # Tag auto-mode resources for fair-share rebalancing across concurrent instances
    if [[ "$MEMORY_LIMIT" == "auto" ]]; then
        RUN_ARGS+=(--label "dev-sandbox.memory-mode=auto")
    fi
    if [[ "$CPU_LIMIT" == "auto" ]]; then
        RUN_ARGS+=(--label "dev-sandbox.cpu-mode=auto")
    fi

    # Apt packages to pre-install at container start
    if [ "$APT_PACKAGES" != "" ]; then
        RUN_ARGS+=(-e "APT_PACKAGES=${APT_PACKAGES}")
    fi

    # Brew packages to install at container start (as devcage user)
    if [ "$BREW_PACKAGES" != "" ]; then
        RUN_ARGS+=(-e "BREW_PACKAGES=${BREW_PACKAGES}")
    fi

    # Snap packages to install at container start (as root, --devmode)
    if [ "$SNAP_PACKAGES" != "" ]; then
        RUN_ARGS+=(-e "SNAP_PACKAGES=${SNAP_PACKAGES}")
    fi

    # Verbose mode: enable entrypoint tracing
    if [ "$VERBOSE" = "1" ]; then
        RUN_ARGS+=(-e "SANDBOX_VERBOSE=1")
    fi

    # Yolo config mode: auto-approve project-level config (init.sh, crontab)
    if [ "$YOLO_CONFIG" = "1" ]; then
        RUN_ARGS+=(-e "SANDBOX_YOLO_CONFIG=1")
    fi

    # Signal to entrypoint that a project init exists (needs confirmation)
    if [ "$_SANDBOX_PROJECT_INIT" = "1" ]; then
        RUN_ARGS+=(-e "SANDBOX_PROJECT_INIT=1")
    fi

    # Signal to entrypoint that a project crontab exists (needs confirmation)
    if [ "$_SANDBOX_PROJECT_CRONTAB" = "1" ]; then
        RUN_ARGS+=(-e "SANDBOX_PROJECT_CRONTAB=1")
    fi

    # No-docker mode: skip DinD sidecar, disable Docker env vars
    if [ "$EMBED_DOCKER" = "0" ]; then
        RUN_ARGS+=(-e "DOCKER_HOST=tcp://disabled:0")
        RUN_ARGS+=(-e "DOCKER_TLS_VERIFY=")
        RUN_ARGS+=(-e "DOCKER_CERT_PATH=/nonexistent")
    fi

    # Host OS for entrypoint platform-specific optimizations
    RUN_ARGS+=(-e "HOST_OS=${_HOST_OS}")
}

# ── Port forwarding args ──────────────────────────────────────────
# Parses PORT_LIST and appends --publish args to RUN_ARGS.
# Supports: simple port (3000), host:container (5080:80), full bind:host:container.
# Uses string-based dedup for Bash 3.2 compatibility (macOS ships Bash 3.2).
#
# Reads:  PORT_LIST, BIND_ADDR
# Writes: RUN_ARGS (appended)
_build_port_args() {
    [ "$PORT_LIST" = "" ] && return 0

    local _seen_ports=" "
    local _ports _port_spec _colon_count _port_key
    IFS=',' read -ra _ports <<< "$PORT_LIST"
    for _port_spec in "${_ports[@]}"; do
        _port_spec="$(echo "$_port_spec" | tr -d '[:space:]')"

        # Count colons to determine format
        _colon_count=$(echo "$_port_spec" | tr -cd ':' | wc -c | tr -d '[:space:]')

        if [ "$_colon_count" -eq 0 ]; then
            # Simple port number: 3000 → BIND_ADDR:3000:3000
            if [[ "$_port_spec" =~ ^[0-9]+$ ]] && [ "$_port_spec" -ge 1 ] && [ "$_port_spec" -le 65535 ]; then
                _port_key="${BIND_ADDR}:${_port_spec}:${_port_spec}"
                case "$_seen_ports" in
                    *" ${_port_key} "*) echo "Warning: duplicate port mapping ${_port_key} ignored" >&2 ;;
                    *) _seen_ports="${_seen_ports}${_port_key} "
                       RUN_ARGS+=("--publish" "$_port_key") ;;
                esac
            else
                echo "Error: invalid port '${_port_spec}' (must be 1-65535)" >&2
                exit 1
            fi
        elif [ "$_colon_count" -eq 1 ]; then
            # Host:container mapping: 5080:80 → BIND_ADDR:5080:80
            local _host_port _container_port
            IFS=':' read -r _host_port _container_port <<< "$_port_spec"
            if [[ "$_host_port" =~ ^[0-9]+$ ]] && [ "$_host_port" -ge 1 ] && [ "$_host_port" -le 65535 ] && \
               [[ "$_container_port" =~ ^[0-9]+$ ]] && [ "$_container_port" -ge 1 ] && [ "$_container_port" -le 65535 ]; then
                _port_key="${BIND_ADDR}:${_host_port}:${_container_port}"
                case "$_seen_ports" in
                    *" ${_port_key} "*) echo "Warning: duplicate port mapping ${_port_key} ignored" >&2 ;;
                    *) _seen_ports="${_seen_ports}${_port_key} "
                       RUN_ARGS+=("--publish" "$_port_key") ;;
                esac
            else
                echo "Error: invalid port mapping '${_port_spec}' (format: HOST_PORT:CONTAINER_PORT, both 1-65535)" >&2
                exit 1
            fi
        elif [ "$_colon_count" -eq 2 ]; then
            # Full Docker syntax: 0.0.0.0:5080:80 → use as-is
            local _bind_ip _host_port _container_port
            IFS=':' read -r _bind_ip _host_port _container_port <<< "$_port_spec"
            if _valid_ipv4 "$_bind_ip" && \
               [[ "$_host_port" =~ ^[0-9]+$ ]] && [ "$_host_port" -ge 1 ] && [ "$_host_port" -le 65535 ] && \
               [[ "$_container_port" =~ ^[0-9]+$ ]] && [ "$_container_port" -ge 1 ] && [ "$_container_port" -le 65535 ]; then
                _port_key="$_port_spec"
                case "$_seen_ports" in
                    *" ${_port_key} "*) echo "Warning: duplicate port mapping ${_port_key} ignored" >&2 ;;
                    *) _seen_ports="${_seen_ports}${_port_key} "
                       RUN_ARGS+=("--publish" "$_port_key") ;;
                esac
            else
                echo "Error: invalid port mapping '${_port_spec}' (format: IP:HOST_PORT:CONTAINER_PORT)" >&2
                exit 1
            fi
        else
            echo "Error: invalid port mapping '${_port_spec}' (too many colons)" >&2
            exit 1
        fi
    done
}

# ── Interactive command detection ─────────────────────────────────
# Determines whether the command is interactive (→ -it) or batch (→ -i).
# Uses _INTERACTIVE_COMMANDS array from constants.sh.
# Downgrades to non-interactive if no TTY is attached (e.g. scripts, CI/CD).
#
# Args:   "$@" — the command to run (same as launcher's positional args)
# Writes: _INTERACTIVE_CMD (1=interactive, 0=batch)
_detect_interactive_cmd() {
    _INTERACTIVE_CMD=1
    if [ $# -gt 0 ]; then
        local _first_cmd
        _first_cmd="$(basename "$1")"
        if ! _is_interactive_command "$_first_cmd"; then
            _INTERACTIVE_CMD=0
        fi
    fi
    # Downgrade to non-interactive if no TTY is available
    if [ ! -t 0 ] || [ ! -t 1 ]; then
        _INTERACTIVE_CMD=0
    fi
}

# ── Common docker run args ────────────────────────────────────────
# Constructs the _DR_ARGS array shared between reuse and single-shot runs.
# Contains: security caps, resource limits, core env vars, workspace mount, DNS.
#
# Reads:  WORKSPACE_SLUG, WORKSPACE, SANDBOX_MEMORY, SANDBOX_MEMORY_SWAP,
#         SANDBOX_CPUS, TERM, COLORTERM, HOST_UID, AGENT_CONFIG_RO, DNS_SERVERS
# Writes: _DR_ARGS
_build_common_run_args() {
    _DR_ARGS=(
        --hostname "${WORKSPACE_SLUG:-sandbox}"
        --label "dev-sandbox.workspace=${WORKSPACE:-unknown}"
        --label "dev-sandbox.version=${DEVCAGE_VERSION:-unknown}"
        --cap-drop ALL
        --cap-add SETUID --cap-add SETGID --cap-add SETPCAP
        --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER
        --cap-add NET_ADMIN --cap-add NET_RAW
        --security-opt no-new-privileges:true
        --memory "$SANDBOX_MEMORY"
        --memory-swap "$SANDBOX_MEMORY_SWAP"
        --cpus "$SANDBOX_CPUS"
        --pids-limit 4096
        --pull never
        -e "TERM=${TERM:-xterm-256color}"
        -e "COLORTERM=${COLORTERM:-}"
        -e "HOST_UID=${HOST_UID:-1000}"
        -e "HOST_WORKSPACE=${WORKSPACE}"
        -e "AGENT_CONFIG_RO=${AGENT_CONFIG_RO:-1}"
        -e "SANDBOX_VERBOSE=${SANDBOX_VERBOSE:-0}"
        -e "DISABLE_TELEMETRY=1"
        -e "DO_NOT_TRACK=1"
        -v "${WORKSPACE}:/workspace"
    )
    # Memory swappiness: only on native Linux where cgroup support is reliable.
    # Docker Desktop (Windows/macOS) VMs often lack cgroup swappiness support,
    # producing a noisy warning.  Compose silences it; docker run does not.
    if [ "$(uname -s)" = "Linux" ]; then
        _DR_ARGS+=(--memory-swappiness 10)
    fi
    # DNS servers: configurable via --dns or switches.conf, default to public resolvers
    local _dns_list="${DNS_SERVERS:-8.8.8.8,1.1.1.1}"
    local _dns_arr=() _dns_ip
    IFS=',' read -ra _dns_arr <<< "$_dns_list"
    for _dns_ip in "${_dns_arr[@]}"; do
        _dns_ip="$(echo "$_dns_ip" | tr -d '[:space:]')"
        [ "$_dns_ip" != "" ] && _DR_ARGS+=(--dns "$_dns_ip")
    done
}

# ── Exec env args (per-session API key passthrough) ───────────────
# Builds _EXEC_ENV_ARGS for docker exec sessions (reuse mode).
# All 9 API keys are passed (including exec-only GH_TOKEN/GITHUB_TOKEN).
# Uses _API_KEY_VARS loaded from shared/api-keys.list (constants.sh).
#
# Reads:  _API_KEY_VARS, TERM, COLORTERM, SESSION_LOG, SESSION_TS
# Writes: _EXEC_ENV_ARGS
_build_exec_env_args() {
    _EXEC_ENV_ARGS=()
    local _api_var
    for _api_var in "${_API_KEY_VARS[@]+"${_API_KEY_VARS[@]}"}"; do
        if [ "${!_api_var:-}" != "" ]; then
            _EXEC_ENV_ARGS+=(-e "$_api_var")
        fi
    done
    _EXEC_ENV_ARGS+=(-e "TERM=${TERM:-xterm-256color}")
    _EXEC_ENV_ARGS+=(-e "COLORTERM=${COLORTERM:-}")
    if [ "${SESSION_LOG:-}" != "" ]; then
        _EXEC_ENV_ARGS+=(-e "DEVCAGE_SESSION_LOG=/workspace/.devcage/logs/session-${SESSION_TS}.log")
    fi
}

# ── Assemble final volume arg arrays ─────────────────────────────
# Combines all individual volume arrays into _ALL_VOLUME_ARGS (for docker run)
# and _ALL_COMPOSE_VOLUME_ARGS (for docker compose run, which rejects --mount).
#
# Reads:  VOLUME_ARGS, INIT_VOLUME_ARGS, CRONTAB_VOLUME_ARGS, AGENTS_VOLUME_ARGS,
#         VERSIONS_VOLUME_ARGS, CACHE_VOLUME_ARGS, DIR_CACHE_VOLUME_ARGS,
#         CUSTOM_VOLUME_ARGS
# Writes: _ALL_VOLUME_ARGS, _ALL_COMPOSE_VOLUME_ARGS
_build_all_volume_args() {
    _ALL_VOLUME_ARGS=(
        "${VOLUME_ARGS[@]+"${VOLUME_ARGS[@]}"}"
        "${INIT_VOLUME_ARGS[@]+"${INIT_VOLUME_ARGS[@]}"}"
        "${CRONTAB_VOLUME_ARGS[@]+"${CRONTAB_VOLUME_ARGS[@]}"}"
        "${AGENTS_VOLUME_ARGS[@]+"${AGENTS_VOLUME_ARGS[@]}"}"
        "${VERSIONS_VOLUME_ARGS[@]+"${VERSIONS_VOLUME_ARGS[@]}"}"
        "${CACHE_VOLUME_ARGS[@]+"${CACHE_VOLUME_ARGS[@]}"}"
        "${DIR_CACHE_VOLUME_ARGS[@]+"${DIR_CACHE_VOLUME_ARGS[@]}"}"
        "${CUSTOM_VOLUME_ARGS[@]+"${CUSTOM_VOLUME_ARGS[@]}"}"
    )
    # Compose-compatible volume args: excludes --mount (not supported by docker compose run).
    # DIR_CACHE_VOLUME_ARGS may contain --mount for volume-subpath dircache.
    local _COMPOSE_VOLUME_ARGS=() _skip_next=0 _varg
    for _varg in "${DIR_CACHE_VOLUME_ARGS[@]+"${DIR_CACHE_VOLUME_ARGS[@]}"}"; do
        if [ "$_skip_next" = "1" ]; then _skip_next=0; continue; fi
        if [ "$_varg" = "--mount" ]; then _skip_next=1; continue; fi
        _COMPOSE_VOLUME_ARGS+=("$_varg")
    done
    _ALL_COMPOSE_VOLUME_ARGS=(
        "${VOLUME_ARGS[@]+"${VOLUME_ARGS[@]}"}"
        "${INIT_VOLUME_ARGS[@]+"${INIT_VOLUME_ARGS[@]}"}"
        "${CRONTAB_VOLUME_ARGS[@]+"${CRONTAB_VOLUME_ARGS[@]}"}"
        "${AGENTS_VOLUME_ARGS[@]+"${AGENTS_VOLUME_ARGS[@]}"}"
        "${VERSIONS_VOLUME_ARGS[@]+"${VERSIONS_VOLUME_ARGS[@]}"}"
        "${CACHE_VOLUME_ARGS[@]+"${CACHE_VOLUME_ARGS[@]}"}"
        "${_COMPOSE_VOLUME_ARGS[@]+"${_COMPOSE_VOLUME_ARGS[@]}"}"
        "${CUSTOM_VOLUME_ARGS[@]+"${CUSTOM_VOLUME_ARGS[@]}"}"
    )
}

# ── Container reuse mode ──────────────────────────────────────────
# Discovers an existing persistent container for this workspace, or creates one.
# Sets EXIT_CODE and _REUSE_ATTACHED on completion.
#
# Lifecycle:
#   --recreate → remove existing container first
#   Running container found → version check → docker exec attach
#   Stopped container → remove (bind mounts stale) → fall through to create
#   No container → docker run -d (detached) + wait for ready + docker exec attach
_handle_container_reuse() {
    # ── Handle --recreate (stop + remove before creating fresh) ────
    if [ "$_RECREATE" = "1" ]; then
        local _old_cid
        _old_cid=$("$CONTAINER_ENGINE" ps -aq \
            --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
        if [ "$_old_cid" != "" ]; then
            _info "Recreating container…"
            "$CONTAINER_ENGINE" rm -f "$_old_cid" >/dev/null 2>&1 || true
        fi
    fi

    # ── Discover existing container ───────────────────────────────
    local _existing _stopped
    _existing=$("$CONTAINER_ENGINE" ps -q \
        --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
    _stopped=""
    if [ "$_existing" = "" ]; then
        _stopped=$("$CONTAINER_ENGINE" ps -aq --filter "status=exited" \
            --filter "label=dev-sandbox.workspace=${WORKSPACE}" 2>/dev/null | head -1)
    fi

    if [ "$_existing" != "" ]; then
        # ── Running container found — validate image version ──────
        local _container_version
        _container_version=$("$CONTAINER_ENGINE" inspect \
            --format '{{index .Config.Labels "dev-sandbox.version"}}' "$_existing" 2>/dev/null || echo "")
        if [ "$_container_version" != "$DEVCAGE_VERSION" ]; then
            _info "Container running older DevCage version (${_container_version}). Stopping and recreating…"
            "$CONTAINER_ENGINE" rm -f "$_existing" >/dev/null 2>&1 || true
            _existing=""
        fi
    fi

    if [ "$_existing" != "" ]; then
        # ── Attach to running container via docker exec ────────────
        # Check if requested ports are available in the existing container
        if [ "$PORT_LIST" != "" ]; then
            local _container_ports
            # shellcheck disable=SC2016  # Go template syntax, not shell expansion
            _container_ports=$("$CONTAINER_ENGINE" inspect \
                --format '{{range $p, $c := .NetworkSettings.Ports}}{{$p}} {{end}}' "$_existing" 2>/dev/null || echo "")
            local _requested_ports=() _rp _cp
            IFS=',' read -ra _requested_ports <<< "$PORT_LIST"
            for _rp in "${_requested_ports[@]}"; do
                _rp="$(echo "$_rp" | tr -d '[:space:]')"
                # Extract container port from various formats
                case "$_rp" in
                    *:*:*) _cp="${_rp##*:}" ;;
                    *:*)   _cp="${_rp##*:}" ;;
                    *)     _cp="$_rp" ;;
                esac
                if [[ "$_container_ports" != *"${_cp}/tcp"* ]]; then
                    _warn "Port ${_cp} not available in running container."
                    _warn "  Use \`devcage.sh --recreate -p ${PORT_LIST} ...\` to rebuild with additional ports."
                fi
            done
        fi

        local _ndc _ws_sessions _dind_label="off"
        [ "$EMBED_DOCKER" = "1" ] && _dind_label="on"
        _ndc=$("$CONTAINER_ENGINE" ps -q --filter "label=dev-sandbox.workspace" 2>/dev/null | wc -l | tr -d '[:space:]')
        _ndc=$(( _ndc - 1 ))
        # shellcheck disable=SC2016  # single-quoted string is a command for sh -c in container
        _ws_sessions=$("$CONTAINER_ENGINE" exec "$_existing" sh -c \
            'count=0; for f in /tmp/.devcage-sessions/[0-9]*; do [ -f "$f" ] || continue; p="${f##*/}"; if [ -d "/proc/$p" ]; then count=$(( count + 1 )); else rm -f "$f" 2>/dev/null; fi; done; echo "$count"' \
            2>/dev/null | tr -d '[:space:]')
        _ws_sessions=$(( _ws_sessions + 0 ))
        if [ "$_ndc" -gt 0 ] || [ "$_ws_sessions" -gt 0 ]; then
            local _msg=""
            if [ "$_ndc" -gt 0 ]; then
                _msg=$([ "$_ndc" -eq 1 ] && echo "There is 1 other DevCage container running." || echo "There are ${_ndc} other DevCage containers running.")
            fi
            if [ "$_ws_sessions" -gt 0 ]; then
                local _ws_msg
                _ws_msg=$([ "$_ws_sessions" -eq 1 ] && echo "There is 1 other session in this workspace." || echo "There are ${_ws_sessions} other sessions in this workspace.")
                _msg="${_msg}${_msg:+ }${_ws_msg}"
            fi
            _info "$_msg"
        fi
        _info "$(_format_status_line "$CONTAINER_ENGINE" "$_dind_label" "$_DIR_CACHE_COUNT" "$RESOLVED_MEMORY" "$RESOLVED_CPUS" "Container: reused")"

        # TTY handling for exec
        local _EXEC_TTY_ARGS=()
        if [ "$_INTERACTIVE_CMD" = "1" ]; then
            _EXEC_TTY_ARGS=(-it)
        else
            _EXEC_TTY_ARGS=(-i)
        fi

        _REUSE_CONTAINER_ID="$_existing"
        "$CONTAINER_ENGINE" exec \
            "${_EXEC_TTY_ARGS[@]}" \
            "${_EXEC_ENV_ARGS[@]}" \
            "$_existing" /usr/local/bin/devcage-session "$@" && EXIT_CODE=0 || EXIT_CODE=$?
        _REUSE_ATTACHED=1

    elif [ "$_stopped" != "" ]; then
        # ── Stopped container found — remove and recreate with fresh config ───
        # Always recreate rather than restart: bind mounts reference temp files
        # from the previous launcher session that no longer exist. Recreating
        # ensures fresh mounts and up-to-date config. Named volumes survive removal.
        _info "Recreating stopped container…"
        "$CONTAINER_ENGINE" rm "$_stopped" >/dev/null 2>&1 || true
        _stopped=""
    fi

    # ── No existing container — create new persistent container ───
    if [ "$_REUSE_ATTACHED" = "0" ]; then
        local _ndc _dind_label="off"
        [ "$EMBED_DOCKER" = "1" ] && _dind_label="on"
        _ndc=$("$CONTAINER_ENGINE" ps -q --filter "label=dev-sandbox.workspace" 2>/dev/null | wc -l | tr -d '[:space:]')
        _ndc=$(( _ndc + 0 ))
        if [ "$_ndc" -gt 0 ]; then
            # shellcheck disable=SC2015  # _info always succeeds; false positive
            [ "$_ndc" -eq 1 ] && _info "There is 1 other DevCage container running." || _info "There are ${_ndc} other DevCage containers running."
        fi
        _info "$(_format_status_line "$CONTAINER_ENGINE" "$_dind_label" "$_DIR_CACHE_COUNT" "$RESOLVED_MEMORY" "$RESOLVED_CPUS" "Container: new | Initializing...")"

        # API keys NOT passed at docker run in reuse mode — they are passed
        # per-session via docker exec -e, avoiding persistent exposure in
        # docker inspect. The entrypoint does not need API keys.

        # Reuse mode: create detached container
        _DR_ARGS+=(
            --name "$CONTAINER_NAME"
            -d
            --restart on-failure
            -e "DEVCAGE_REUSE=1"
            -e "DEVCAGE_IDLE_TIMEOUT=${IDLE_TIMEOUT}"
        )

        local _docker_run_stderr="${SANDBOX_TMPDIR}/.docker-run-stderr"
        local _new_cid
        _new_cid=$("$CONTAINER_ENGINE" run \
            "${_DR_ARGS[@]}" \
            "${RUN_ARGS[@]}" \
            "${_ALL_VOLUME_ARGS[@]+"${_ALL_VOLUME_ARGS[@]}"}" \
            "$_IMAGE_NAME" 2>"$_docker_run_stderr") || {
            echo "Error: failed to create container" >&2
            cat "$_docker_run_stderr" >&2
            exit 1
        }
        _new_cid="$(echo "$_new_cid" | tr -d '[:space:]')"
        _REUSE_CONTAINER_ID="$_new_cid"

        # Wait for healthcheck (/tmp/.devcage-ready)
        local _wait_count=0
        while [ "$_wait_count" -lt 240 ]; do
            if "$CONTAINER_ENGINE" exec "$_new_cid" test -f /tmp/.devcage-ready 2>/dev/null; then
                break
            fi
            # Check if container died during init
            local _state
            _state=$("$CONTAINER_ENGINE" inspect --format '{{.State.Status}}' "$_new_cid" 2>/dev/null || echo "unknown")
            if [ "$_state" = "exited" ] || [ "$_state" = "dead" ]; then
                echo "Error: container exited during initialization" >&2
                "$CONTAINER_ENGINE" logs "$_new_cid" >&2 2>/dev/null || true
                "$CONTAINER_ENGINE" rm "$_new_cid" >/dev/null 2>&1 || true
                exit 1
            fi
            sleep 0.5
            _wait_count=$(( _wait_count + 1 ))
        done
        if [ "$_wait_count" -ge 240 ]; then
            _warn "container did not become ready within 120s — proceeding anyway"
        fi

        # Attach via exec
        local _EXEC_TTY_ARGS=()
        if [ "$_INTERACTIVE_CMD" = "1" ]; then
            _EXEC_TTY_ARGS=(-it)
        else
            _EXEC_TTY_ARGS=(-i)
        fi

        "$CONTAINER_ENGINE" exec \
            "${_EXEC_TTY_ARGS[@]}" \
            "${_EXEC_ENV_ARGS[@]}" \
            "$_new_cid" /usr/local/bin/devcage-session "$@" && EXIT_CODE=0 || EXIT_CODE=$?
        _REUSE_ATTACHED=1
    fi
}

# ── Single-shot container ─────────────────────────────────────────
# Runs an ephemeral (--rm) container for --no-reuse or --embed-docker mode.
# DinD uses docker compose run; non-DinD uses docker run directly.
_run_single_shot_container() {
    local _dind_label="off"
    [ "$EMBED_DOCKER" = "1" ] && _dind_label="on"
    local _ndc
    _ndc=$("$CONTAINER_ENGINE" ps -q --filter "label=dev-sandbox.workspace" 2>/dev/null | wc -l | tr -d '[:space:]')
    _ndc=$(( _ndc + 0 ))
    if [ "$_ndc" -gt 0 ]; then
        # shellcheck disable=SC2015  # _info always succeeds; false positive
        [ "$_ndc" -eq 1 ] && _info "There is 1 other DevCage container running." || _info "There are ${_ndc} other DevCage containers running."
    fi
    _info "$(_format_status_line "$CONTAINER_ENGINE" "$_dind_label" "$_DIR_CACHE_COUNT" "$RESOLVED_MEMORY" "$RESOLVED_CPUS" "Initializing...")"

    # ── DinD LAN isolation ───────────────────────────────────────
    local DIND_LAN_BLOCK="${SCRIPT_DIR}/sandbox/dind-lan-block.sh"
    if [ "$EMBED_DOCKER" = "1" ] && [ -f "$DIND_LAN_BLOCK" ]; then
        # Start DinD sidecar (progress output is noisy, suppress)
        if "$CONTAINER_ENGINE" compose "${RUN_PROGRESS_ARGS[@]+"${RUN_PROGRESS_ARGS[@]}"}" \
                "${COMPOSE_FILES[@]}" up -d --wait dind 2>/dev/null; then
            # Inject LAN isolation rules — keep stderr visible for diagnostics
            if "$CONTAINER_ENGINE" compose "${COMPOSE_FILES[@]}" cp \
                    "$(_host_path "$DIND_LAN_BLOCK")" dind:/tmp/dind-lan-block.sh 2>/dev/null \
                && "$CONTAINER_ENGINE" compose "${COMPOSE_FILES[@]}" exec -T dind \
                    sh /tmp/dind-lan-block.sh; then
                : # success
            else
                _warn "DinD LAN isolation failed — containers inside DinD may reach LAN addresses"
                _warn "  Tip: omit --embed-docker if Docker-in-Docker is not needed"
            fi
        else
            _warn "DinD sidecar failed to start — containers inside DinD may reach LAN addresses"
            _warn "  Tip: omit --embed-docker if Docker-in-Docker is not needed"
        fi
    fi

    if [ "$EMBED_DOCKER" = "0" ]; then
        # ── Fast path: bypass docker compose for single-container mode ───
        # Pass API keys only if set (avoids Docker warnings for unset env vars)
        local _api_var
        for _api_var in "${_API_KEY_VARS_CORE[@]+"${_API_KEY_VARS_CORE[@]}"}"; do
            if [ "${!_api_var:-}" != "" ]; then
                _DR_ARGS+=(-e "$_api_var")
            fi
        done
        # TTY: -it for interactive commands (shells, agents), -i for batch
        if [ "$_INTERACTIVE_CMD" = "1" ]; then
            _DR_ARGS+=(-it)
        else
            _DR_ARGS+=(-i)
        fi

        if [ "$NO_REUSE" = "1" ]; then
            _DR_ARGS+=(-e "DEVCAGE_REUSE=0")
        fi

        "$CONTAINER_ENGINE" run \
            "${_DR_ARGS[@]}" \
            "${RUN_ARGS[@]}" \
            "${_ALL_VOLUME_ARGS[@]+"${_ALL_VOLUME_ARGS[@]}"}" \
            "$_IMAGE_NAME" "$@" && EXIT_CODE=0 || EXIT_CODE=$?
    else
        # ── DinD enabled: use docker compose for two-container orchestration ──
        # Export DNS env vars for compose variable substitution
        local _dns_compose="${DNS_SERVERS:-8.8.8.8,1.1.1.1}"
        local _dns_compose_arr=()
        IFS=',' read -ra _dns_compose_arr <<< "$_dns_compose"
        export DNS_1="${_dns_compose_arr[0]:-8.8.8.8}"
        export DNS_2="${_dns_compose_arr[1]:-1.1.1.1}"

        # Compose-specific flags added directly (not in RUN_ARGS).
        local _COMPOSE_EXTRA=(--quiet-pull)
        if [ "$_INTERACTIVE_CMD" = "0" ]; then
            _COMPOSE_EXTRA+=(-T)
        fi

        # Compose always uses --rm (reuse mode not supported with DinD)
        # Use _ALL_COMPOSE_VOLUME_ARGS (excludes --mount, unsupported by compose run)
        "$CONTAINER_ENGINE" compose "${RUN_PROGRESS_ARGS[@]+"${RUN_PROGRESS_ARGS[@]}"}" "${COMPOSE_FILES[@]}" run \
            --rm \
            "${_COMPOSE_EXTRA[@]}" \
            "${RUN_ARGS[@]}" \
            "${_ALL_COMPOSE_VOLUME_ARGS[@]+"${_ALL_COMPOSE_VOLUME_ARGS[@]}"}" \
            workspace "$@" && EXIT_CODE=0 || EXIT_CODE=$?
    fi
}

# ── Top-level container runner ────────────────────────────────────
# Dispatches to reuse mode or single-shot mode based on NO_REUSE/EMBED_DOCKER.
# Sets EXIT_CODE. The caller (devcage.sh) uses EXIT_CODE after return.
#
# Args: "$@" — command + args to run inside the container
_run_container() {
    # shellcheck disable=SC2034  # consumed by the sourcing script after return
    EXIT_CODE=1  # safe default: failure if something goes wrong before docker
    _REUSE_ATTACHED=0
    _REUSE_CONTAINER_ID=""  # set by reuse path; used for OOM-vs-idle diagnostics

    if [ "$NO_REUSE" = "0" ] && [ "$EMBED_DOCKER" = "0" ]; then
        _handle_container_reuse "$@"
    fi

    if [ "$_REUSE_ATTACHED" = "0" ]; then
        _run_single_shot_container "$@"
    fi
}

# ── Console diagnostics on non-zero exit ─────────────────────────
# Prints exit code and OOM hint to stderr. Called from devcage.sh after _run_container.
#
# Args: $1 — exit code, $2 — resolved memory, $3 — memory swap (for OOM hint)
_print_exit_diagnostics() {
    local _exit_code="${1:-0}" _mem="${2:-}" _swap="${3:-}"
    [ "$_exit_code" = "0" ] && return 0
    echo "" >&2
    echo "DevCage exited with code ${_exit_code}." >&2
    case "$_exit_code" in
        130) echo "Container was interrupted (SIGINT)." >&2 ;;
        137)
            # In reuse mode, exit 137 from docker exec means the container stopped
            # while the session was attached (idle timeout, docker stop, etc.) — not
            # necessarily OOM. Check actual OOM status via docker inspect.
            local _oom_killed="unknown"
            if [ "${_REUSE_CONTAINER_ID:-}" != "" ]; then
                _oom_killed=$("$CONTAINER_ENGINE" inspect --format '{{.State.OOMKilled}}' "$_REUSE_CONTAINER_ID" 2>/dev/null || echo "unknown")
            fi
            if [ "$_oom_killed" = "true" ]; then
                echo "Container was killed (OOM — out of memory). Memory limit was ${_mem} (+swap to ${_swap})." >&2
            elif [ "$_oom_killed" = "false" ]; then
                echo "Session was terminated (container stopped). Likely cause: idle timeout or manual stop." >&2
                echo "  Tip: use --idle-timeout with a higher value, or check the session log." >&2
            else
                echo "Container was killed (likely OOM — out of memory). Memory limit was ${_mem} (+swap to ${_swap})." >&2
            fi
            ;;
        143) echo "Container was terminated (SIGTERM)." >&2 ;;
    esac
    if [ "${SESSION_LOG:-}" != "" ] && [ -f "$SESSION_LOG" ]; then
        echo "Session log: ${SESSION_LOG}" >&2
    fi
}
