#!/bin/bash
# ============================================================================
# bootstrap-upgrade.sh — Background Model Download + Auto Hot-Swap
# ============================================================================
# Runs in the background after the installer starts services with the
# bootstrap model. Downloads the full tier-appropriate model, then swaps
# llama-server to the new model with minimal downtime.
#
# Usage (called by phase 11, not directly by users):
#   nohup bash bootstrap-upgrade.sh \
#       <install_dir> <gguf_file> <gguf_url> <gguf_sha256> \
#       <llm_model> <max_context> [<bootstrap_gguf_file>] \
#       > logs/model-upgrade.log 2>&1 &
#
# Arg 7 (bootstrap_gguf_file) is optional and defaults to the historical
# Qwen3.5-2B-Q4_K_M.gguf for backwards compatibility. Phase 11 must pass the
# canonical $BOOTSTRAP_GGUF_FILE from installers/lib/bootstrap-model.sh so the
# Phase 4b cleanup step removes the actual bootstrap model after hot-swap.
#
# On failure: logs the error, preserves any .part download for resume, and
# exits. The bootstrap model continues running; `ods start`, `ods restart`,
# or re-running the installer retries the full-model upgrade.
# ============================================================================

set -uo pipefail
# Note: no set -e — we handle errors explicitly to avoid killing the
# background process on transient failures.

# ── Arguments ──
INSTALL_DIR="$1"
FULL_GGUF_FILE="$2"
FULL_GGUF_URL="$3"
FULL_GGUF_SHA256="$4"
FULL_LLM_MODEL="$5"
FULL_MAX_CONTEXT="$6"
BOOTSTRAP_GGUF_FILE="${7:-Qwen3.5-2B-Q4_K_M.gguf}"

LOG_TAG="[BOOTSTRAP-UPGRADE]"

log()  { echo "$LOG_TAG $(date '+%H:%M:%S') $*"; }
fail() { log "ERROR: $*"; release_upgrade_lock; exit 1; }

if [[ -z "$INSTALL_DIR" || ! -d "$INSTALL_DIR" ]]; then
    log "ERROR: install directory does not exist: ${INSTALL_DIR:-<empty>}"
    exit 1
fi
if ! INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd -P)"; then
    log "ERROR: could not resolve install directory: $INSTALL_DIR"
    exit 1
fi
cd "$INSTALL_DIR" || {
    log "ERROR: could not enter install directory: $INSTALL_DIR"
    exit 1
}

MODELS_DIR="$INSTALL_DIR/data/models"
ENV_FILE="$INSTALL_DIR/.env"
MODELS_INI="$INSTALL_DIR/config/llama-server/models.ini"
STATUS_FILE="$INSTALL_DIR/data/bootstrap-status.json"
UPGRADE_LOCK_DIR=""

# Cross-platform file size (GNU stat on Linux/WSL2, BSD stat on macOS)
# IMPORTANT: Try GNU stat -c %s FIRST (Linux). stat -f on Linux returns filesystem
# block count (not file size). BSD stat -f %z is the macOS fallback.
file_size() {
    if stat -c %s "$1" 2>/dev/null; then
        return
    fi
    stat -f %z "$1" 2>/dev/null || echo 0
}

# Get total size via HTTP HEAD request
get_remote_size() {
    local url="$1"
    curl -sI -L --connect-timeout 10 "$url" 2>/dev/null \
        | grep -i '^content-length:' | tail -1 | tr -dc '0-9'
}

# Write status JSON (atomic via mv)
write_status() {
    local status="$1" percent="${2:-}" downloaded="${3:-0}" total="${4:-0}" speed="${5:-0}" eta="${6:-}"
    local _safe_model="${FULL_GGUF_FILE//\"/\\\"}"
    cat > "$STATUS_FILE.tmp" << STATUSEOF
{
  "status": "$status",
  "model": "$_safe_model",
  "percent": ${percent:-null},
  "bytesDownloaded": $downloaded,
  "bytesTotal": $total,
  "speedBytesPerSec": $speed,
  "eta": "${eta:-}",
  "updatedAt": "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%dT%H:%M:%SZ')"
}
STATUSEOF
    mv "$STATUS_FILE.tmp" "$STATUS_FILE"
}

status_percent() {
    local downloaded="${1:-0}" total="${2:-0}"
    if [[ "$total" -le 0 ]]; then
        echo ""
        return
    fi
    local display_bytes="$downloaded"
    [[ "$display_bytes" -lt 0 ]] && display_bytes=0
    [[ "$display_bytes" -gt "$total" ]] && display_bytes="$total"
    awk "BEGIN { printf \"%.1f\", ($display_bytes / $total) * 100 }"
}

write_failed_download_status() {
    local part_file="${1:-}" total="${2:-0}" message="${3:-}"
    local downloaded=0 percent=""
    if [[ -n "$part_file" && -f "$part_file" ]]; then
        downloaded=$(file_size "$part_file")
    fi
    percent=$(status_percent "$downloaded" "$total")
    write_status "failed" "$percent" "$downloaded" "$total" 0 "$message"
}

compose_recreate_llama_server_with_retry() {
    local -a compose_args=("$@")
    local max_attempts="${ODS_BOOTSTRAP_COMPOSE_RETRY_ATTEMPTS:-3}"
    if ! [[ "$max_attempts" =~ ^[0-9]+$ ]] || (( max_attempts < 1 )); then
        max_attempts=3
    fi

    local attempt=1
    local retries=$(( max_attempts - 1 ))
    local output rc
    while true; do
        output=$(env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
            $DOCKER_COMPOSE_CMD "${compose_args[@]}" up -d --force-recreate --no-deps llama-server 2>&1)
        rc=$?
        [[ -n "$output" ]] && printf '%s\n' "$output"
        (( rc == 0 )) && return 0

        if (( attempt >= max_attempts )) || \
           ! grep -Eiq 'dependency failed to start|No such container|container .* (exited|is unhealthy)|service .* failed to start|failed to start.*container' <<<"$output"; then
            return "$rc"
        fi

        log "Docker reported a transient llama-server recreate failure; waiting briefly and retrying (${attempt}/${retries})."
        sleep "${ODS_BOOTSTRAP_COMPOSE_RETRY_DELAY:-15}"
        attempt=$(( attempt + 1 ))
    done
}

release_upgrade_lock() {
    if [[ -n "${UPGRADE_LOCK_DIR:-}" && -d "$UPGRADE_LOCK_DIR" ]]; then
        rm -rf "$UPGRADE_LOCK_DIR"
    fi
    UPGRADE_LOCK_DIR=""
}

acquire_upgrade_lock() {
    local tmp_root="${TMPDIR:-/tmp}"
    local lock_key
    lock_key="$(printf '%s\0%s' "$INSTALL_DIR" "$FULL_GGUF_FILE" | cksum | awk '{print $1}')"
    local lock_dir="$tmp_root/ods-bootstrap-upgrade-${lock_key}.lock"
    local pid_file="$lock_dir/pid"
    local existing_pid=""

    mkdir -p "$(dirname "$lock_dir")"
    while ! mkdir "$lock_dir" 2>/dev/null; do
        existing_pid=""
        if [[ -f "$pid_file" ]]; then
            existing_pid="$(tr -dc '0-9' < "$pid_file" 2>/dev/null || true)"
        fi

        if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
            log "Another bootstrap model upgrade is already running (pid $existing_pid); leaving it in control."
            if [[ ! -f "$STATUS_FILE" ]]; then
                write_status "downloading" "" 0 0 0 "Another bootstrap model upgrade is already running."
            fi
            exit 0
        fi

        log "Removing stale bootstrap model upgrade lock: $lock_dir"
        rm -rf "$lock_dir"
    done

    UPGRADE_LOCK_DIR="$lock_dir"
    printf '%s\n' "$$" > "$pid_file"
    trap 'release_upgrade_lock' EXIT
}

model_sha256() {
    local path="$1"
    if command -v sha256sum &>/dev/null; then
        sha256sum "$path" 2>/dev/null | awk '{print $1}'
    elif command -v shasum &>/dev/null; then
        shasum -a 256 "$path" 2>/dev/null | awk '{print $1}'
    else
        return 2
    fi
}

verify_model_integrity() {
    local path="$1" actual_hash
    [[ -n "$FULL_GGUF_SHA256" ]] || return 0

    actual_hash="$(model_sha256 "$path")"
    case "$?" in
        0) ;;
        2)
            log "WARNING: No checksum tool available — skipping SHA256 verification"
            return 0
            ;;
        *)
            log "Could not compute SHA256 for $path"
            return 1
            ;;
    esac

    if [[ -z "$actual_hash" ]]; then
        log "Could not compute SHA256 for $path"
        return 1
    fi
    if [[ "$actual_hash" != "$FULL_GGUF_SHA256" ]]; then
        log "SHA256 mismatch (expected: $FULL_GGUF_SHA256, got: $actual_hash)."
        return 1
    fi

    log "SHA256 verified"
    return 0
}

ACTIVE_CONFIG_SNAPSHOT_DIR=""

snapshot_active_model_config() {
    local snapshot_base
    snapshot_base="${INSTALL_DIR}/data"
    mkdir -p "$snapshot_base" 2>/dev/null || return 1
    ACTIVE_CONFIG_SNAPSHOT_DIR="$(mktemp -d "${snapshot_base}/bootstrap-upgrade-active-config.XXXXXX" 2>/dev/null || true)"
    [[ -n "$ACTIVE_CONFIG_SNAPSHOT_DIR" ]] || return 1

    if [[ -f "$ENV_FILE" ]]; then
        cp -p "$ENV_FILE" "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" || return 1
    else
        : > "$ACTIVE_CONFIG_SNAPSHOT_DIR/env.missing"
    fi

    if [[ -f "$MODELS_INI" ]]; then
        mkdir -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server" || return 1
        cp -p "$MODELS_INI" "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" || return 1
    else
        : > "$ACTIVE_CONFIG_SNAPSHOT_DIR/models.ini.missing"
    fi
}

restore_active_model_config() {
    [[ -n "${ACTIVE_CONFIG_SNAPSHOT_DIR:-}" && -d "$ACTIVE_CONFIG_SNAPSHOT_DIR" ]] || return 1

    if [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" ]]; then
        cp -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/env" "$ENV_FILE" || return 1
    elif [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/env.missing" ]]; then
        rm -f "$ENV_FILE"
    fi

    if [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" ]]; then
        mkdir -p "$(dirname "$MODELS_INI")" || return 1
        cp -p "$ACTIVE_CONFIG_SNAPSHOT_DIR/config-llama-server/models.ini" "$MODELS_INI" || return 1
    elif [[ -f "$ACTIVE_CONFIG_SNAPSHOT_DIR/models.ini.missing" ]]; then
        rm -f "$MODELS_INI"
    fi

    rm -rf "$ACTIVE_CONFIG_SNAPSHOT_DIR"
    ACTIVE_CONFIG_SNAPSHOT_DIR=""
}

discard_active_model_config_snapshot() {
    if [[ -n "${ACTIVE_CONFIG_SNAPSHOT_DIR:-}" ]]; then
        rm -rf "$ACTIVE_CONFIG_SNAPSHOT_DIR"
        ACTIVE_CONFIG_SNAPSHOT_DIR=""
    fi
}

restore_docker_llama_server_after_swap_failure() {
    local health_url="${1:-}"
    local compose_arg_count=0
    local rollback_healthy=false

    log "Restoring previous active model config after Docker llama-server swap failure..."
    if ! restore_active_model_config; then
        log "WARNING: could not restore previous active model config; inspect $ENV_FILE and $MODELS_INI"
        return 1
    fi

    if declare -p COMPOSE_ARGS >/dev/null 2>&1; then
        compose_arg_count=${#COMPOSE_ARGS[@]}
    fi
    if [[ "$compose_arg_count" -eq 0 || -z "${DOCKER_COMPOSE_CMD:-}" ]]; then
        log "WARNING: cannot recreate llama-server with previous config because compose flags are unavailable."
        return 1
    fi

    log "Recreating llama-server with previous active model config..."
    if ! compose_recreate_llama_server_with_retry "${COMPOSE_ARGS[@]}"; then
        log "WARNING: rollback recreate failed; inspect docker logs ods-llama-server"
        return 1
    fi

    [[ -n "$health_url" ]] || return 0
    log "Waiting for restored llama-server health at $health_url ..."
    for _rollback_i in $(seq 1 60); do
        if curl -sf --max-time 5 "$health_url" >/dev/null 2>&1; then
            rollback_healthy=true
            break
        fi
        sleep 5
    done

    if [[ "$rollback_healthy" == "true" ]]; then
        log "Rollback complete: llama-server is healthy with the previous active model config."
        return 0
    fi

    log "WARNING: rollback config was restored, but llama-server did not become healthy within the wait window."
    return 1
}

docker_llama_server_container_failed_after_swap() {
    [[ -n "${DOCKER_CMD:-}" ]] || return 1

    local state
    state=$($DOCKER_CMD inspect ods-llama-server \
        --format '{{.State.Status}} {{.State.Restarting}} {{.State.ExitCode}}' \
        2>/dev/null || true)
    case "$state" in
        restarting*|exited*|dead*)
            return 0
            ;;
        running\ true\ *|created\ true\ *)
            return 0
            ;;
    esac

    return 1
}

BOOTSTRAP_SWAP_BACKUP_PATH=""

move_bootstrap_model_aside_for_windows_swap() {
    [[ -f "$BOOTSTRAP_PATH" ]] || return 0

    BOOTSTRAP_SWAP_BACKUP_PATH="${BOOTSTRAP_PATH}.ods-swap-backup"
    rm -f "$BOOTSTRAP_SWAP_BACKUP_PATH"
    mv "$BOOTSTRAP_PATH" "$BOOTSTRAP_SWAP_BACKUP_PATH" || return 1
    log "Moved bootstrap model aside before Windows Lemonade full-model restart: $(basename "$BOOTSTRAP_PATH")"
}

restore_bootstrap_model_after_windows_swap_failure() {
    [[ -n "$BOOTSTRAP_SWAP_BACKUP_PATH" && -f "$BOOTSTRAP_SWAP_BACKUP_PATH" ]] || return 0

    mv "$BOOTSTRAP_SWAP_BACKUP_PATH" "$BOOTSTRAP_PATH" || return 1
    log "Restored bootstrap model after Windows Lemonade swap failure: $(basename "$BOOTSTRAP_PATH")"
}

discard_bootstrap_model_backup_after_windows_swap() {
    [[ -n "$BOOTSTRAP_SWAP_BACKUP_PATH" && -f "$BOOTSTRAP_SWAP_BACKUP_PATH" ]] || return 0

    rm -f "$BOOTSTRAP_SWAP_BACKUP_PATH"
    log "Removed bootstrap model after verified Windows Lemonade full-model serving: $(basename "$BOOTSTRAP_PATH")"
}

sync_windows_opencode_config() {
    case "$(uname -s)" in
        MINGW*|MSYS*|CYGWIN*) ;;
        *) return 0 ;;
    esac

    local sync_script="$INSTALL_DIR/scripts/update-windows-opencode-config.ps1"
    [[ -f "$sync_script" ]] || return 0

    local ps_cmd=""
    if command -v powershell.exe >/dev/null 2>&1; then
        ps_cmd="powershell.exe"
    elif command -v pwsh.exe >/dev/null 2>&1; then
        ps_cmd="pwsh.exe"
    fi
    [[ -n "$ps_cmd" ]] || return 0

    local install_dir_arg="$INSTALL_DIR"
    local sync_script_arg="$sync_script"
    if command -v cygpath >/dev/null 2>&1; then
        install_dir_arg=$(cygpath -w "$INSTALL_DIR")
        sync_script_arg=$(cygpath -w "$sync_script")
    fi

    log "Refreshing Windows OpenCode config for model: $FULL_GGUF_FILE"
    "$ps_cmd" -NoProfile -ExecutionPolicy Bypass -File "$sync_script_arg" -InstallDir "$install_dir_arg" \
        >/dev/null 2>&1 || log "WARNING: OpenCode config refresh failed (non-fatal)"
}

read_env_value() {
    local key="$1"
    [[ -f "$ENV_FILE" ]] || return 0
    grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"\047\r'
}

is_windows_bash() {
    case "$(uname -s)" in
        MINGW*|MSYS*|CYGWIN*) return 0 ;;
        *) return 1 ;;
    esac
}

windows_path() {
    local path="$1"
    if command -v cygpath >/dev/null 2>&1; then
        cygpath -w "$path"
    else
        printf '%s\n' "$path"
    fi
}

windows_ps_command() {
    if command -v powershell.exe >/dev/null 2>&1; then
        printf '%s\n' "powershell.exe"
    elif command -v pwsh.exe >/dev/null 2>&1; then
        printf '%s\n' "pwsh.exe"
    fi
}

restart_windows_lemonade_with_full_model() {
    is_windows_bash || return 1

    local runtime llm_backend managed runtime_mode lemonade_external
    runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
    llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
    [[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]] || return 1
    managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
    runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
    lemonade_external="$(read_env_value LEMONADE_EXTERNAL | tr '[:upper:]' '[:lower:]')"
    if [[ "$managed" == "false" || "$runtime_mode" == "external-lemonade" || "$lemonade_external" == "true" ]]; then
        log "Skipping native Windows Lemonade restart because the runtime is externally managed."
        return 1
    fi

    local ps_cmd
    ps_cmd="$(windows_ps_command)"
    [[ -n "$ps_cmd" ]] || {
        log "WARNING: no PowerShell executable found; cannot restart native Windows Lemonade."
        return 1
    }

    local pid_file bind_addr lemonade_port
    pid_file="$INSTALL_DIR/data/llama-server.pid"
    bind_addr="$(read_env_value BIND_ADDRESS)"
    [[ -n "$bind_addr" ]] || bind_addr="127.0.0.1"
    lemonade_port="$(read_env_value AMD_INFERENCE_PORT)"
    [[ -n "$lemonade_port" ]] || lemonade_port="8080"

    log "Restarting native Windows Lemonade with full model..."
    ODS_WIN_PID_FILE="$(windows_path "$pid_file")" \
    ODS_WIN_MODELS_DIR="$(windows_path "$MODELS_DIR")" \
    ODS_WIN_BIND_ADDR="$bind_addr" \
    ODS_WIN_LEMONADE_PORT="$lemonade_port" \
    "$ps_cmd" -NoProfile -ExecutionPolicy Bypass -Command '
        $ErrorActionPreference = "Stop"

        $roots = @()
        if ($env:ProgramFiles) { $roots += $env:ProgramFiles }
        $pf86 = [Environment]::GetFolderPath("ProgramFilesX86")
        if ($pf86) { $roots += $pf86 }
        $exe = $null
        foreach ($root in $roots) {
            $candidate = Join-Path (Join-Path (Join-Path $root "Lemonade Server") "bin") "lemonade-server.exe"
            if (Test-Path $candidate) { $exe = $candidate; break }
        }
        if (-not $exe) { throw "lemonade-server.exe not found under Program Files roots" }

        function Stop-ODSProcessId {
            param([int]$ProcessId)
            Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
            for ($i = 0; $i -lt 30; $i++) {
                $old = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
                if (-not $old) { return }
                Start-Sleep -Milliseconds 500
            }
        }

        $pidPath = $env:ODS_WIN_PID_FILE
        if (Test-Path $pidPath) {
            $rawPid = (Get-Content -LiteralPath $pidPath -Raw).Trim()
            if ($rawPid -match "^\d+$") {
                Stop-ODSProcessId -ProcessId ([int]$rawPid)
            }
            Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
        }

        # Lemonade also has a process-wide router singleton. If the saved PID
        # is stale or points at a child that has already exited, Start-Process
        # can report success while the new router immediately exits with
        # "Another instance of lemonade-router is already running"; the swap
        # then polls the old bootstrap instance forever. Clear the configured
        # listener and any Lemonade Server child processes before launching the
        # full-model instance.
        $port = [int]$env:ODS_WIN_LEMONADE_PORT
        $deadline = (Get-Date).AddSeconds(20)
        do {
            $listeners = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
            foreach ($listener in $listeners) {
                if ($listener.OwningProcess -gt 0) {
                    Stop-ODSProcessId -ProcessId ([int]$listener.OwningProcess)
                }
            }
            if ($listeners.Count -eq 0) { break }
            Start-Sleep -Milliseconds 500
        } while ((Get-Date) -lt $deadline)

        $binDir = Split-Path -Parent $exe
        $userProfile = [Environment]::GetFolderPath("UserProfile")
        $lemonadeCacheBin = if ($userProfile) { Join-Path (Join-Path (Join-Path $userProfile ".cache") "lemonade") "bin" } else { $null }
        $odsModelsDir = $env:ODS_WIN_MODELS_DIR
        $lemonadeChildren = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
            ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($binDir, [StringComparison]::OrdinalIgnoreCase)) -or
            ($lemonadeCacheBin -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($lemonadeCacheBin, [StringComparison]::OrdinalIgnoreCase)) -or
            ($odsModelsDir -and $_.CommandLine -and $_.CommandLine.IndexOf($odsModelsDir, [StringComparison]::OrdinalIgnoreCase) -ge 0)
        })
        foreach ($child in $lemonadeChildren) {
            Stop-ODSProcessId -ProcessId ([int]$child.ProcessId)
        }

        $stillListening = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
        if ($stillListening.Count -gt 0) {
            throw "port $port is still occupied after stopping the previous Lemonade instance"
        }

        $logPath = Join-Path $env:TEMP "lemonade-server.log"
        Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue

        $args = @(
            "serve",
            "--port", $env:ODS_WIN_LEMONADE_PORT,
            "--host", $env:ODS_WIN_BIND_ADDR,
            "--no-tray",
            "--llamacpp", "vulkan",
            "--extra-models-dir", $env:ODS_WIN_MODELS_DIR
        )
        $proc = Start-Process -FilePath $exe -ArgumentList $args -WindowStyle Hidden -PassThru
        New-Item -ItemType Directory -Path (Split-Path -Parent $pidPath) -Force | Out-Null
        Set-Content -LiteralPath $pidPath -Value $proc.Id

        Start-Sleep -Seconds 2
        $started = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue
        if (-not $started) {
            try {
                $health = Invoke-WebRequest -Uri ("http://127.0.0.1:{0}/api/v1/models" -f $port) -TimeoutSec 3 -UseBasicParsing
                if ([int]$health.StatusCode -eq 200) { return }
            } catch { }
            $tail = ""
            if (Test-Path $logPath) {
                $tail = (Get-Content -LiteralPath $logPath -Tail 8 -ErrorAction SilentlyContinue) -join " "
            }
            throw "lemonade-server exited immediately after restart. $tail"
        }
    ' >/dev/null 2>&1 || {
        log "WARNING: native Windows Lemonade restart failed."
        return 1
    }

    log "Waiting for native Windows Lemonade to serve extra.$FULL_GGUF_FILE ..."
    local model_id _swap_attempts
    model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
    # A freshly-swapped full model can take several minutes to register in
    # --extra-models-dir and then load on the first completion. The old 12x10s
    # (~2 min) budget timed out before a 22 GB MoE (Qwen3.6-35B-A3B) was even
    # listed, so the swap reverted to bootstrap (#1517). Use the same longer
    # budget as the llama.cpp warm-up path; override with ODS_LEMONADE_SWAP_ATTEMPTS.
    _swap_attempts="${ODS_LEMONADE_SWAP_ATTEMPTS:-60}"
    case "$_swap_attempts" in ''|*[!0-9]*|0) _swap_attempts=60 ;; esac
    for _i in $(seq 1 "$_swap_attempts"); do
        if curl -sf --max-time 5 "http://127.0.0.1:${lemonade_port}/api/v1/models" 2>/dev/null \
            | grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${model_id}\""; then
            if curl -sf --max-time 240 -X POST \
                "http://127.0.0.1:${lemonade_port}/api/v1/chat/completions" \
                -H "Content-Type: application/json" \
                -d "{\"model\":\"${model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}" \
                >/dev/null 2>&1; then
                log "SUCCESS: native Windows Lemonade completed with ${model_id}"
                return 0
            fi
            log "Windows Lemonade lists ${model_id}, but completion is not ready yet (attempt $_i/${_swap_attempts})."
        else
            log "Waiting for Windows Lemonade to register ${model_id} (attempt $_i/${_swap_attempts})."
        fi
        sleep 10
    done

    log "WARNING: native Windows Lemonade did not complete with ${model_id} after ${_swap_attempts} attempts; keeping bootstrap model for recovery."
    return 1
}

restart_windows_native_llama_server_with_full_model() {
    is_windows_bash || return 1

    local runtime managed runtime_mode location ps_cmd
    runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
    managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
    runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
    location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
    [[ "$runtime_mode" == "windows-llama-server-fallback" || ( "$runtime" == "llama-server" && "$location" == "host" ) ]] || return 1
    [[ "$managed" == "false" || "$location" == "external" ]] && return 1

    ps_cmd="$(windows_ps_command)"
    [[ -n "$ps_cmd" ]] || {
        log "WARNING: no PowerShell executable found; cannot restart native Windows llama-server."
        return 1
    }

    local pid_file llama_exe model_path rollback_model_path log_path bind_addr ctx_size llama_port reasoning reasoning_fmt
    pid_file="$INSTALL_DIR/data/llama-server.pid"
    llama_exe="$INSTALL_DIR/llama-server/llama-server.exe"
    model_path="$MODELS_DIR/$FULL_GGUF_FILE"
    rollback_model_path="$MODELS_DIR/$BOOTSTRAP_GGUF_FILE"
    log_path="$INSTALL_DIR/data/llama-server.log"
    bind_addr="$(read_env_value BIND_ADDRESS)"
    [[ -n "$bind_addr" ]] || bind_addr="127.0.0.1"
    ctx_size="$(read_env_value CTX_SIZE)"
    [[ -n "$ctx_size" ]] || ctx_size="$(read_env_value MAX_CONTEXT)"
    [[ -n "$ctx_size" ]] || ctx_size="$FULL_MAX_CONTEXT"
    llama_port="$(read_env_value AMD_INFERENCE_PORT)"
    [[ -n "$llama_port" ]] || llama_port="8080"
    reasoning="$(read_env_value LLAMA_REASONING)"
    [[ -n "$reasoning" ]] || reasoning="off"
    case "$reasoning" in
        off) reasoning_fmt="none" ;;
        on)  reasoning_fmt="deepseek" ;;
        *)   reasoning_fmt="$reasoning" ;;
    esac

    [[ -f "$llama_exe" ]] || {
        log "WARNING: llama-server.exe not found at $llama_exe. Cannot hot-swap native Windows llama-server."
        return 1
    }
    [[ -f "$model_path" ]] || {
        log "WARNING: full model not found at $model_path. Cannot hot-swap native Windows llama-server."
        return 1
    }

    log "Restarting native Windows llama-server with full model..."
    ODS_WIN_PID_FILE="$(windows_path "$pid_file")" \
    ODS_WIN_LLAMA_EXE="$(windows_path "$llama_exe")" \
    ODS_WIN_MODEL_PATH="$(windows_path "$model_path")" \
    ODS_WIN_ROLLBACK_MODEL_PATH="$(windows_path "$rollback_model_path")" \
    ODS_WIN_LOG_PATH="$(windows_path "$log_path")" \
    ODS_WIN_BIND_ADDR="$bind_addr" \
    ODS_WIN_LLAMA_PORT="$llama_port" \
    ODS_WIN_CTX_SIZE="$ctx_size" \
    ODS_WIN_REASONING_FORMAT="$reasoning_fmt" \
    ODS_WIN_FLASH_ATTN="$(read_env_value LLAMA_ARG_FLASH_ATTN)" \
    ODS_WIN_CACHE_TYPE_K="$(read_env_value LLAMA_ARG_CACHE_TYPE_K)" \
    ODS_WIN_CACHE_TYPE_V="$(read_env_value LLAMA_ARG_CACHE_TYPE_V)" \
    ODS_WIN_N_CPU_MOE="$(read_env_value LLAMA_ARG_N_CPU_MOE)" \
    ODS_WIN_PARALLEL="$(read_env_value LLAMA_PARALLEL)" \
    ODS_WIN_CHECKPOINT_EVERY_N_TOKENS="$(read_env_value LLAMA_ARG_CHECKPOINT_EVERY_N_TOKENS)" \
    ODS_WIN_NO_CACHE_PROMPT="$(read_env_value LLAMA_ARG_NO_CACHE_PROMPT)" \
    ODS_WIN_SPEC_TYPE="$(read_env_value LLAMA_ARG_SPEC_TYPE)" \
    ODS_WIN_SPEC_DRAFT_N_MAX="$(read_env_value LLAMA_ARG_SPEC_DRAFT_N_MAX)" \
    "$ps_cmd" -NoProfile -ExecutionPolicy Bypass -Command '
        $ErrorActionPreference = "Stop"

        function Stop-ODSProcessId {
            param([int]$ProcessId)
            Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
            for ($i = 0; $i -lt 30; $i++) {
                $old = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
                if (-not $old) { return }
                Start-Sleep -Milliseconds 500
            }
        }

        function Stop-ODSLlamaListeners {
            param([int]$Port)
            $deadline = (Get-Date).AddSeconds(20)
            do {
                $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
                foreach ($listener in $listeners) {
                    if ($listener.OwningProcess -gt 0) {
                        $proc = Get-CimInstance Win32_Process -Filter ("ProcessId = {0}" -f [int]$listener.OwningProcess) -ErrorAction SilentlyContinue
                        if ($proc -and (
                            ($proc.Name -like "llama-server*") -or
                            ($proc.ExecutablePath -and $proc.ExecutablePath.Equals($env:ODS_WIN_LLAMA_EXE, [StringComparison]::OrdinalIgnoreCase)) -or
                            ($proc.CommandLine -and $proc.CommandLine.IndexOf("llama-server", [StringComparison]::OrdinalIgnoreCase) -ge 0)
                        )) {
                            Stop-ODSProcessId -ProcessId ([int]$listener.OwningProcess)
                        }
                    }
                }
                if ($listeners.Count -eq 0) { return }
                Start-Sleep -Milliseconds 500
            } while ((Get-Date) -lt $deadline)
        }

        function Start-ODSLlama {
            param([string]$ModelPath)

            $args = @(
                "--model", $ModelPath,
                "--host", $env:ODS_WIN_BIND_ADDR,
                "--port", $env:ODS_WIN_LLAMA_PORT,
                "--n-gpu-layers", "999",
                "--ctx-size", $env:ODS_WIN_CTX_SIZE,
                "--reasoning-format", $env:ODS_WIN_REASONING_FORMAT,
                "--metrics"
            )
            if ($env:ODS_WIN_FLASH_ATTN) { $args += @("--flash-attn", $env:ODS_WIN_FLASH_ATTN) }
            if ($env:ODS_WIN_CACHE_TYPE_K) { $args += @("--cache-type-k", $env:ODS_WIN_CACHE_TYPE_K) }
            if ($env:ODS_WIN_CACHE_TYPE_V) { $args += @("--cache-type-v", $env:ODS_WIN_CACHE_TYPE_V) }
            if ($env:ODS_WIN_N_CPU_MOE) { $args += @("--n-cpu-moe", $env:ODS_WIN_N_CPU_MOE) }
            if ($env:ODS_WIN_PARALLEL) { $args += @("--parallel", $env:ODS_WIN_PARALLEL) }
            if ($env:ODS_WIN_CHECKPOINT_EVERY_N_TOKENS) { $args += @("--checkpoint-every-n-tokens", $env:ODS_WIN_CHECKPOINT_EVERY_N_TOKENS) }
            if ($env:ODS_WIN_NO_CACHE_PROMPT -and $env:ODS_WIN_NO_CACHE_PROMPT -notin @("0", "false", "off", "no")) { $args += @("--no-cache-prompt") }
            if ($env:ODS_WIN_SPEC_TYPE) { $args += @("--spec-type", $env:ODS_WIN_SPEC_TYPE) }
            if ($env:ODS_WIN_SPEC_DRAFT_N_MAX) { $args += @("--spec-draft-n-max", $env:ODS_WIN_SPEC_DRAFT_N_MAX) }

            New-Item -ItemType Directory -Path (Split-Path -Parent $env:ODS_WIN_PID_FILE) -Force | Out-Null
            New-Item -ItemType Directory -Path (Split-Path -Parent $env:ODS_WIN_LOG_PATH) -Force | Out-Null
            $proc = Start-Process -FilePath $env:ODS_WIN_LLAMA_EXE `
                -ArgumentList $args -WindowStyle Hidden -RedirectStandardOutput $env:ODS_WIN_LOG_PATH -RedirectStandardError ($env:ODS_WIN_LOG_PATH + ".err") -PassThru
            Set-Content -LiteralPath $env:ODS_WIN_PID_FILE -Value $proc.Id
            return $proc
        }

        function Wait-ODSLlamaHealth {
            param([int]$ProcessId, [int]$Port)
            for ($i = 0; $i -lt 60; $i++) {
                $proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
                if (-not $proc) { return $false }
                try {
                    $resp = Invoke-WebRequest -Uri ("http://127.0.0.1:{0}/health" -f $Port) -TimeoutSec 5 -UseBasicParsing
                    if ([int]$resp.StatusCode -eq 200) { return $true }
                } catch { }
                Start-Sleep -Seconds 5
            }
            return $false
        }

        $pidPath = $env:ODS_WIN_PID_FILE
        if (Test-Path $pidPath) {
            $rawPid = (Get-Content -LiteralPath $pidPath -Raw).Trim()
            if ($rawPid -match "^\d+$") {
                Stop-ODSProcessId -ProcessId ([int]$rawPid)
            }
            Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
        }

        $port = [int]$env:ODS_WIN_LLAMA_PORT
        Stop-ODSLlamaListeners -Port $port

        $fullProc = Start-ODSLlama -ModelPath $env:ODS_WIN_MODEL_PATH
        if (Wait-ODSLlamaHealth -ProcessId ([int]$fullProc.Id) -Port $port) { exit 0 }

        Stop-ODSProcessId -ProcessId ([int]$fullProc.Id)
        if ($env:ODS_WIN_ROLLBACK_MODEL_PATH -and (Test-Path $env:ODS_WIN_ROLLBACK_MODEL_PATH)) {
            $rollbackProc = Start-ODSLlama -ModelPath $env:ODS_WIN_ROLLBACK_MODEL_PATH
            [void](Wait-ODSLlamaHealth -ProcessId ([int]$rollbackProc.Id) -Port $port)
        }
        exit 1
    ' >/dev/null 2>&1 || {
        log "WARNING: native Windows llama-server restart failed."
        return 1
    }

    log "SUCCESS: native Windows llama-server running with ${FULL_GGUF_FILE}"
    return 0
}

patch_hermes_yaml_with_sed() {
    local path="$1" model="$2" context_length="$3" base_url="${4:-}" request_timeout_seconds="${5:-180}"
    [[ -f "$path" ]] || return 1

    local model_sed base_url_sed
    model_sed="$(printf '%s' "$model" | sed 's/[\\&|]/\\&/g')"
    base_url_sed="$(printf '%s' "$base_url" | sed 's/[\\&|]/\\&/g')"

    local sed_args=(
        -e "s|^  default: \".*\"[[:space:]]*$|  default: \"${model_sed}\"|"
        -e "s|^  context_length: .*|  context_length: ${context_length}|"
        -e "s|^    context_length: .*|    context_length: ${context_length}|"
    )
    if [[ "$request_timeout_seconds" =~ ^[0-9]+$ && "$request_timeout_seconds" != "180" ]]; then
        sed_args+=(-e "s|^    request_timeout_seconds: 180[[:space:]]*$|    request_timeout_seconds: ${request_timeout_seconds}|")
    fi
    if [[ -n "$base_url" ]]; then
        sed_args+=(-e "s|^  base_url: \".*\"[[:space:]]*$|  base_url: \"${base_url_sed}\"|")
    fi

    if sed -i.bak \
        "${sed_args[@]}" \
        "$path" 2>&1; then
        rm -f "${path}.bak"
    else
        [[ -f "${path}.bak" ]] && mv "${path}.bak" "$path"
        return 1
    fi

    grep -Fq "  default: \"${model}\"" "$path" \
        && grep -Fq "  context_length: ${context_length}" "$path" \
        && { [[ -z "$base_url" ]] || grep -Fq "  base_url: \"${base_url}\"" "$path"; }
}

patch_hermes_model_after_swap() {
    local runtime llm_backend hermes_base_url old_model new_model tpl live live_host_patch_failed hermes_request_timeout
    runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
    llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
    hermes_base_url="$(read_env_value HERMES_LLM_BASE_URL)"
    old_model="$BOOTSTRAP_GGUF_FILE"
    new_model="$FULL_GGUF_FILE"
    if [[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]]; then
        old_model="extra.$BOOTSTRAP_GGUF_FILE"
        new_model="extra.$FULL_GGUF_FILE"
    fi

    log "Patching Hermes config after full-model swap: ${old_model} -> ${new_model}"
    hermes_request_timeout=180
    if is_windows_bash || [[ "$runtime" == "lemonade" || "$llm_backend" == "lemonade" ]]; then
        hermes_request_timeout=900
    fi

    tpl="$INSTALL_DIR/extensions/services/hermes/cli-config.yaml.template"
    if [[ -f "$tpl" ]]; then
        if ! patch_hermes_yaml_with_sed "$tpl" "$new_model" "$FULL_MAX_CONTEXT" "$hermes_base_url" "$hermes_request_timeout"; then
            log "ERROR: Could not patch ${tpl} after full-model swap."
            return 1
        fi
    fi

    live="$INSTALL_DIR/data/hermes/config.yaml"
    live_host_patch_failed=false
    if [[ -f "$live" ]]; then
        if ! patch_hermes_yaml_with_sed "$live" "$new_model" "$FULL_MAX_CONTEXT" "$hermes_base_url" "$hermes_request_timeout"; then
            live_host_patch_failed=true
            log "WARNING: Could not patch ${live} after full-model swap; will try the container copy if Hermes is running."
        fi
    fi

    if [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-hermes --format '{{.Names}}' 2>/dev/null | grep -q ods-hermes; then
        local live_patch new_model_sed hermes_base_url_sed
        new_model_sed="$(printf '%s' "$new_model" | sed 's/[\\&|]/\\&/g')"
        hermes_base_url_sed="$(printf '%s' "$hermes_base_url" | sed 's/[\\&|]/\\&/g')"
        live_patch="sed -i -e 's|^  default: \".*\"[[:space:]]*$|  default: \"${new_model_sed}\"|' -e 's|^  context_length: .*|  context_length: ${FULL_MAX_CONTEXT}|' -e 's|^    context_length: .*|    context_length: ${FULL_MAX_CONTEXT}|'"
        if [[ -n "$hermes_base_url" ]]; then
            live_patch="${live_patch} -e 's|^  base_url: \".*\"|  base_url: \"${hermes_base_url_sed}\"|'"
        fi
        if [[ "$hermes_request_timeout" != "180" ]]; then
            live_patch="${live_patch} -e 's|^    request_timeout_seconds: 180[[:space:]]*$|    request_timeout_seconds: ${hermes_request_timeout}|'"
        fi
        live_patch="${live_patch} /opt/data/config.yaml"
        $DOCKER_CMD exec ods-hermes sh -c \
            "$live_patch" 2>&1 || {
                log "ERROR: Could not patch Hermes live config after full-model swap."
                return 1
            }
        $DOCKER_CMD restart ods-hermes 2>&1 || {
            log "ERROR: Could not restart Hermes after full-model swap."
            return 1
        }
    elif [[ "$live_host_patch_failed" == "true" ]]; then
        log "ERROR: Could not patch Hermes live config after full-model swap."
        return 1
    fi

    return 0
}

refresh_windows_native_litellm_local_config_after_swap() {
    is_windows_bash || return 0

    local runtime runtime_mode location managed
    runtime="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
    runtime_mode="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
    location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
    managed="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"

    [[ "$managed" != "false" && "$location" == "host" ]] || return 0
    [[ "$runtime_mode" == "windows-llama-server-fallback" || "$runtime" == "llama-server" ]] || return 0

    local litellm_dir litellm_config native_port native_api_base model_sed
    litellm_dir="$INSTALL_DIR/config/litellm"
    litellm_config="$litellm_dir/local.yaml"
    native_port="$(read_env_value AMD_INFERENCE_PORT)"
    [[ -n "$native_port" ]] || native_port="8080"
    native_api_base="http://host.docker.internal:${native_port}/v1"
    model_sed="${FULL_GGUF_FILE//\"/\\\"}"

    log "Updating LiteLLM local config for native Windows llama-server: ${FULL_GGUF_FILE}"
    mkdir -p "$litellm_dir" || return 1
    cat > "$litellm_config" << LITELLM_NATIVE_LOCAL_EOF
model_list:
  - model_name: default
    litellm_params:
      model: openai/${model_sed}
      api_base: ${native_api_base}
      api_key: not-needed
      extra_body:
        chat_template_kwargs:
          enable_thinking: false

  - model_name: "*"
    litellm_params:
      model: openai/*
      api_base: ${native_api_base}
      api_key: not-needed
      extra_body:
        chat_template_kwargs:
          enable_thinking: false

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

litellm_settings:
  drop_params: true
  set_verbose: false
  request_timeout: 900
  stream_timeout: 900
LITELLM_NATIVE_LOCAL_EOF

    if [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-litellm --format '{{.Names}}' 2>/dev/null | grep -q ods-litellm; then
        $DOCKER_CMD restart ods-litellm 2>&1 || log "WARNING: LiteLLM restart failed after native Windows config refresh (non-fatal)"
    fi
}

refresh_lemonade_after_bootstrap_cleanup() {
    local gpu_backend llm_backend
    gpu_backend="$(read_env_value GPU_BACKEND | tr '[:upper:]' '[:lower:]')"
    llm_backend="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"

    [[ "$gpu_backend" == "amd" || "$llm_backend" == "lemonade" ]] || return 0
    is_windows_bash && return 0
    [[ "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" ]] || return 0
    [[ -n "$DOCKER_CMD" ]] || return 1

    local compose_args=()
    if [[ -f "$INSTALL_DIR/.compose-flags" ]]; then
        read -ra compose_args <<< "$(cat "$INSTALL_DIR/.compose-flags")"
    fi
    if [[ ${#compose_args[@]} -eq 0 || -z "$DOCKER_COMPOSE_CMD" ]]; then
        log "WARNING: cannot refresh Lemonade after bootstrap cleanup because compose flags are unavailable."
        return 1
    fi

    log "Refreshing Lemonade after bootstrap model cleanup so stale model metadata is dropped..."
    env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
        $DOCKER_COMPOSE_CMD "${compose_args[@]}" up -d --force-recreate --no-deps llama-server 2>&1 || return 1

    local lemonade_port model_id old_model_id models_json
    lemonade_port="$(read_env_value OLLAMA_PORT)"
    [[ -n "$lemonade_port" ]] || lemonade_port="8080"
    model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
    old_model_id="extra.${BOOTSTRAP_GGUF//\"/\\\"}"

    for _i in $(seq 1 60); do
        models_json="$(curl -sf --max-time 5 "http://127.0.0.1:${lemonade_port}/api/v1/models" 2>/dev/null || true)"
        if echo "$models_json" | grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${model_id}\"" \
            && ! echo "$models_json" | grep -q "\"id\"[[:space:]]*:[[:space:]]*\"${old_model_id}\""; then
            if curl -sf --max-time 240 -X POST \
                "http://127.0.0.1:${lemonade_port}/api/v1/chat/completions" \
                -H "Content-Type: application/json" \
                -d "{\"model\":\"${model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}" \
                >/dev/null 2>&1; then
                log "Lemonade refreshed with only the full model advertised: ${model_id}"
                return 0
            fi
            log "Lemonade lists ${model_id} after cleanup, but completion is not ready yet (attempt $_i/60)."
        else
            log "Waiting for Lemonade to drop bootstrap metadata after cleanup (attempt $_i/60)."
        fi
        sleep 5
    done

    return 1
}

# Background monitor: polls .part file size every 2s
monitor_download() {
    local part_file="$1" total_bytes="$2"

    # Wait for curl to create the .part file (up to 30s)
    for _wait in $(seq 1 30); do
        [[ -f "$part_file" ]] && break
        sleep 1
    done
    [[ -f "$part_file" ]] || return 0

    local prev_bytes=0 prev_time
    prev_time=$(date +%s)

    while [[ -f "$part_file" ]]; do
        sleep 2
        [[ -f "$part_file" ]] || break

        local current_bytes
        current_bytes=$(file_size "$part_file")
        local now
        now=$(date +%s)
        local elapsed=$((now - prev_time))

        local speed=0
        if [[ $elapsed -gt 0 && $current_bytes -ge $prev_bytes ]]; then
            speed=$(( (current_bytes - prev_bytes) / elapsed ))
        fi

        local percent="null"
        local eta=""
        local progress_bytes="$current_bytes"
        if [[ $total_bytes -gt 0 ]]; then
            [[ "$progress_bytes" -gt "$total_bytes" ]] && progress_bytes="$total_bytes"
            percent=$(status_percent "$progress_bytes" "$total_bytes")
            if [[ $speed -gt 0 ]]; then
                local remaining=$(( total_bytes - progress_bytes ))
                local eta_secs=$(( remaining / speed ))
                local eta_min=$(( eta_secs / 60 ))
                local eta_sec=$(( eta_secs % 60 ))
                eta="${eta_min}m ${eta_sec}s"
            else
                eta="calculating..."
            fi
        fi

        write_status "downloading" "$percent" "$progress_bytes" "$total_bytes" "$speed" "$eta"
        prev_bytes=$current_bytes
        prev_time=$now
    done
}

# ── Docker permission detection ──
# This script runs detached via nohup, so DOCKER_CMD from the parent installer
# is not inherited. For Linux installs we MUST be able to talk to the docker
# daemon — silently failing here leaves the user running the small bootstrap
# model forever. macOS installs use a native llama-server PID file and never
# enter the docker hot-swap path; skip detection there. Mirrors the
# sudo-fallback pattern in installers/phases/05-docker.sh.
DOCKER_CMD=""
DOCKER_COMPOSE_CMD=""
if command -v docker >/dev/null 2>&1; then
    if docker info >/dev/null 2>&1; then
        DOCKER_CMD="docker"
    elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then
        DOCKER_CMD="sudo docker"
        log "Detected docker requires sudo (user not in docker group). Using 'sudo docker'."
    elif [[ ! -f "$INSTALL_DIR/data/.llama-server.pid" ]]; then
        # Linux install: docker is the only hot-swap path. Failing silently
        # would leave the bootstrap model running forever — fail loudly.
        log "ERROR: docker is installed but not accessible by this user."
        log "       Tried 'docker info' and 'sudo -n docker info' — both failed."
        log "       The bootstrap model will continue running. Fix one of:"
        log "         1. Re-login (so 'docker' group membership takes effect), then re-run this script."
        log "         2. Configure passwordless sudo for 'docker' (e.g. NOPASSWD in /etc/sudoers.d)."
        write_status "failed"
        exit 1
    fi

    if [[ -n "$DOCKER_CMD" ]]; then
        # Pick docker compose v2 (plugin) if available, else legacy docker-compose v1.
        if $DOCKER_CMD compose version >/dev/null 2>&1; then
            DOCKER_COMPOSE_CMD="$DOCKER_CMD compose"
        elif command -v docker-compose >/dev/null 2>&1; then
            if [[ "$DOCKER_CMD" == "sudo docker" ]]; then
                DOCKER_COMPOSE_CMD="sudo docker-compose"
            else
                DOCKER_COMPOSE_CMD="docker-compose"
            fi
        fi
    fi
fi

log "Starting full model download: $FULL_GGUF_FILE"
log "URL: $FULL_GGUF_URL"
log "Target: $MODELS_DIR/$FULL_GGUF_FILE"

# ── Phase 1: Download the full model ──
mkdir -p "$MODELS_DIR"
acquire_upgrade_lock

# Get total file size for progress calculation
TOTAL_BYTES=$(get_remote_size "$FULL_GGUF_URL")
[[ -z "$TOTAL_BYTES" ]] && TOTAL_BYTES=0
log "Expected file size: $TOTAL_BYTES bytes"

# Write initial status
write_status "starting" "" 0 "$TOTAL_BYTES" 0 "calculating..."

_part_path="$MODELS_DIR/$FULL_GGUF_FILE.part"
_final_path="$MODELS_DIR/$FULL_GGUF_FILE"
_dl_success=false
_download_attempts="${ODS_BOOTSTRAP_DOWNLOAD_ATTEMPTS:-6}"
case "$_download_attempts" in
    ''|*[!0-9]*|0) _download_attempts=6 ;;
esac

if [[ -f "$_final_path" ]]; then
    log "Full model already exists on disk; verifying before reuse"
    if verify_model_integrity "$_final_path"; then
        _dl_success=true
        write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
    else
        log "Existing full model failed integrity; deleting and retrying from a clean file."
        rm -f "$_final_path"
    fi
fi

if [[ -f "$_part_path" && "$TOTAL_BYTES" -gt 0 ]]; then
    _part_bytes="$(file_size "$_part_path")"
    if [[ "$_part_bytes" -gt "$TOTAL_BYTES" ]]; then
        log "Existing partial is larger than the remote file (got $_part_bytes, expected $TOTAL_BYTES); deleting corrupt resume state."
        rm -f "$_part_path"
    fi
fi

if [[ "$_dl_success" != "true" ]]; then
    monitor_download "$_part_path" "$TOTAL_BYTES" &
    _monitor_pid=$!
    trap 'kill $_monitor_pid 2>/dev/null || true; write_failed_download_status "$_part_path" "$TOTAL_BYTES" "Download interrupted; partial file preserved for resume."; release_upgrade_lock; exit 1' TERM INT

    # Download with resume support. curl success is not enough: finalizing the
    # .part file can fail, and checksum verification can expose a corrupt
    # resume. Keep retries inside the script so the detached upgrade can
    # recover without leaving the user on the bootstrap model.
    for ((_attempt=1; _attempt<=_download_attempts; _attempt++)); do
        [[ $_attempt -gt 1 ]] && log "Retry attempt $_attempt of $_download_attempts..." && sleep 5

        if [[ -f "$_part_path" && "$TOTAL_BYTES" -gt 0 ]]; then
            _part_bytes="$(file_size "$_part_path")"
            if [[ "$_part_bytes" -gt "$TOTAL_BYTES" ]]; then
                log "Partial file grew larger than expected before attempt $_attempt (got $_part_bytes, expected $TOTAL_BYTES); deleting corrupt resume state."
                rm -f "$_part_path"
            elif [[ "$_part_bytes" -eq "$TOTAL_BYTES" ]]; then
                log "Partial file already has the expected size; promoting it for integrity verification."
                if ! mv "$_part_path" "$_final_path"; then
                    log "Download attempt $_attempt failed while finalizing $_part_path -> $_final_path"
                fi
            fi
        fi

        if [[ ! -f "$_final_path" ]]; then
            # Let this script own retry/resume. curl's internal retry path can
            # restart the transfer from byte zero after a long connection reset,
            # truncating an otherwise good multi-GB .part file.
            if curl -fSL -C - --connect-timeout 30 --speed-time 300 --speed-limit 1024 \
                    -o "$_part_path" "$FULL_GGUF_URL" 2>&1; then
                if [[ ! -s "$_part_path" ]]; then
                    log "Download attempt $_attempt reported success but produced no partial file: $_part_path"
                elif mv "$_part_path" "$_final_path"; then
                    :
                else
                    log "Download attempt $_attempt failed while finalizing $_part_path -> $_final_path"
                fi
            else
                log "Download attempt $_attempt failed"
            fi
        fi

        if [[ ! -s "$_final_path" ]]; then
            continue
        fi

        if [[ "$TOTAL_BYTES" -gt 0 ]]; then
            ACTUAL_BYTES=$(file_size "$_final_path")
            if [[ "$ACTUAL_BYTES" -lt "$TOTAL_BYTES" ]]; then
                mv "$_final_path" "$_part_path" 2>/dev/null || rm -f "$_final_path"
                log "Downloaded model is smaller than expected (got $ACTUAL_BYTES, expected $TOTAL_BYTES); preserving as partial for retry."
                continue
            fi
            if [[ "$ACTUAL_BYTES" -gt "$TOTAL_BYTES" ]]; then
                rm -f "$_final_path" "$_part_path"
                log "Downloaded model is larger than expected (got $ACTUAL_BYTES, expected $TOTAL_BYTES); deleting corrupt file and retrying from scratch."
                continue
            fi
        fi

        write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
        log "Download complete: $FULL_GGUF_FILE"
        if verify_model_integrity "$_final_path"; then
            _dl_success=true
            break
        fi

        rm -f "$_final_path" "$_part_path"
        log "Integrity verification failed on attempt $_attempt; retrying from a clean download."
    done

    kill $_monitor_pid 2>/dev/null || true
    trap - TERM INT

    if [[ "$_dl_success" != "true" ]]; then
        write_failed_download_status "$_part_path" "$TOTAL_BYTES" "Download failed after $_download_attempts attempts; partial file preserved for resume."
        fail "Download failed after $_download_attempts attempts. Preserved partial file for resume: $_part_path. Bootstrap model will continue running."
    fi
fi

# ── Phase 2: Verify integrity (if SHA256 provided) ──
if [[ -n "$FULL_GGUF_SHA256" ]]; then
    write_status "verifying" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
    log "Verifying SHA256..."
    if ! verify_model_integrity "$MODELS_DIR/$FULL_GGUF_FILE"; then
        rm -f "$MODELS_DIR/$FULL_GGUF_FILE"
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Downloaded model failed SHA256 after retries. Corrupt file deleted; bootstrap model left running."
        fail "SHA256 mismatch after retries. Deleted corrupt file."
    fi
fi

_windows_lemonade_swap_applies=false
_windows_native_llama_swap_applies=false
_docker_llama_swap_applies=false
if is_windows_bash; then
    _runtime_for_swap="$(read_env_value AMD_INFERENCE_RUNTIME | tr '[:upper:]' '[:lower:]')"
    _backend_for_swap="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
    _managed_for_swap="$(read_env_value AMD_INFERENCE_MANAGED | tr '[:upper:]' '[:lower:]')"
    _runtime_mode_for_swap="$(read_env_value AMD_INFERENCE_RUNTIME_MODE | tr '[:upper:]' '[:lower:]')"
    _location_for_swap="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
    if [[ "$_runtime_for_swap" == "lemonade" || "$_backend_for_swap" == "lemonade" ]]; then
        _windows_lemonade_swap_applies=true
    elif [[ "$_managed_for_swap" != "false" && "$_location_for_swap" != "external" ]] \
        && [[ "$_runtime_mode_for_swap" == "windows-llama-server-fallback" || ( "$_runtime_for_swap" == "llama-server" && "$_location_for_swap" == "host" ) ]]; then
        _windows_native_llama_swap_applies=true
    fi
elif [[ -n "$DOCKER_CMD" ]]; then
    # Linux Docker installs mutate .env/models.ini before attempting a
    # llama-server hot-swap. Snapshot whenever Docker is available so every
    # Docker failure path can restore the last known-good model config.
    _docker_llama_swap_applies=true
fi

if [[ "$_windows_lemonade_swap_applies" == "true" || "$_windows_native_llama_swap_applies" == "true" || "$_docker_llama_swap_applies" == "true" ]]; then
    log "Snapshotting active model config before full-model swap..."
    if ! snapshot_active_model_config; then
        discard_active_model_config_snapshot
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Full model downloaded and verified, but ODS could not snapshot active model config before swap. Bootstrap model left unchanged; re-run to retry."
        exit 1
    fi
fi

# ── Phase 3: Update .env ──
write_status "swapping" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
log "Updating .env..."
if [[ -f "$ENV_FILE" ]]; then
    # Update GGUF_FILE
    if grep -q '^GGUF_FILE=' "$ENV_FILE"; then
        awk -v v="$FULL_GGUF_FILE" '{ if (index($0, "GGUF_FILE=") == 1) print "GGUF_FILE=" v; else print }' \
            "$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
    fi
    # Update LLM_MODEL
    if grep -q '^LLM_MODEL=' "$ENV_FILE"; then
        awk -v v="$FULL_LLM_MODEL" '{ if (index($0, "LLM_MODEL=") == 1) print "LLM_MODEL=" v; else print }' \
            "$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
    fi
    # Update MAX_CONTEXT / CTX_SIZE
    if grep -q '^MAX_CONTEXT=' "$ENV_FILE"; then
        awk -v v="$FULL_MAX_CONTEXT" '{ if (index($0, "MAX_CONTEXT=") == 1) print "MAX_CONTEXT=" v; else print }' \
            "$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
    fi
    if grep -q '^CTX_SIZE=' "$ENV_FILE"; then
        awk -v v="$FULL_MAX_CONTEXT" '{ if (index($0, "CTX_SIZE=") == 1) print "CTX_SIZE=" v; else print }' \
            "$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
    fi
    log ".env updated"
else
    fail ".env not found at $ENV_FILE"
fi

# ── Phase 4: Update models.ini ──
log "Updating models.ini..."
mkdir -p "$(dirname "$MODELS_INI")"
cat > "$MODELS_INI" << EOF
[${FULL_LLM_MODEL}]
filename = ${FULL_GGUF_FILE}
load-on-startup = true
n-ctx = ${FULL_MAX_CONTEXT}
EOF
log "models.ini updated"

BOOTSTRAP_GGUF="${BOOTSTRAP_GGUF_FILE:-Qwen3.5-2B-Q4_K_M.gguf}"
BOOTSTRAP_PATH="$MODELS_DIR/$BOOTSTRAP_GGUF"
HOT_SWAP_VERIFIED=false

# ── Phase 5: Hot-swap llama-server (if running) ──
# Read OLLAMA_PORT from .env (nohup doesn't inherit env vars from parent)
if [[ -f "$ENV_FILE" ]]; then
    OLLAMA_PORT=$(grep -E '^OLLAMA_PORT=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
fi

if [[ "$_windows_lemonade_swap_applies" == "true" ]]; then
    if ! move_bootstrap_model_aside_for_windows_swap; then
        restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Full model downloaded and verified, but ODS could not move the bootstrap model aside before the Windows Lemonade swap. Previous active model config restored; re-run to retry."
        exit 1
    fi

    if restart_windows_lemonade_with_full_model; then
        if ! patch_hermes_model_after_swap; then
            # The full model downloaded, verified, and loaded; only the Hermes
            # config patch failed. Report the real byte counts and the actual
            # cause, not a bare write_status "failed" that zeroes the byte fields
            # and reads as a 0-byte download failure (#1517).
            log "Restoring previous active model config after Hermes patch failure..."
            restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
            restore_bootstrap_model_after_windows_swap_failure || log "WARNING: could not restore bootstrap model; inspect $BOOTSTRAP_PATH"
            write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
                "Full model downloaded and loaded, but the Hermes config patch failed after swap. Previous active model config restored; re-run to retry."
            exit 1
        fi
        HOT_SWAP_VERIFIED=true
        discard_active_model_config_snapshot
        discard_bootstrap_model_backup_after_windows_swap
    else
        # The model downloaded and verified (byte counts are real); the native
        # Windows Lemonade swap did not register/load it within the wait window,
        # so the bootstrap model is kept. Report the real bytes + cause instead
        # of a bare write_status "failed" that zeroes bytesDownloaded/bytesTotal
        # and misreads as a 0-byte download failure (#1517).
        log "Restoring previous active model config after Windows Lemonade swap timeout..."
        restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
        restore_bootstrap_model_after_windows_swap_failure || log "WARNING: could not restore bootstrap model; inspect $BOOTSTRAP_PATH"
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Model downloaded and verified, but native Windows Lemonade did not load it after swap (registration timeout). Previous active model config restored and bootstrap model kept; re-run to retry the swap."
        exit 1
    fi
elif [[ "$_windows_native_llama_swap_applies" == "true" ]]; then
    if restart_windows_native_llama_server_with_full_model; then
        if ! patch_hermes_model_after_swap; then
            log "Restoring previous active model config after Hermes patch failure..."
            restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
            write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
                "Full model downloaded and loaded in native Windows llama-server, but the Hermes config patch failed after swap. Previous active model config restored; re-run to retry."
            exit 1
        fi
        if ! refresh_windows_native_litellm_local_config_after_swap; then
            log "Restoring previous active model config after LiteLLM config refresh failure..."
            restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
            write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
                "Full model downloaded and loaded in native Windows llama-server, but the LiteLLM local config refresh failed after swap. Previous active model config restored; re-run to retry."
            exit 1
        fi
        HOT_SWAP_VERIFIED=true
        discard_active_model_config_snapshot
    else
        log "Restoring previous active model config after native Windows llama-server swap timeout..."
        restore_active_model_config || log "WARNING: could not restore active model config; inspect $ENV_FILE and $MODELS_INI"
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Model downloaded and verified, but native Windows llama-server did not load it after swap. Previous active model config restored and bootstrap model kept; re-run to retry the swap."
        exit 1
    fi
elif [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=ods-llama-server --format '{{.Names}}' 2>/dev/null | grep -q ods-llama-server; then
    log "Restarting llama-server with full model..."

    # Read GPU backend from .env (needed for health endpoint and restart strategy)
    _gpu_backend=""
    if [[ -f "$ENV_FILE" ]]; then
        _gpu_backend=$(grep -E '^GPU_BACKEND=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
    fi

    # Detect compose files
    COMPOSE_ARGS=()
    if [[ -f "$INSTALL_DIR/.compose-flags" ]]; then
        read -ra COMPOSE_ARGS <<< "$(cat "$INSTALL_DIR/.compose-flags")"
    elif [[ -x "$INSTALL_DIR/scripts/resolve-compose-stack.sh" ]]; then
        _tier="1"
        if [[ -f "$ENV_FILE" ]]; then
            _tier=$(grep -E '^TIER=' "$ENV_FILE" | cut -d= -f2 | tr -d '"\047\r')
            [[ -n "$_tier" ]] || _tier="1"
        fi
        _resolved_env=$("$INSTALL_DIR/scripts/resolve-compose-stack.sh" \
            --script-dir "$INSTALL_DIR" \
            --tier "$_tier" \
            --gpu-backend "${_gpu_backend:-cpu}" \
            --env 2>/dev/null || true)
        _resolved_flags=$(printf '%s\n' "$_resolved_env" | sed -n 's/^COMPOSE_FLAGS="\([^"]*\)".*/\1/p')
        if [[ -n "$_resolved_flags" ]]; then
            read -ra COMPOSE_ARGS <<< "$_resolved_flags"
            printf '%s\n' "$_resolved_flags" > "$INSTALL_DIR/.compose-flags"
            log "Recovered compose flags via resolve-compose-stack.sh"
        fi
    elif [[ -f "$INSTALL_DIR/docker-compose.base.yml" ]]; then
        COMPOSE_ARGS=(-f "$INSTALL_DIR/docker-compose.base.yml")
        case "${_gpu_backend}" in
            nvidia) [[ -f "$INSTALL_DIR/docker-compose.nvidia.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.nvidia.yml") ;;
            amd)    [[ -f "$INSTALL_DIR/docker-compose.amd.yml" ]]    && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.amd.yml") ;;
            apple)
                # On Darwin hosts the canonical macOS overlay lives at
                # installers/macos/docker-compose.macos.yml (native Metal llama-server
                # replicas: 0, llama-server-ready sidecar, host.docker.internal for
                # dashboard-api). The top-level docker-compose.apple.yml remains
                # valid for Linux hosts that select --gpu-backend apple (Lemonade).
                # Mirror the branch in scripts/resolve-compose-stack.sh so that the
                # .compose-flags fallback selects the same overlay the resolver does.
                if [[ "$(uname -s)" == "Darwin" && -f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml" ]]; then
                    COMPOSE_ARGS+=(-f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml")
                elif [[ -f "$INSTALL_DIR/docker-compose.apple.yml" ]]; then
                    COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.apple.yml")
                fi
                ;;
            # cpu or unknown: base only, no GPU overlay
        esac
    fi

    cd "$INSTALL_DIR" || fail "Cannot cd to $INSTALL_DIR"

    # Restart llama-server — strategy depends on GPU backend:
    # - AMD (Lemonade): use 'restart' to preserve cached llama-server build.
    #   Lemonade reads models.ini at startup, so it picks up the new model.
    # - NVIDIA/CPU (llama.cpp): force-recreate so the new GGUF_FILE in .env
    #   takes effect. `compose stop` + `compose up -d` is NOT enough — when the
    #   service has a stopped container, compose will start the existing
    #   container in place (preserving its baked --model arg) instead of
    #   building a fresh one from the updated .env. The original CMD points at
    #   /models/${BOOTSTRAP_GGUF_FILE}, which Phase 4b just deleted, so the
    #   container crash-loops. `--force-recreate --no-deps` guarantees a new
    #   container; --no-deps avoids touching other services in the project.
    log "Restarting llama-server container (backend: ${_gpu_backend:-unknown})..."
    # Both backends need a container *recreate*, not just a restart, so the
    # updated CTX_SIZE / MAX_CONTEXT / GGUF_FILE env values from the
    # freshly-bumped .env land in the new container. A plain `compose
    # restart` preserves the original env vars from when the container
    # first started — which on AMD/Lemonade leaves LEMONADE_CTX_SIZE pinned
    # at the bootstrap-tier value (e.g. 8192) even after .env says 131072.
    # The lemonade-entrypoint.sh wrapper then sees the stale env value,
    # never updates /root/.cache/lemonade/config.json, and Lemonade serves
    # the full model at the bootstrap context size — Hermes then returns
    # empty responses in ~1s because its 16k-token tool catalog overruns
    # the 8k context window. OpenCode hits the same wall.
    #
    # `env -u` strips the model-config vars from compose's shell so the
    # freshly-updated .env wins interpolation. Compose precedence is
    # shell-env > .env > compose default, and Phase 11 (parent of this
    # nohup'd script) sets the bootstrap-tier values as shell variables.
    #
    # Named volumes (lemonade-cache / lemonade-llama / lemonade-recipe on
    # AMD) survive --force-recreate, so the Lemonade binary cache + HF
    # cache + recipe state all persist across the recreate. The older
    # "AMD uses restart to preserve cached binary" comment was wrong —
    # named volumes are decoupled from the container lifecycle.
    if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then
        compose_recreate_llama_server_with_retry "${COMPOSE_ARGS[@]}" || \
            log "WARNING: llama-server recreate command failed after retries; continuing to health check before declaring failure."
    else
        # No reliable compose stack is available. Do NOT stop/remove the
        # currently-running bootstrap container here: leaving an old but
        # serving model online is safer than turning a completed download into
        # an outage. The operator can repair the compose cache or re-run the
        # installer; both paths can recreate llama-server from the updated .env.
        log "WARNING: unable to recover compose flags — leaving the current llama-server container untouched."
        log "Manual recovery: re-run the installer, or restore $INSTALL_DIR/.compose-flags and run the ODS CLI restart command."
        restore_active_model_config || log "WARNING: could not restore previous active model config; inspect $ENV_FILE and $MODELS_INI"
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Full model downloaded and verified, but ODS could not recover compose flags for Docker hot-swap. Previous active model config restored; re-run to retry."
        exit 1
    fi

    # Pick health endpoint based on GPU backend — Lemonade (AMD) serves
    # /api/v1/health, llama.cpp (NVIDIA/Apple/CPU) serves /health.
    if [[ "$_gpu_backend" == "amd" ]]; then
        _health_url="http://127.0.0.1:${OLLAMA_PORT:-8080}/api/v1/health"
    else
        _health_url="http://127.0.0.1:${OLLAMA_PORT:-8080}/health"
    fi

    # Wait for health (up to 5 minutes for the larger model to load)
    # For AMD/Lemonade: check that model_loaded is non-null in the JSON response.
    # Lemonade returns 200 with "model_loaded": null when no model is loaded yet.
    # Lemonade doesn't auto-load models from models.ini — it uses --extra-models-dir
    # for discovery but loads on-demand. We send a warm-up request to trigger loading.
    # For llama.cpp: a simple 200 check is sufficient — the server only starts
    # after loading the model specified in --model.
    log "Waiting for llama-server health at $_health_url ..."
    _healthy=false
    _warmup_sent=false
    for _i in $(seq 1 60); do
        _resp=$(curl -sf --max-time 5 "$_health_url" 2>/dev/null || echo "")
        if [[ -n "$_resp" ]]; then
            if [[ "$_gpu_backend" == "amd" ]]; then
                # Lemonade: verify a model is actually loaded, not just "status: ok"
                if echo "$_resp" | grep -q '"model_loaded"' && ! echo "$_resp" | grep -q '"model_loaded": *null'; then
                    _healthy=true
                    break
                fi
                # Lemonade is healthy but no model loaded — send a warm-up request
                # to trigger on-demand loading of the new model. Lemonade caches the
                # previously-loaded model name across restarts, which fails after the
                # bootstrap GGUF is deleted. This request forces it to load the new one.
                # Retry every 15s — the first request may fail if Lemonade isn't fully
                # ready to accept chat completions yet.
                if [[ "$_warmup_sent" == "false" ]] || (( _i % 3 == 0 )); then
                    # Escape any double-quotes in the filename so the JSON body
                    # below stays well-formed even for non-standard library entries.
                    # Mirrors the _safe_model pattern in write_status() above.
                    _model_id="extra.${FULL_GGUF_FILE//\"/\\\"}"
                    log "Sending warm-up request to trigger model loading: $_model_id (attempt $_i/60)"
                    if curl -sf --max-time 30 -X POST \
                        "http://127.0.0.1:${OLLAMA_PORT:-8080}/api/v1/chat/completions" \
                        -H "Content-Type: application/json" \
                        -d "{\"model\":\"${_model_id}\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"max_tokens\":1}" \
                        &>/dev/null; then
                        _warmup_sent=true
                        log "Warm-up request accepted — waiting for model to finish loading"
                    fi
                fi
                log "Lemonade healthy but no model loaded yet (attempt $_i/60)"
            else
                # llama.cpp: 200 means model is loaded
                _healthy=true
                break
            fi
        fi
        if [[ "$_gpu_backend" != "amd" ]] && docker_llama_server_container_failed_after_swap; then
            log "llama-server container exited or is restarting while loading the full model; treating Docker hot-swap as failed."
            break
        fi
        sleep 5
    done

    # Assert the recreated container's --model arg actually points at the new
    # GGUF file. If compose handed us back a started-not-recreated container
    # (the bug --force-recreate above is meant to prevent), the health check
    # may still pass on Lemonade (which loads on demand) but llama.cpp will
    # crash-loop the moment the next request hits, because Phase 4b has
    # already deleted the bootstrap GGUF the baked CMD refers to. Fail loudly
    # so the operator does not discover this hours later via a 502.
    if [[ "$_gpu_backend" != "amd" ]] && [[ -n "$DOCKER_CMD" ]]; then
        _running_cmd=$($DOCKER_CMD inspect ods-llama-server --format '{{join .Config.Cmd " "}}' 2>/dev/null || echo "")
        if [[ -z "$_running_cmd" ]]; then
            log "ERROR: could not inspect llama-server container command after recreate."
            log "  Recover with: cd $INSTALL_DIR && docker compose \$(cat .compose-flags) up -d --force-recreate --no-deps llama-server"
            write_status "failed"
            fail "llama-server command inspection failed after force-recreate."
        elif ! [[ "$_running_cmd" == *"/models/${FULL_GGUF_FILE}"* ]]; then
            log "ERROR: llama-server container started with stale --model arg."
            log "  expected /models/${FULL_GGUF_FILE}, got: $_running_cmd"
            log "  This means 'compose up -d --force-recreate' did not pick up the updated .env."
            # Dump what compose would have seen so a future regression can be
            # diagnosed from logs alone. If any of these have non-empty values,
            # they overrode the .env at compose interpolation time.
            for _k in GGUF_FILE LLM_MODEL MAX_CONTEXT CTX_SIZE; do
                _v="$(printenv "$_k" 2>/dev/null || true)"
                if [[ -n "$_v" ]]; then
                    log "  shell env leak: $_k=$_v (overrode .env's $_k)"
                fi
            done
            log "  .env now has:"
            for _k in GGUF_FILE LLM_MODEL MAX_CONTEXT CTX_SIZE; do
                log "    $(grep -E "^${_k}=" "$ENV_FILE" 2>/dev/null || echo "${_k}=<missing>")"
            done
            log "  Recover with: cd $INSTALL_DIR && env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE docker compose \$(cat .compose-flags) up -d --force-recreate --no-deps llama-server"
            write_status "failed"
            fail "llama-server container started with stale --model arg after force-recreate."
        fi
    fi

    if $_healthy; then
        log "SUCCESS: llama-server is running with $FULL_LLM_MODEL"
        HOT_SWAP_VERIFIED=true
        discard_active_model_config_snapshot
        # Regenerate lemonade.yaml with the new model ID and restart LiteLLM.
        # Lemonade exposes models as "extra.<GGUF_FILE>" — the config must
        # reference the exact ID, not a wildcard passthrough.
        if $DOCKER_CMD ps --filter name=ods-litellm --format '{{.Names}}' 2>/dev/null | grep -q ods-litellm; then
            log "Updating LiteLLM config for new model: extra.${FULL_GGUF_FILE}"
            # Read per-install lemonade key from .env; fall back to literal so
            # older installs without the key still produce a valid config (lemonade
            # itself ignores the value).
            LITELLM_LEMONADE_API_KEY=$(grep '^LITELLM_LEMONADE_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"\047\r')
            : "${LITELLM_LEMONADE_API_KEY:=sk-lemonade}"
            # extra_body.chat_template_kwargs.enable_thinking=false is what
            # un-blocks Perplexica + any other client that doesn't manually
            # prepend `/no_think` to its prompts. Qwen3 thinking models
            # (qwen3-coder-next, Qwen3.6-35B-A3B, Qwen3-30B-A3B) emit a
            # <think>...</think> reasoning block before the answer, which on
            # Strix Halo with a long Perplexica synthesis prompt can run for
            # minutes — the user sees the GPU spin up but no tokens reach the
            # UI until the close-think tag fires. Passing
            # `chat_template_kwargs: {enable_thinking: false}` to llama.cpp /
            # Lemonade hits the Qwen3 chat template's enable_thinking switch
            # and skips the reasoning block entirely.
            #
            # Verified 2026-05-20 on Strix Halo (AMD/Lemonade): same prompt
            # went from "hangs indefinitely" to 1.8s end-to-end with this
            # one block added. The kwarg is a Qwen3-specific switch and is
            # safely ignored by non-Qwen3 chat templates.
            _lemonade_api_base="http://llama-server:8080/api/v1"
            _amd_location="$(read_env_value AMD_INFERENCE_LOCATION | tr '[:upper:]' '[:lower:]')"
            _amd_port="$(read_env_value AMD_INFERENCE_PORT)"
            : "${_amd_port:=8080}"
            if [[ "$_amd_location" == "host" ]]; then
                _lemonade_api_base="http://host.docker.internal:${_amd_port}/api/v1"
            fi
            _renderer_ok=false
            _renderer_script="$INSTALL_DIR/scripts/render-runtime-configs.py"
            _renderer_py="${ODS_PYTHON_CMD:-}"
            if [[ -z "$_renderer_py" && -f "$INSTALL_DIR/lib/python-cmd.sh" ]]; then
                . "$INSTALL_DIR/lib/python-cmd.sh"
                _renderer_py="$(ods_detect_python_cmd 2>/dev/null || true)"
            fi
            if [[ -z "$_renderer_py" ]]; then
                _renderer_py="python3"
            fi
            if [[ -f "$_renderer_script" ]] && command -v "$_renderer_py" >/dev/null 2>&1; then
                if "$_renderer_py" "$_renderer_script" \
                    --surface litellm-lemonade \
                    --ods-mode lemonade \
                    --gpu-backend amd \
                    --gguf-file "$FULL_GGUF_FILE" \
                    --lemonade-api-base "$_lemonade_api_base" \
                    --litellm-key "$LITELLM_LEMONADE_API_KEY" \
                    --output-root "$INSTALL_DIR" \
                    --write >/dev/null 2>&1; then
                    _renderer_ok=true
                else
                    log "WARNING: Runtime config renderer failed for LiteLLM; falling back to inline writer"
                fi
            fi
            if [[ "$_renderer_ok" != "true" ]]; then
                cat > "$INSTALL_DIR/config/litellm/lemonade.yaml" << LITELLM_UPGRADE_EOF
model_list:
  - model_name: default
    litellm_params:
      model: openai/extra.${FULL_GGUF_FILE}
      api_base: ${_lemonade_api_base}
      api_key: ${LITELLM_LEMONADE_API_KEY}
      extra_body:
        chat_template_kwargs:
          enable_thinking: false

  - model_name: "*"
    litellm_params:
      model: openai/extra.${FULL_GGUF_FILE}
      api_base: ${_lemonade_api_base}
      api_key: ${LITELLM_LEMONADE_API_KEY}
      extra_body:
        chat_template_kwargs:
          enable_thinking: false

litellm_settings:
  drop_params: true
  set_verbose: false
  request_timeout: 900
  stream_timeout: 900
LITELLM_UPGRADE_EOF
            fi
            unset _renderer_ok _renderer_script _renderer_py _lemonade_api_base _amd_location _amd_port
            log "Restarting LiteLLM to pick up model change..."
            $DOCKER_CMD restart ods-litellm 2>&1 || log "WARNING: LiteLLM restart failed (non-fatal)"
        fi
        # Recreate OpenClaw so inject-token.js picks up the new GGUF_FILE/LLM_MODEL
        # from .env. A restart alone won't work — env vars are baked in at container
        # creation time, and inject-token.js builds the Lemonade model name from them.
        # Strip the same model-config vars here as the llama-server recreate path
        # so shell-env pollution cannot override the freshly-updated .env.
        if $DOCKER_CMD ps --filter name=ods-openclaw --format '{{.Names}}' 2>/dev/null | grep -q ods-openclaw; then
            log "Recreating OpenClaw to pick up model change..."
            # Guard on BOTH compose args AND a non-empty $DOCKER_COMPOSE_CMD —
            # mirrors the llama-server hot-swap contract above (the
            # `${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD"` checks).
            # If $DOCKER_COMPOSE_CMD is empty (no compose v2 plugin AND no
            # docker-compose v1 binary), expanding it as the command word
            # would turn the line into `"${COMPOSE_ARGS[@]}" up -d ...`,
            # which executes the first compose-arg (e.g. `-f`) as a binary.
            # Skip the recreate and surface a clear warning instead.
            if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then
                env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE \
                    $DOCKER_COMPOSE_CMD "${COMPOSE_ARGS[@]}" up -d --force-recreate openclaw 2>&1 || \
                    log "WARNING: OpenClaw recreate failed (non-fatal)"
            else
                log "WARNING: No compose binary available (DOCKER_COMPOSE_CMD empty or compose args missing) — OpenClaw was NOT recreated. The new model will not take effect until OpenClaw is recreated manually with: env -u GGUF_FILE -u LLM_MODEL -u MAX_CONTEXT -u CTX_SIZE docker compose up -d --force-recreate openclaw"
            fi
        fi
        # Patch Hermes Agent's config so it stops asking the LLM server for the
        # bootstrap model id. PR #1191 substitutes model.default in the template
        # at install time, but at install time we've only loaded the bootstrap
        # model (Qwen3.5-2B) — Hermes's /opt/data/config.yaml is therefore
        # pinned to that name. Once this script swaps Lemonade/llama-server
        # to the full model, Hermes keeps sending the stale bootstrap id and
        # every chat completion 404s.
        #
        # This is hard-broken on AMD/Lemonade (which strictly validates the
        # `model` field) and silently masked on NVIDIA/Apple (llama.cpp
        # ignores the field and serves whatever's loaded), so the bug
        # surfaces as "Hermes works on Tower2/Mac but every prompt 404s on
        # Strix Halo" after a bootstrap-to-full swap.
        #
        # Three files/views to keep in sync:
        #   1. data/hermes/config.yaml on the host — the bind-mounted live
        #      config that persists across Hermes restarts.
        #   2. /opt/data/config.yaml inside the container — the same live
        #      config from Hermes's view. Patch via docker exec too so Linux
        #      container-owned files can still be recovered.
        #   3. extensions/services/hermes/cli-config.yaml.template — the
        #      source Hermes copies into /opt/data on first start. Updating
        #      it keeps subsequent down-and-up cycles correct.
        # Lemonade prefixes the served model id with "extra."; llama.cpp
        # serves under the bare file name. Mirror the same branch PR #1191
        # added in installers/phases/11-services.sh.
        _hermes_old_model="$BOOTSTRAP_GGUF_FILE"
        _hermes_new_model="$FULL_GGUF_FILE"
        _hermes_base_url="$(read_env_value HERMES_LLM_BASE_URL)"
        _gpu_backend_for_hermes=$(grep -E '^GPU_BACKEND=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "")
        if [[ "$_gpu_backend_for_hermes" == "amd" ]]; then
            _hermes_old_model="extra.$BOOTSTRAP_GGUF_FILE"
            _hermes_new_model="extra.$FULL_GGUF_FILE"
        fi
        log "Patching Hermes config: model.default $_hermes_old_model -> $_hermes_new_model"
        _hermes_request_timeout=180
        _hermes_llm_backend_for_timeout="$(read_env_value LLM_BACKEND | tr '[:upper:]' '[:lower:]')"
        if is_windows_bash || [[ "$_gpu_backend_for_hermes" == "amd" || "$_hermes_llm_backend_for_timeout" == "lemonade" ]]; then
            _hermes_request_timeout=900
        fi

        # Template on host (user-owned, no sudo needed). Patch this even when
        # Hermes is stopped so future container creates do not copy the stale
        # bootstrap model id.
        _hermes_tpl="$INSTALL_DIR/extensions/services/hermes/cli-config.yaml.template"
        if [[ -f "$_hermes_tpl" ]]; then
            if ! patch_hermes_yaml_with_sed "$_hermes_tpl" "$_hermes_new_model" "$FULL_MAX_CONTEXT" "$_hermes_base_url" "$_hermes_request_timeout"; then
                log "WARNING: Could not patch ${_hermes_tpl} (non-fatal; operator can hand-edit before restarting Hermes)"
            fi
        fi

        _hermes_live="$INSTALL_DIR/data/hermes/config.yaml"
        _hermes_live_host_patched=false
        if [[ -f "$_hermes_live" ]]; then
            if patch_hermes_yaml_with_sed "$_hermes_live" "$_hermes_new_model" "$FULL_MAX_CONTEXT" "$_hermes_base_url" "$_hermes_request_timeout"; then
                _hermes_live_host_patched=true
            else
                log "WARNING: Could not patch ${_hermes_live} on host (non-fatal if container patch below succeeds)"
            fi
        fi

        if $DOCKER_CMD ps --filter name=ods-hermes --format '{{.Names}}' 2>/dev/null | grep -q ods-hermes; then
            # Live config inside the running container (owned by container UID).
            _hermes_new_model_sed="$(printf '%s' "$_hermes_new_model" | sed 's/[\\&|]/\\&/g')"
            _hermes_base_url_sed="$(printf '%s' "$_hermes_base_url" | sed 's/[\\&|]/\\&/g')"
            _hermes_live_patch="sed -i -e 's|^  default: \".*\"[[:space:]]*$|  default: \"${_hermes_new_model_sed}\"|' -e 's|^  context_length: .*|  context_length: ${FULL_MAX_CONTEXT}|' -e 's|^    context_length: .*|    context_length: ${FULL_MAX_CONTEXT}|'"
            if [[ -n "$_hermes_base_url" ]]; then
                _hermes_live_patch="${_hermes_live_patch} -e 's|^  base_url: \".*\"|  base_url: \"${_hermes_base_url_sed}\"|'"
            fi
            if [[ "$_hermes_request_timeout" != "180" ]]; then
                _hermes_live_patch="${_hermes_live_patch} -e 's|^    request_timeout_seconds: 180[[:space:]]*$|    request_timeout_seconds: ${_hermes_request_timeout}|'"
            fi
            _hermes_live_patch="${_hermes_live_patch} -e 's|^  enabled: .*|  enabled: true|' -e 's|^  threshold: .*|  threshold: 0.75|' -e 's|^  target_ratio: .*|  target_ratio: 0.50|' -e 's|^  protect_last_n: .*|  protect_last_n: 40|' /opt/data/config.yaml"
            $DOCKER_CMD exec ods-hermes sh -c \
                "$_hermes_live_patch" 2>&1 || \
                log "WARNING: Could not patch Hermes /opt/data/config.yaml (non-fatal — operator can hand-edit and 'docker restart ods-hermes')"
            log "Restarting Hermes to pick up model change..."
            $DOCKER_CMD restart ods-hermes 2>&1 || log "WARNING: Hermes restart failed (non-fatal — hand-restart with 'docker restart ods-hermes')"

            # Pre-warm the freshly-swapped LLM + Hermes's 14K-token system prompt.
            #
            # Two latency hits if we skip this:
            #   1. llama-server / Lemonade loads the full model into VRAM on first
            #      request (`--n-gpu-layers 999` is lazy). PR #1192 already warms
            #      this at install time, but that warm-up was against the
            #      bootstrap model — after the swap, the slot is cold again.
            #   2. Hermes's runtime config bakes a 14K-token system prompt
            #      (skills, soul, tool descriptors). First Hermes prompt has
            #      to prefill all of it. Empirically 67s on Strix Halo,
            #      1m25s on macOS, ~5s once cached. We've seen real users
            #      think Hermes is broken because they alt-tabbed away during
            #      a fresh install and the first prompt looked stuck.
            #
            # Mirrors PR #1192's pattern: best-effort, time-bounded, never fails
            # the upgrade. If either warm-up times out the swap still succeeds —
            # the user just eats the slow first call.
            log "Pre-warming llama-server slot with full model..."
            _prewarm_api_path="/v1"
            _prewarm_model="$FULL_GGUF_FILE"
            if [[ "$_gpu_backend_for_hermes" == "amd" ]]; then
                _prewarm_api_path="/api/v1"
                _prewarm_model="extra.$FULL_GGUF_FILE"
            fi
            _prewarm_body="{\"model\":\"${_prewarm_model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1,\"temperature\":0,\"stream\":false}"
            if $DOCKER_CMD exec ods-hermes curl -sf --max-time 120 -X POST \
                "http://llama-server:8080${_prewarm_api_path}/chat/completions" \
                -H "Content-Type: application/json" \
                -d "$_prewarm_body" >/dev/null 2>&1; then
                log "llama-server slot pre-warmed."
            else
                log "WARNING: llama-server pre-warm timed out — first Hermes prompt may be slow."
            fi

            # Wait for Hermes to come back up after the restart, then trigger
            # one no-op invocation so the 14K system prompt gets into
            # llama-server's KV cache. We cap at 90s total — long enough for
            # Hermes's skills sync + config bootstrap (start_period: 60s in
            # compose.yaml) plus a few decode tokens, short enough that a
            # broken Hermes doesn't stall the script forever.
            log "Pre-warming Hermes system prompt (caches 14K-token prefill)..."
            _hermes_ready=false
            for _i in $(seq 1 30); do
                if $DOCKER_CMD exec ods-hermes curl -sf --max-time 3 http://127.0.0.1:9119/api/status >/dev/null 2>&1; then
                    _hermes_ready=true
                    break
                fi
                sleep 2
            done
            if $_hermes_ready; then
                if $DOCKER_CMD exec ods-hermes timeout 90 \
                    /opt/hermes/.venv/bin/hermes -z "ping" --yolo \
                    >/dev/null 2>&1; then
                    log "Hermes system prompt cached — first user prompt will be fast."
                else
                    log "WARNING: Hermes warm-up timed out (>90s). First user prompt will incur the full 14K-token prefill."
                fi
            else
                log "WARNING: Hermes did not respond on /api/status within 60s; skipping system-prompt warm-up."
            fi
        else
            if [[ -f "$_hermes_live" && "$_hermes_live_host_patched" != "true" ]]; then
                log "WARNING: Hermes is stopped and ${_hermes_live} could not be patched; operator can hand-edit and restart Hermes"
            fi
        fi
        sync_windows_opencode_config
    else
        log "WARNING: llama-server health check timed out. The model may still be loading."
        log "Check: docker logs ods-llama-server"
        _rollback_status="Previous active model config restore was attempted; inspect docker logs ods-llama-server and re-run to retry."
        if restore_docker_llama_server_after_swap_failure "$_health_url"; then
            _rollback_status="Previous active model config restored and llama-server is healthy; re-run to retry the full-model swap."
        fi
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Full model downloaded and verified, but Docker llama-server did not become healthy after the hot-swap. ${_rollback_status}"
        exit 1
    fi
elif [[ -f "$INSTALL_DIR/data/.llama-server.pid" ]]; then
    # macOS native llama-server (Metal) — restart with new model
    log "Detected native llama-server (macOS Metal mode)"

    LLAMA_SERVER_BIN="$INSTALL_DIR/bin/llama-server"
    LLAMA_SERVER_PID_FILE="$INSTALL_DIR/data/.llama-server.pid"
    LLAMA_SERVER_LOG="$INSTALL_DIR/data/llama-server.log"

    if [[ ! -x "$LLAMA_SERVER_BIN" ]]; then
        log "WARNING: llama-server binary not found at $LLAMA_SERVER_BIN. Cannot hot-swap."
        log "Run './ods-macos.sh restart' to load the new model manually."
    else
        # Read updated model config from .env
        _gguf_file=$(grep '^GGUF_FILE=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'")
        _ctx_size=$(grep '^CTX_SIZE=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" || echo "")
        [[ -z "$_ctx_size" ]] && _ctx_size=$(grep '^MAX_CONTEXT=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" || echo "")
        [[ -z "$_ctx_size" ]] && _ctx_size="16384"
        _model_path="$MODELS_DIR/${_gguf_file}"

        if [[ ! -f "$_model_path" ]]; then
            log "WARNING: Model file not found at $_model_path"
        else
            # Capture old model path for rollback before we kill the process
            _old_pid=$(cat "$LLAMA_SERVER_PID_FILE" 2>/dev/null | tr -d '[:space:]')
            _old_model_path=""
            if [[ -n "$_old_pid" ]] && kill -0 "$_old_pid" 2>/dev/null; then
                _old_model_path=$(ps -p "$_old_pid" -o args= 2>/dev/null | grep -oE '\-\-model [^ ]+' | awk '{print $2}') || true
            fi

            # Stop existing native llama-server
            if [[ -n "$_old_pid" ]] && kill -0 "$_old_pid" 2>/dev/null; then
                # Verify it's actually llama-server (PID could have been reused)
                if ps -p "$_old_pid" -o comm= 2>/dev/null | grep -q llama; then
                    log "Stopping native llama-server (PID $_old_pid)..."
                    kill "$_old_pid" 2>/dev/null || true
                    sleep 2
                    if kill -0 "$_old_pid" 2>/dev/null; then
                        kill -9 "$_old_pid" 2>/dev/null || true
                    fi
                else
                    log "PID $_old_pid is no longer llama-server, skipping kill"
                fi
            fi

            # Read reasoning mode from .env (default off to prevent thinking models
            # from consuming the entire token budget on internal reasoning)
            _reasoning=$(grep '^LLAMA_REASONING=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "")
            [[ -z "$_reasoning" ]] && _reasoning="off"
            case "$_reasoning" in
                off)  _reasoning_fmt="none" ;;
                on)   _reasoning_fmt="deepseek" ;;
                *)    _reasoning_fmt="$_reasoning" ;;
            esac

            # Honour the unified BIND_ADDRESS knob (PR #964); empty/missing → loopback.
            _bind=$(grep '^BIND_ADDRESS=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            [[ -z "$_bind" ]] && _bind="127.0.0.1"
            _flash_attn=$(grep '^LLAMA_ARG_FLASH_ATTN=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _cache_type_k=$(grep '^LLAMA_ARG_CACHE_TYPE_K=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _cache_type_v=$(grep '^LLAMA_ARG_CACHE_TYPE_V=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _n_cpu_moe=$(grep '^LLAMA_ARG_N_CPU_MOE=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _spec_type=$(grep '^LLAMA_ARG_SPEC_TYPE=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _spec_draft_n_max=$(grep '^LLAMA_ARG_SPEC_DRAFT_N_MAX=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' || echo "")
            _llama_args=(
                --host "$_bind" --port 8080
                --model "$_model_path"
                --ctx-size "$_ctx_size"
                --n-gpu-layers 999
                --reasoning-format "$_reasoning_fmt"
                --metrics
            )
            [[ -n "$_flash_attn" ]] && _llama_args+=(--flash-attn "$_flash_attn")
            [[ -n "$_cache_type_k" ]] && _llama_args+=(--cache-type-k "$_cache_type_k")
            [[ -n "$_cache_type_v" ]] && _llama_args+=(--cache-type-v "$_cache_type_v")
            [[ -n "$_n_cpu_moe" ]] && _llama_args+=(--n-cpu-moe "$_n_cpu_moe")
            [[ -n "$_spec_type" ]] && _llama_args+=(--spec-type "$_spec_type")
            [[ -n "$_spec_draft_n_max" ]] && _llama_args+=(--spec-draft-n-max "$_spec_draft_n_max")

            # Relaunch with new model
            log "Starting native llama-server with ${_gguf_file}..."
            (
                cd "$INSTALL_DIR" || exit 1
                exec "$LLAMA_SERVER_BIN" "${_llama_args[@]}"
            ) > "$LLAMA_SERVER_LOG" 2>&1 &
            _new_pid=$!
            echo "$_new_pid" > "$LLAMA_SERVER_PID_FILE"

            # Wait for health
            log "Waiting for native llama-server health..."
            _healthy=false
            for _i in $(seq 1 60); do
                if curl -sf --max-time 5 "http://127.0.0.1:8080/health" &>/dev/null; then
                    _healthy=true
                    break
                fi
                sleep 5
            done

            if $_healthy; then
                log "SUCCESS: Native llama-server running with ${_gguf_file} (PID $_new_pid)"
                HOT_SWAP_VERIFIED=true
            else
                log "WARNING: New model failed to load. Attempting rollback..."
                kill "$_new_pid" 2>/dev/null || true
                sleep 2
                if kill -0 "$_new_pid" 2>/dev/null; then
                    kill -9 "$_new_pid" 2>/dev/null || true
                fi
                if [[ -n "${_old_model_path:-}" && -f "$_old_model_path" ]]; then
                    (
                        cd "$INSTALL_DIR" || exit 1
                        exec "$LLAMA_SERVER_BIN" \
                            --host "$_bind" --port 8080 \
                            --model "$_old_model_path" \
                            --ctx-size "$_ctx_size" \
                            --n-gpu-layers 999 \
                            --reasoning-format "${_reasoning_fmt:-none}" \
                            --metrics
                    ) > "$LLAMA_SERVER_LOG" 2>&1 &
                    _rollback_pid=$!
                    echo "$_rollback_pid" > "$LLAMA_SERVER_PID_FILE"
                    log "Rolled back to previous model: $(basename "$_old_model_path") (PID $_rollback_pid)"
                else
                    log "WARNING: Could not rollback — previous model not found."
                    log "Run './ods-macos.sh restart' to manually recover."
                fi
                write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
                    "Full model downloaded and verified, but native macOS llama-server did not load it after swap. Bootstrap model kept; run './ods-macos.sh restart' or re-run to retry."
                exit 1
            fi
        fi
    fi
else
    log "Docker services not running. Config updated — full model will load on next start."
    discard_active_model_config_snapshot
fi

# ── Phase 5b: Remove bootstrap model only after verified full-model serving ──
# Lemonade's --extra-models-dir auto-discovers all GGUFs in /models. Removing
# the bootstrap too early can wedge Windows Lemonade: it may keep serving the
# old model id while the file is gone, producing 500s until a manual restart.
# Keep the bootstrap as the recovery path unless the new model has answered a
# real completion.
if [[ "$HOT_SWAP_VERIFIED" == "true" && -f "$BOOTSTRAP_PATH" && "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" ]]; then
    log "Removing bootstrap model after verified full-model serving: $BOOTSTRAP_GGUF"
    rm -f "$BOOTSTRAP_PATH"
    log "Bootstrap model removed"
    if ! refresh_lemonade_after_bootstrap_cleanup; then
        write_status "failed" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 \
            "Full model served, but ODS could not refresh Lemonade after removing the bootstrap model. Re-run to retry."
        fail "Lemonade refresh after bootstrap cleanup failed."
    fi
elif [[ "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" && -f "$BOOTSTRAP_PATH" ]]; then
    log "Keeping bootstrap model until the full model is verified serving: $BOOTSTRAP_GGUF"
fi

# ── Phase 5c: Update Perplexica's defaultChatModel ──
# Phase 12 of the installer configures Perplexica with whatever LLM_MODEL was
# in scope at that time — which on bootstrap installs is the bootstrap model
# name (e.g. qwen3.5-2b), NOT the full model. Without an update here,
# Perplexica's settings.preferences.defaultChatModel stays "qwen3.5-2b"
# forever, even after the hot-swap replaces the underlying GGUF. The UI
# shows the wrong model name in the dropdown, and the chatModels list under
# the OpenAI provider keeps the bootstrap entry instead of the full model.
#
# Requests still functionally route via LiteLLM's `*` wildcard or llama.cpp's
# served-model-passthrough, so this hasn't been a hard failure — but it's a
# cosmetic + future-proofing issue (a non-wildcard router would 404 the
# stale model id). Mirror the install-time logic from
# installers/phases/12-health.sh:194-238: update modelProviders + preferences
# via Perplexica's `/api/config` PUT endpoint.
PERPLEXICA_PORT=$(grep -E '^PERPLEXICA_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r')
: "${PERPLEXICA_PORT:=3004}"
_perplexica_url="http://127.0.0.1:${PERPLEXICA_PORT}"
if curl -sf --max-time 3 "${_perplexica_url}/api/config" >/dev/null 2>&1; then
    log "Updating Perplexica config to point at ${FULL_LLM_MODEL}..."
    _py_cmd="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
    if [[ -n "$_py_cmd" ]]; then
        # On Lemonade, LiteLLM exposes the model id as "extra.<GGUF_FILE>".
        # On NVIDIA/Apple/CPU, llama.cpp serves under the bare GGUF id.
        _px_model="$FULL_GGUF_FILE"
        _runtime_for_perplexica=$(grep -E '^AMD_INFERENCE_RUNTIME=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' | tr '[:upper:]' '[:lower:]' || echo "")
        _llm_backend_for_perplexica=$(grep -E '^LLM_BACKEND=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' | tr '[:upper:]' '[:lower:]' || echo "")
        if [[ "$_runtime_for_perplexica" == "lemonade" || "$_llm_backend_for_perplexica" == "lemonade" ]]; then
            _px_model="extra.$FULL_GGUF_FILE"
        fi
        _litellm_key=$(grep -E '^LITELLM_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "no-key")
        : "${_litellm_key:=no-key}"
        _px_base_url=$(grep -E '^LLM_API_URL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"\047\r' || echo "http://llama-server:8080")
        : "${_px_base_url:=http://llama-server:8080}"
        case "$_px_base_url" in
            */v1|*/api/v1) ;;
            *) _px_base_url="${_px_base_url%/}/v1" ;;
        esac
        if curl -sf --max-time 3 "${_perplexica_url}/api/config" 2>/dev/null | \
            PERPLEXICA_URL="$_perplexica_url" \
            PX_MODEL="$_px_model" \
            PX_KEY="$_litellm_key" \
            PX_BASE_URL="$_px_base_url" \
            "$_py_cmd" -c '
import os, sys, json, urllib.request
config = json.load(sys.stdin)["values"]
providers = config.get("modelProviders", [])
openai_prov = next((p for p in providers if p["type"] == "openai"), None)
if not openai_prov:
    sys.exit(0)  # Perplexica has no OpenAI provider configured; skip (non-fatal)
url = os.environ["PERPLEXICA_URL"] + "/api/config"
model = os.environ["PX_MODEL"]
key = os.environ["PX_KEY"]
base_url = os.environ["PX_BASE_URL"]
def post(k, v):
    data = json.dumps({"key": k, "value": v}).encode()
    req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=5)
def post_setup_complete():
    setup_url = os.environ["PERPLEXICA_URL"] + "/api/config/setup-complete"
    req = urllib.request.Request(setup_url, data=b"{}", headers={"Content-Type": "application/json"})
    try:
        urllib.request.urlopen(req, timeout=5)
    except Exception:
        post("setupComplete", True)
openai_prov["chatModels"] = [{"key": model, "name": model}]
# Refresh the provider config so Perplexica can reach the active ODS route.
prov_config = openai_prov.get("config") or {}
prov_config["apiKey"] = key
prov_config["baseURL"] = base_url
openai_prov["config"] = prov_config
post("modelProviders", providers)
prefs = config.get("preferences", {})
prefs["defaultChatModel"] = model
prefs["defaultChatProvider"] = openai_prov["id"]
post("preferences", prefs)
post_setup_complete()
print("ok")
' >/dev/null 2>&1; then
            log "Perplexica defaultChatModel updated to ${_px_model}."
        else
            log "WARNING: Perplexica config update failed (non-fatal — defaultChatModel may still read the bootstrap value)"
        fi
    else
        log "WARNING: python3 not found, skipping Perplexica config update"
    fi
fi

# ── Phase 6: Restart host agent (if running) ──
# The host agent may cache stale state — restart it so it picks up the new
# model config and any updated endpoints.
if command -v systemctl &>/dev/null && systemctl --user is-active ods-host-agent.service &>/dev/null; then
    log "Restarting ods-host-agent (systemd)..."
    systemctl --user restart ods-host-agent.service 2>&1 || \
        log "WARNING: Could not restart host agent (non-fatal)"
elif [[ -f "$HOME/Library/LaunchAgents/com.ods.host-agent.plist" ]]; then
    log "Restarting ods-host-agent (launchctl)..."
    launchctl kickstart -k "gui/$(id -u)/com.ods.host-agent" 2>&1 || \
        log "WARNING: Could not restart host agent (non-fatal)"
fi

write_status "complete" 100 "$TOTAL_BYTES" "$TOTAL_BYTES" 0 ""
log "Bootstrap upgrade complete."
