# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
# shellcheck shell=bash
# shellcheck disable=SC2034  # vars set here are consumed by the sourcing script (devcage.sh)

# config.sh — Configuration hierarchy module for devcage.sh.
# Handles switches.conf parsing, profile directory resolution, and the
# two-pass config loading pattern (discover + full apply).
#
# Depends on: _valid_ipv4, _validate_pkg_list (from utils.sh).
#
# Required globals set by the orchestrator before sourcing:
#   GLOBAL_CONFIG_DIR  — path to the global config directory
#   USER_CONFIG_DIR    — path to the user config directory (may be empty if
#                        same physical dir as GLOBAL_CONFIG_DIR)
#
# Set after workspace resolution:
#   PROJECT_CONFIG_DIR — path to the project config directory (may be empty)
#
# INVARIANT: If a new accumulator variable is added to _apply_switches, it
# MUST also be saved and restored in _full_config_apply(). Currently only
# ENV_VARS and CUSTOM_VOLUMES are accumulators.

# ── Module-level globals ──────────────────────────────────────────
_defaults_files=()        # files loaded in Pass 1; used by _full_config_apply to skip duplicates
GLOBAL_CONFIG_DIR=""      # set by _init_config_dirs
USER_CONFIG_DIR=""        # set by _init_config_dirs (empty if same physical dir as global)
PROJECT_CONFIG_DIR=""     # set by _set_project_config_dir after workspace resolution
ACTIVE_PROFILES=()        # profile names activated by --profile / PROFILES_FROM_CONF
_global_real=""           # canonical real path of GLOBAL_CONFIG_DIR (for dedup)
_user_real=""             # canonical real path of USER_CONFIG_DIR (for dedup)

# ── Merge key=value config files (last value wins) ───────────────
# Portable awk-based merge (bash 3.2 compatible — no associative arrays).
# Accepts one or more file paths; outputs merged KEY=VALUE lines.
# Used for versions.conf which retains key=value format.
_merge_conf() {
    awk -F= '
        { gsub(/\r/, "") }
        /^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
        { key=$1; sub(/^[[:space:]]+/,"",key); sub(/[[:space:]]+$/,"",key)
          val=substr($0,index($0,"=")+1); sub(/^[[:space:]]+/,"",val); sub(/[[:space:]]+$/,"",val)
          if(key!=""){ data[key]=val; if(!(key in seen)){seen[key]=1;order[++n]=key} } }
        END { for(i=1;i<=n;i++) print order[i] "=" data[order[i]] }
    ' "$@"
}

# ── Parse switches.conf files ────────────────────────────────────
# Reads CLI-argument-style config lines from one or more files.
# Strips leading --/- prefix, normalizes to "name value" lines.
# Accumulator keys (env, volume) are output with a + prefix: "+env value".
# Last-wins keys output plain: "name value".
_parse_switches() {
    awk '
        BEGIN { n=0 }
        { gsub(/\r/, "") }
        /^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
        {
            gsub(/^[[:space:]]+|[[:space:]]+$/, "")
            # Strip leading -- or -
            sub(/^--/, "")
            sub(/^-/, "")
            # Handle name=value (e.g. memory=8g)
            if (index($0, "=") > 0 && match($0, /^[^ =]+=/)) {
                key = substr($0, 1, RLENGTH - 1)
                val = substr($0, RLENGTH + 1)
            } else if (NF >= 2) {
                key = $1
                val = substr($0, index($0, " ") + 1)
                gsub(/^[[:space:]]+/, "", val)
            } else {
                key = $0; val = ""
            }
            # Accumulator keys: output with + prefix (each occurrence)
            if (key == "env" || key == "e" || key == "volume" || key == "V") {
                print "+" key " " val
            } else {
                # Last-wins: overwrite
                data[key] = val
                if (!(key in seen)) { seen[key] = 1; order[++n] = key }
            }
        }
        END {
            for (i = 1; i <= n; i++) print order[i] " " data[order[i]]
        }
    ' "$@"
}

# ── Apply boolean value from switches.conf ─────────────────────
# Empty or absent value = true (flag present). "false"/"0"/"no" = off.
_switches_bool() {
    local _val="$1"
    case "$_val" in
        ""|true|1|yes) return 0 ;;  # true
        false|0|no)    return 1 ;;  # false
        *) return 0 ;;  # default: presence = true
    esac
}

# ── Profile directory matching ────────────────────────────────────
# Given a parent directory, find config.* directories whose profile
# set is a subset of ACTIVE_PROFILES. Returns matching directories
# in loading order: by profile count, then by index tuple, then dirname.
_profile_dirs() {
    local parent="$1"
    local dir p ap
    [ ${#ACTIVE_PROFILES[@]} -gt 0 ] || return 0
    for dir in "${parent}/config."*/; do
        [ -d "$dir" ] || continue
        local dirname
        dirname=$(basename "$dir")
        local profile_part="${dirname#config.}"
        # Split on dots to get individual profiles
        local -a dir_profiles
        IFS='.' read -ra dir_profiles <<< "$profile_part"
        local all_match=true sorted_keys="" count=${#dir_profiles[@]}
        for p in "${dir_profiles[@]}"; do
            local found=false idx=0
            for ap in "${ACTIVE_PROFILES[@]}"; do
                if [ "$ap" = "$p" ]; then
                    found=true; sorted_keys="${sorted_keys}$(printf '%04d' "$idx")"; break
                fi
                idx=$(( idx + 1 ))
            done
            if ! "$found"; then all_match=false; break; fi
        done
        if "$all_match"; then
            # Sort indices for canonical ordering, then use as sort key
            local canonical
            canonical=$(echo "$sorted_keys" | fold -w4 | sort | tr -d '\n')
            printf '%02d %s %s\n' "$count" "$canonical" "${dir%/}"
        fi
    done | sort | sed 's/^[^ ]* [^ ]* //'
}

# ── Collect config files across a scope (base + profiles) ────────
# Args: $1 = scope config dir, $2 = filename (e.g. switches.conf)
_scope_files() {
    local scope_dir="$1" filename="$2"
    [ "$scope_dir" != "" ] || return 0    # skip if deduped
    [ -f "${scope_dir}/${filename}" ] && printf '%s\n' "${scope_dir}/${filename}"
    if [ ${#ACTIVE_PROFILES[@]} -gt 0 ]; then
        local parent
        parent=$(dirname "$scope_dir")
        while IFS= read -r pdir; do
            [ -f "${pdir}/${filename}" ] && printf '%s\n' "${pdir}/${filename}"
        done < <(_profile_dirs "$parent")
    fi
}

# ── Validate a profile name ──────────────────────────────────────
_validate_profile_name() {
    local _name="$1"
    if ! [[ "$_name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
        echo "Error: invalid profile name '${_name}' (must match [a-zA-Z0-9][a-zA-Z0-9_-]*)" >&2
        exit 1
    fi
}

# ── Apply parsed switches from stdin ──────────────────────────────
# Reads "name value" lines from _parse_switches output.
# Accumulator lines start with "+". Shared by both passes.
# Args: $1 = source label (for warnings), $2 = "skip_workspace" to skip path
_apply_switches() {
    local _src_label="${1:-switches.conf}" _skip_ws="${2:-}"
    while IFS= read -r _line; do
        [ "$_line" = "" ] && continue
        local _is_accum=0
        # Accumulator lines start with "+"
        if [[ "$_line" == +* ]]; then
            _is_accum=1
            _line="${_line#"+"}"
        fi
        # Split on first space
        local _name="${_line%% *}"
        local _val=""
        if [[ "$_line" == *" "* ]]; then
            _val="${_line#* }"
        fi
        # Reject values containing shell metacharacters to prevent injection.
        if [[ "$_val" =~ [\`\$\(\)\{\}\|\<\>\&\!\\\"\'\[\;\~\#] ]] || [[ "$_val" == *$'\n'* ]] || [[ "$_val" == *$'\r'* ]]; then
            echo "Warning: rejecting '${_name}' — value contains shell metacharacters" >&2
            continue
        fi
        case "$_name" in
            memory)                  MEMORY_LIMIT="$_val" ;;
            cpus)                    CPU_LIMIT="$_val" ;;
            embed-docker)            if _switches_bool "$_val"; then EMBED_DOCKER=1; else EMBED_DOCKER=0; fi ;;
            no-cache-volumes|ncv)    if _switches_bool "$_val"; then NO_CACHE_VOLUMES=1; else NO_CACHE_VOLUMES=0; fi ;;
            host-agents-config|hac)  case "$_val" in
                                        ro|rw|no|disabled) HOST_AGENTS_CONFIG="$_val" ;;
                                        *)  if _switches_bool "$_val"; then HOST_AGENTS_CONFIG="rw"; fi ;;
                                     esac ;;
            editable-config)         if _switches_bool "$_val"; then HOST_AGENTS_CONFIG="rw"; else HOST_AGENTS_CONFIG="ro"; fi ;;
            apt-packages|apt)
                if ! _validate_pkg_list "$_val" '^[a-zA-Z0-9][a-zA-Z0-9.+-]*(:[a-zA-Z0-9]+)?$' "apt (${_src_label})"; then continue; fi
                APT_PACKAGES="$_val" ;;
            brew-packages|brew)
                if ! _validate_pkg_list "$_val" '^[a-zA-Z0-9][a-zA-Z0-9_./-]*$' "brew (${_src_label})"; then continue; fi
                BREW_PACKAGES="$_val" ;;
            snap-packages|snap)
                if ! _validate_pkg_list "$_val" '^[a-z][a-z0-9-]*$' "snap (${_src_label})"; then continue; fi
                SNAP_PACKAGES="$_val" ;;
            port|ports)              PORT_LIST="$_val" ;;
            bind)                    if _valid_ipv4 "$_val"; then
                                        BIND_ADDR="$_val"
                                        if [ "$_val" = "0.0.0.0" ]; then
                                            echo "WARNING: bind 0.0.0.0 in ${_src_label} will expose forwarded ports to ALL network interfaces (LAN-accessible)." >&2
                                        fi
                                    else
                                        echo "Warning: invalid bind address '${_val}' in ${_src_label}; ignoring" >&2
                                    fi ;;
            dns)                     if [ "$_val" != "" ]; then
                                        local _dns_ok=1
                                        IFS=',' read -ra _dns_items <<< "$_val"
                                        for _dns_ip in "${_dns_items[@]}"; do
                                            _dns_ip="$(echo "$_dns_ip" | tr -d '[:space:]')"
                                            if ! _valid_ipv4 "$_dns_ip"; then
                                                echo "Warning: invalid DNS server '${_dns_ip}' in ${_src_label}; ignoring entire dns setting" >&2
                                                _dns_ok=0; break
                                            fi
                                        done
                                        [ "$_dns_ok" = "1" ] && DNS_SERVERS="$_val"
                                    fi ;;
            script|s)                if [ "$_skip_ws" = "skip_workspace" ]; then
                                        echo "Warning: script in project-level config ignored (requires --yolo-config)" >&2
                                    else
                                        [ "$_val" != "" ] && EXTRA_SCRIPT_SCRIPT="$_val"
                                    fi ;;
            yolo-config|yc)          if [ "$_skip_ws" = "skip_workspace" ]; then
                                        : # Silently ignore — project-level cannot auto-approve its own config
                                    else
                                        if _switches_bool "$_val"; then YOLO_CONFIG=1; fi
                                    fi ;;
            skip-build)              if _switches_bool "$_val"; then SKIP_BUILD=1; fi ;;
            gpu)                     case "$_val" in
                                        nvidia|amd) GPU_TYPE="$_val"; NO_GPU=0 ;;
                                        false|0|no) NO_GPU=1 ;;
                                        true|1|yes|auto|"") GPU_TYPE="auto"; NO_GPU=0 ;;
                                     esac ;;
            no-gpu)                  if _switches_bool "$_val"; then NO_GPU=1; fi ;;
            git-auth|git)            if _switches_bool "$_val"; then GIT_AUTH=1; else GIT_AUTH=0; fi ;;
            verbose)                 if _switches_bool "$_val"; then VERBOSE=1; fi ;;
            skip-sandbox-warning)    if _switches_bool "$_val"; then SKIP_SANDBOX_WARNING=1; fi ;;
            profiles|profile|p)      if [ "$_skip_ws" != "skip_workspace" ]; then
                                        [ "$_val" != "" ] && PROFILES_FROM_CONF="$_val"
                                    fi ;;
            path)                    if [ "$_skip_ws" = "skip_workspace" ]; then
                                        echo "Warning: path in project-level switches.conf is ignored (circular)" >&2
                                    else
                                        [ "$_val" != "" ] && WORKSPACE_PATH="$_val"
                                    fi ;;
            log-keep)                if [ "$_val" != "" ]; then
                                        if [[ "$_val" =~ ^[0-9]+$ ]] && [ "$_val" -ge 1 ]; then
                                            LOG_KEEP="$_val"
                                        else
                                            echo "Warning: ignoring invalid log-keep value '${_val}' in ${_src_label} — must be a positive integer" >&2
                                        fi
                                    fi ;;
            env|e)                   if [ "$_val" != "" ]; then
                                        if [[ ! "$_val" =~ ^[a-zA-Z_][a-zA-Z0-9_]*= ]]; then
                                            echo "Warning: ignoring invalid env entry '${_val}' in ${_src_label}" >&2
                                        else
                                            ENV_VARS+=("$_val")
                                        fi
                                    fi ;;
            no-reuse)                if _switches_bool "$_val"; then NO_REUSE=1; else NO_REUSE=0; fi ;;
            idle-timeout)            if [ "$_val" != "" ] && [[ "$_val" =~ ^[0-9]+$ ]]; then
                                        IDLE_TIMEOUT="$_val"
                                    else
                                        echo "Warning: ignoring invalid idle-timeout value '${_val}' in ${_src_label}" >&2
                                    fi ;;
            cache-dirs)              CACHE_DIRS="$_val" ;;
            lang|langs)              LANGS="$_val" ;;
            volume|V)                [ "$_val" != "" ] && CUSTOM_VOLUMES+=("$_val") ;;
            agent)                   [ "${_LAUNCH_AGENT:-}" = "" ] && _LAUNCH_AGENT="$_val" ;;
            agent-args)              : ;; # GUI-only; launcher gets agent-args as positional CLI args
            browse)                  : ;; # Windows-only, silently ignored
            *)                       echo "Warning: unknown switch '${_name}' in ${_src_label}" >&2 ;;
        esac
    done
}

# ── Pass 1: discover global + user base config ────────────────────
# Loads global and user-level switches.conf to set scalar defaults,
# including WORKSPACE_PATH and PROFILES_FROM_CONF, before the workspace
# path is resolved. Stores the loaded file list in _defaults_files for
# use by _full_config_apply to avoid re-processing them.
#
# Called BEFORE CLI argument parsing so that CLI flags naturally win
# by overwriting the scalars set here.
_discover_config() {
    _defaults_files=()
    [ -f "${GLOBAL_CONFIG_DIR}/switches.conf" ] && _defaults_files+=("${GLOBAL_CONFIG_DIR}/switches.conf")
    [ -f "${USER_CONFIG_DIR}/switches.conf" ]   && _defaults_files+=("${USER_CONFIG_DIR}/switches.conf")
    [ ${#_defaults_files[@]} -gt 0 ] && \
        _apply_switches "switches.conf (global+user)" < <(_parse_switches "${_defaults_files[@]}")
}

# ── Pass 2: full config apply with accumulator save/restore ──────
# Processes profile variants and project-level switches.conf files —
# i.e., only the files NOT already processed in Pass 1 (_defaults_files).
# CLI accumulator entries (ENV_VARS, CUSTOM_VOLUMES) are saved before the
# pass and re-appended afterwards so that CLI always wins for accumulators.
#
# Called AFTER CLI argument parsing and workspace resolution (so that
# PROJECT_CONFIG_DIR and ACTIVE_PROFILES are known).
#
# INVARIANT: Any new accumulator variable added to _apply_switches MUST
# be saved and restored here.
_full_config_apply() {
    # Save CLI accumulator entries (set before this function is called)
    local _cli_env_vars=("${ENV_VARS[@]+"${ENV_VARS[@]}"}")
    local _cli_custom_volumes=("${CUSTOM_VOLUMES[@]+"${CUSTOM_VOLUMES[@]}"}")
    ENV_VARS=()
    CUSTOM_VOLUMES=()

    # Build file list: profile variants + project level (skip files already in _defaults_files)
    local _files=() _f _already _old
    for _scope_dir in "$GLOBAL_CONFIG_DIR" "$USER_CONFIG_DIR"; do
        [ "$_scope_dir" != "" ] || continue
        while IFS= read -r _f; do
            _already=0
            for _old in "${_defaults_files[@]+"${_defaults_files[@]}"}"; do
                [ "$_f" = "$_old" ] && _already=1 && break
            done
            [ "$_already" = "0" ] && _files+=("$_f")
        done < <(_scope_files "$_scope_dir" "switches.conf")
    done
    if [ "${PROJECT_CONFIG_DIR:-}" != "" ]; then
        while IFS= read -r _f; do _files+=("$_f"); done \
            < <(_scope_files "$PROJECT_CONFIG_DIR" "switches.conf")
    fi
    [ ${#_files[@]} -gt 0 ] && \
        _apply_switches "switches.conf (merged)" "skip_workspace" < <(_parse_switches "${_files[@]}")

    # Re-append CLI entries (CLI always wins for accumulators)
    ENV_VARS+=("${_cli_env_vars[@]+"${_cli_env_vars[@]}"}")
    CUSTOM_VOLUMES+=("${_cli_custom_volumes[@]+"${_cli_custom_volumes[@]}"}")
}

# ── Initialize config directory paths ─────────────────────────────
# Sets GLOBAL_CONFIG_DIR, USER_CONFIG_DIR (+ canonical paths for dedup).
# Deduplicates if they resolve to the same physical directory (e.g. when
# running DevCage from its own install directory).
# Must be called before _discover_config and _full_config_apply.
# Reads: SCRIPT_DIR, HOME
# Writes: GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, _global_real, _user_real
_init_config_dirs() {
    GLOBAL_CONFIG_DIR="${SCRIPT_DIR}/config"
    USER_CONFIG_DIR="${HOME}/.devcage/config"
    _global_real=""
    _user_real=""
    [ -d "$GLOBAL_CONFIG_DIR" ] && _global_real="$(cd "$GLOBAL_CONFIG_DIR" && pwd -P)"
    [ -d "$USER_CONFIG_DIR" ]   && _user_real="$(cd "$USER_CONFIG_DIR" && pwd -P)"
    if [ "$_global_real" != "" ] && [ "$_global_real" = "$_user_real" ]; then
        USER_CONFIG_DIR=""
        _user_real=""
    fi
}

# ── Set project config directory (with dedup) ─────────────────────
# Sets PROJECT_CONFIG_DIR based on WORKSPACE; clears it if it resolves to
# the same physical path as the global or user config directory.
# Must be called AFTER _resolve_workspace() and _init_config_dirs().
# Reads: WORKSPACE, _global_real, _user_real
# Writes: PROJECT_CONFIG_DIR
_set_project_config_dir() {
    PROJECT_CONFIG_DIR="${WORKSPACE}/.devcage/config"
    local _project_real=""
    [ -d "$PROJECT_CONFIG_DIR" ] && _project_real="$(cd "$PROJECT_CONFIG_DIR" && pwd -P)"
    if [ "$_project_real" != "" ]; then
        if [ "$_project_real" = "${_global_real:-}" ] || [ "$_project_real" = "${_user_real:-}" ]; then
            PROJECT_CONFIG_DIR=""
        fi
    fi
}

# ── Resolve active profiles after CLI argument parsing ─────────────
# CLI --profile/--profiles takes precedence over PROFILES_FROM_CONF
# set by switches.conf. Deduplicates while preserving order.
# Reads: CLI_PROFILES, PROFILES_FROM_CONF
# Writes: ACTIVE_PROFILES
_resolve_active_profiles() {
    if [ ${#CLI_PROFILES[@]} -gt 0 ]; then
        ACTIVE_PROFILES=("${CLI_PROFILES[@]}")
    elif [ "$PROFILES_FROM_CONF" != "" ]; then
        IFS=',' read -ra ACTIVE_PROFILES <<< "$PROFILES_FROM_CONF"
        local _i
        for _i in "${!ACTIVE_PROFILES[@]}"; do
            ACTIVE_PROFILES[_i]="$(echo "${ACTIVE_PROFILES[_i]}" | tr -d '[:space:]')"
            _validate_profile_name "${ACTIVE_PROFILES[_i]}"
        done
    fi
    # Deduplicate while preserving order
    if [ ${#ACTIVE_PROFILES[@]} -gt 0 ]; then
        local _deduped=() _seen_p=" " _p
        for _p in "${ACTIVE_PROFILES[@]}"; do
            case "$_seen_p" in
                *" ${_p} "*) ;;
                *) _deduped+=("$_p"); _seen_p="${_seen_p}${_p} " ;;
            esac
        done
        ACTIVE_PROFILES=("${_deduped[@]}")
        _info "Active profiles: $(printf '%s,' "${ACTIVE_PROFILES[@]}" | sed 's/,$//')"
    fi
}

# ── Resolve workspace path ──────────────────────────────────────────
# Sets WORKSPACE from WORKSPACE_PATH or $PWD (normalized via cd+pwd).
# Prompts interactively if running from DevCage's own directory.
# Reads: WORKSPACE_PATH, SCRIPT_DIR, HOME
# Writes: WORKSPACE (exported)
_resolve_workspace() {
    export WORKSPACE
    if [ "$WORKSPACE_PATH" != "" ]; then
        WORKSPACE_PATH="$(_normalize_workspace_path "$WORKSPACE_PATH")"
        WORKSPACE="$WORKSPACE_PATH"
    else
        # Normalize $PWD through cd+pwd to canonicalize drive-letter format on Windows
        # (e.g. C:/Shared/code/DevCage → /c/Shared/code/DevCage), ensuring a consistent
        # WORKSPACE value across invocations and preventing duplicate containers.
        WORKSPACE="$(_normalize_workspace_path "$PWD")"
    fi

    # Self-directory detection: warn if running from DevCage source dir with no command.
    # Suppressed when a command is given — the user knows what they're running.
    if [ "$WORKSPACE_PATH" = "" ] && [ "${_HAS_CMD:-0}" = "0" ] && \
       [ "$(realpath "$WORKSPACE" 2>/dev/null || echo "$WORKSPACE")" = "$(realpath "$SCRIPT_DIR" 2>/dev/null || echo "$SCRIPT_DIR")" ]; then
        _info ""
        _warn "You are running DevCage from its own directory."
        _info "  ${WORKSPACE}"
        _info ""
        _info "This will mount the DevCage source tree as /workspace in the container."
        _info "You probably want to specify a project directory instead."
        _info ""
        _info "Tip: devcage --path ~/my-project [COMMAND]"
        _info ""
        if [ -t 0 ] && [ -t 1 ] && [ "${_QUIET:-0}" = "0" ]; then
            local _user_path
            while true; do
                read -r -p "Enter project path (or press Enter to continue anyway): " _user_path < /dev/tty
                echo "" >&2
                if [ "$_user_path" = "" ]; then
                    break
                fi
                case "$_user_path" in
                    "~"/*) _user_path="${HOME}${_user_path#"~"}" ;;
                    "~")   _user_path="$HOME" ;;
                esac
                if [[ "$_user_path" != /* ]]; then
                    _user_path="$(cd "$SCRIPT_DIR" && cd "$_user_path" 2>/dev/null && pwd)" || {
                        echo "  Directory not found. Try again." >&2; continue
                    }
                fi
                if [ ! -d "$_user_path" ]; then
                    echo "  '${_user_path}' is not a directory. Try again." >&2; continue
                fi
                WORKSPACE="$_user_path"
                echo "Using workspace: ${WORKSPACE}" >&2
                break
            done
        fi
    fi
}

# ── Compute workspace slug and container name ───────────────────────
# Sets WORKSPACE_SLUG (basename-derived with hash suffix to avoid collisions),
# COMPOSE_PROJECT_NAME, and CONTAINER_NAME (path-based, left-truncated to 55 chars).
# Reads: WORKSPACE, HOME
# Writes: WORKSPACE_BASENAME, WORKSPACE_SLUG (exported), COMPOSE_PROJECT_NAME (exported),
#         CONTAINER_NAME
_compute_workspace_slug() {
    WORKSPACE_BASENAME="$(basename "$WORKSPACE")"
    WORKSPACE_SLUG="$(printf '%s' "$WORKSPACE_BASENAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/^-*//; s/-*$//')"
    WORKSPACE_SLUG="${WORKSPACE_SLUG:0:54}"  # 54 + 1 dash + 8 hash = 63 (Docker hostname limit)
    local _ws_hash
    _ws_hash="$(printf '%s' "$WORKSPACE" | _sha256 | cut -c1-8)"
    WORKSPACE_SLUG="${WORKSPACE_SLUG:-default}-${_ws_hash}"
    export WORKSPACE_SLUG
    export COMPOSE_PROJECT_NAME="devcage_${WORKSPACE_SLUG}"

    # Human-readable container name based on relative workspace path.
    # Label-based discovery is authoritative; name is cosmetic for `docker ps`.
    local _rel _container_slug
    _rel="${WORKSPACE#"$HOME"/}"
    _rel="${_rel#/}"
    _container_slug="$(printf '%s' "$_rel" | tr '[:upper:]' '[:lower:]' \
        | sed 's|/|-|g; s/[^a-z0-9-]/_/g; s/-\{2,\}/-/g; s/^[-_]//; s/[-_]$//')"
    # Truncate from LEFT to fit hostname limit (63 chars): devcage- (8) + slug ≤ 55
    if [ ${#_container_slug} -gt 55 ]; then
        _container_slug="${_container_slug: -55}"
        _container_slug="${_container_slug#[-_]}"   # strip leading separator after truncation
    fi
    CONTAINER_NAME="devcage-${_container_slug}"
}

# ── Load and merge versions.conf from all config levels ────────────
# Reads versions.conf files from global + user + project (with profile variants).
# Populates VERSION_BUILD_ARGS (--build-arg pairs for docker build).
# Also injects --lang build args and handles DIND_VERSION (runtime-only) specially.
# Must be called AFTER _full_config_apply() so PROJECT_CONFIG_DIR is set.
# Reads: GLOBAL_CONFIG_DIR, USER_CONFIG_DIR, PROJECT_CONFIG_DIR, LANGS
# Writes: VERSION_BUILD_ARGS, DIND_IMAGE (exported, DIND_VERSION only)
_load_versions() {
    # _versions_files and _merged_versions are non-local: read by _build_versions_volume
    _versions_files=(); _merged_versions=""; local _f
    while IFS= read -r _f; do _versions_files+=("$_f"); done < <(_scope_files "$GLOBAL_CONFIG_DIR" "versions.conf")
    if [ "${USER_CONFIG_DIR:-}" != "" ]; then
        while IFS= read -r _f; do _versions_files+=("$_f"); done < <(_scope_files "$USER_CONFIG_DIR" "versions.conf")
    fi
    if [ "${PROJECT_CONFIG_DIR:-}" != "" ]; then
        while IFS= read -r _f; do _versions_files+=("$_f"); done < <(_scope_files "$PROJECT_CONFIG_DIR" "versions.conf")
    fi
    VERSION_BUILD_ARGS=()
    if [ ${#_versions_files[@]} -gt 0 ]; then
        local _key _val
        _merged_versions="$(_merge_conf "${_versions_files[@]}")"
        while IFS='=' read -r _key _val; do
            [ "$_key" = "" ] && continue
            # Reject legacy DEV_* flags — use --lang instead
            if [[ "$_key" =~ ^DEV_ ]]; then
                echo "Warning: '${_key}' in versions.conf is no longer supported — use --lang flag instead" >&2
                continue
            fi
            if [ "$_val" != "" ]; then
                if [[ "$_val" =~ ^[a-zA-Z0-9._+:@/-]+$ ]]; then
                    # DIND_VERSION is runtime-only (not a Dockerfile ARG)
                    if [ "$_key" = "DIND_VERSION" ]; then
                        export DIND_IMAGE="docker:${_val}-dind"
                    else
                        VERSION_BUILD_ARGS+=(--build-arg "${_key}=${_val}")
                    fi
                else
                    echo "Warning: ignoring versions.conf entry '${_key}' — value contains invalid characters" >&2
                fi
            fi
        done <<< "$_merged_versions"
    fi
    # Inject --lang build args
    if [ "${LANGS:-}" != "" ]; then
        local _larg _larg_parts
        while IFS= read -r _larg; do
            read -ra _larg_parts <<< "$_larg"; VERSION_BUILD_ARGS+=("${_larg_parts[@]}")
        done < <(_lang_build_args "$LANGS")
    fi
}

# ── Resolve image name (shared vs workspace-specific) ──────────────
# Uses the shared "devcage-workspace" image unless the project pins build-time
# version keys in versions.conf or overrides lang in switches.conf, in which
# case a workspace-specific image is used to avoid cross-project conflicts.
# Must be called AFTER _full_config_apply() and _compute_workspace_slug().
# Reads: PROJECT_CONFIG_DIR, WORKSPACE_SLUG, DEVCAGE_IMAGE (may be pre-set)
# Writes: DEVCAGE_IMAGE (exported; only when switching to workspace-specific)
_resolve_image_name() {
    local _BUILD_TIME_KEYS="UBUNTU_TAG"
    _BUILD_TIME_KEYS="${_BUILD_TIME_KEYS}|NODE_VERSION|JDK_VERSION|MAVEN_VERSION|GRADLE_VERSION|GO_VERSION|RUST_VERSION|DOTNET_VERSION"
    _BUILD_TIME_KEYS="${_BUILD_TIME_KEYS}|CMAKE_VERSION|NINJA_VERSION|CCACHE_VERSION|YQ_VERSION|SHFMT_VERSION|SHELLHARDEN_VERSION"
    _BUILD_TIME_KEYS="${_BUILD_TIME_KEYS}|PLAYWRIGHT_VERSION|PLAYWRIGHT_MCP_VERSION"
    if [ "${DEVCAGE_IMAGE:-}" = "" ]; then
        local _has_build_override=0 _pvf _swf
        # Check project versions.conf (base + profiles) for build-time version overrides
        if [ "${PROJECT_CONFIG_DIR:-}" != "" ]; then
            while IFS= read -r _pvf; do
                if grep -qE "^(${_BUILD_TIME_KEYS})=" "$_pvf" 2>/dev/null; then
                    _has_build_override=1; break
                fi
            done < <(_scope_files "$PROJECT_CONFIG_DIR" "versions.conf")
        fi
        # Check project switches.conf for lang/langs override (affects build image)
        if [ "${PROJECT_CONFIG_DIR:-}" != "" ] && [ "$_has_build_override" = "0" ]; then
            while IFS= read -r _swf; do
                if grep -qiE "^(lang|langs)\b" "$_swf" 2>/dev/null; then
                    _has_build_override=1; break
                fi
            done < <(_scope_files "$PROJECT_CONFIG_DIR" "switches.conf")
        fi
        if [ "$_has_build_override" = "1" ]; then
            export DEVCAGE_IMAGE="devcage-workspace-${WORKSPACE_SLUG}"
            _info "Project has build-time version overrides → using workspace-specific image: ${DEVCAGE_IMAGE}"
        fi
        # Otherwise DEVCAGE_IMAGE stays unset → docker-compose.yml defaults to "devcage-workspace"
    fi
}
