#!/usr/bin/env bash

# Shared implementation for install.sh. This file is sourced after strict mode
# has been enabled by the entry point.

WXBOT_DEPLOY_DIR="${SCRIPT_DIR:?install.sh must define SCRIPT_DIR}"
WXBOT_COMPOSE_FILE="${WXBOT_DEPLOY_DIR}/docker-compose.yml"
WXBOT_COMPOSE_FULL_FILE="${WXBOT_DEPLOY_DIR}/docker-compose.full.yml"
WXBOT_ENV_FILE="${WXBOT_DEPLOY_DIR}/.env"
WXBOT_OS_RELEASE_FILE="${WXBOT_OS_RELEASE_FILE:-/etc/os-release}"
WXBOT_MIN_DISK_GB="${WXBOT_MIN_DISK_GB:-12}"
WXBOT_DOCKER_CMD=()
WXBOT_PROJECT_NAME="wx-bot-linux"
WXBOT_USE_FULL_BUILD=0

wxbot_log() {
  printf '[wx-bot] %s\n' "$*"
}

wxbot_warn() {
  printf '[wx-bot] WARNING: %s\n' "$*" >&2
}

wxbot_die() {
  printf '[wx-bot] ERROR: %s\n' "$*" >&2
  exit 1
}

wxbot_usage() {
  cat <<'EOF'
Usage: bash install.sh [--check|--install|--upgrade|--uninstall]

  --check      Validate the host without changing it.
  --install    Install missing Docker components, create a secure .env, build,
               and start the deployment. This is the default action.
  --upgrade    Pull fresh base images, rebuild, and recreate containers while
               retaining the existing configuration and named volumes.
  --uninstall  Stop and remove only this deployment's containers and network.
               The .env file and all named volumes are retained.
EOF
}

wxbot_run_as_root() {
  if (( EUID == 0 )); then
    "$@"
    return
  fi
  command -v sudo >/dev/null 2>&1 || wxbot_die "sudo is required to install host packages"
  sudo "$@"
}

wxbot_os_value() {
  local key="$1"
  awk -F= -v wanted="${key}" '
    $1 == wanted {
      value = substr($0, index($0, "=") + 1)
      gsub(/^['\''"]|['\''"]$/, "", value)
      print value
      exit
    }
  ' "${WXBOT_OS_RELEASE_FILE}"
}

wxbot_check_platform() {
  [[ "$(uname -s)" == "Linux" ]] || wxbot_die "this installer supports Linux only"
  case "$(uname -m)" in
    x86_64|amd64) ;;
    *) wxbot_die "unsupported architecture $(uname -m); the bundled WeChat and SDK packages require x86_64/amd64" ;;
  esac

  [[ -r "${WXBOT_OS_RELEASE_FILE}" ]] || wxbot_die "cannot read ${WXBOT_OS_RELEASE_FILE}"
  local distro
  distro="$(wxbot_os_value ID)"
  case "${distro}" in
    ubuntu|debian) ;;
    *) wxbot_die "unsupported distribution ${distro:-unknown}; use Ubuntu or Debian" ;;
  esac
  wxbot_log "Platform: ${distro} x86_64"
}

wxbot_check_layout() {
  [[ -f "${WXBOT_COMPOSE_FILE}" ]] || wxbot_die "missing docker-compose.yml in ${WXBOT_DEPLOY_DIR}"
  [[ -f "${WXBOT_DEPLOY_DIR}/Dockerfile" ]] || wxbot_die "missing Dockerfile in ${WXBOT_DEPLOY_DIR}"
  [[ -f "${WXBOT_DEPLOY_DIR}/portal/Dockerfile" ]] || wxbot_die "missing portal/Dockerfile in ${WXBOT_DEPLOY_DIR}"
}

wxbot_check_sdk_package() {
  local package_count=0
  local package
  shopt -s nullglob
  for package in "${WXBOT_DEPLOY_DIR}"/packages/wx-bot-client_*_amd64.deb; do
    [[ -f "${package}" ]] && (( package_count += 1 ))
  done
  shopt -u nullglob

  local package_url=""
  if [[ -f "${WXBOT_ENV_FILE}" ]]; then
    package_url="$(wxbot_env_get WXBOT_DEB_URL "")"
  fi
  (( package_count <= 1 )) || wxbot_die "expected at most one packages/wx-bot-client_*_amd64.deb (found ${package_count})"
  if (( package_count == 1 )); then
    wxbot_log "SDK package: found"
    return
  fi
  if [[ -n "${package_url}" ]]; then
    wxbot_log "SDK package: private download URL configured"
    return
  fi
  wxbot_die "expected one packages/wx-bot-client_*_amd64.deb, or set WXBOT_DEB_URL in .env"
}

wxbot_check_disk() {
  [[ "${WXBOT_MIN_DISK_GB}" =~ ^[1-9][0-9]*$ ]] || wxbot_die "WXBOT_MIN_DISK_GB must be a positive integer"
  local target="${WXBOT_DEPLOY_DIR}"
  local docker_root=""
  if (( ${#WXBOT_DOCKER_CMD[@]} > 0 )); then
    docker_root="$(wxbot_docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
    [[ -d "${docker_root}" ]] && target="${docker_root}"
  fi

  local available_kb required_kb
  available_kb="$(df -Pk "${target}" | awk 'NR == 2 { print $4 }')"
  [[ "${available_kb}" =~ ^[0-9]+$ ]] || wxbot_die "could not determine free disk space for ${target}"
  required_kb=$(( WXBOT_MIN_DISK_GB * 1024 * 1024 ))
  (( available_kb >= required_kb )) || wxbot_die "at least ${WXBOT_MIN_DISK_GB} GiB free disk space is required (target: ${target})"
  wxbot_log "Disk: $(( available_kb / 1024 / 1024 )) GiB free (minimum ${WXBOT_MIN_DISK_GB} GiB)"
}

wxbot_ensure_install_tools() {
  local packages=()
  command -v curl >/dev/null 2>&1 || packages+=(curl ca-certificates)
  command -v openssl >/dev/null 2>&1 || packages+=(openssl)
  if ! command -v ss >/dev/null 2>&1 && ! command -v netstat >/dev/null 2>&1; then
    packages+=(iproute2)
  fi
  (( ${#packages[@]} == 0 )) && return

  wxbot_log "Installing required host tools: ${packages[*]}"
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get update
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y "${packages[@]}"
}

wxbot_configure_docker_repository() {
  local distro codename key_tmp repository
  distro="$(wxbot_os_value ID)"
  codename="$(wxbot_os_value VERSION_CODENAME)"
  [[ -n "${codename}" ]] || codename="$(wxbot_os_value DEBIAN_CODENAME)"
  [[ -n "${codename}" ]] || wxbot_die "could not determine the distribution codename"

  wxbot_run_as_root install -m 0755 -d /etc/apt/keyrings
  key_tmp="$(mktemp)"
  if ! curl -fsSL "https://download.docker.com/linux/${distro}/gpg" -o "${key_tmp}"; then
    rm -f "${key_tmp}"
    wxbot_die "failed to download Docker's repository signing key"
  fi
  wxbot_run_as_root install -m 0644 "${key_tmp}" /etc/apt/keyrings/docker.asc
  rm -f "${key_tmp}"

  repository="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${distro} ${codename} stable"
  if (( EUID == 0 )); then
    printf '%s\n' "${repository}" > /etc/apt/sources.list.d/docker.list
  else
    printf '%s\n' "${repository}" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  fi
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get update
}

wxbot_install_docker_engine() {
  wxbot_log "Installing Docker Engine and Docker Compose v2 from Docker's apt repository"
  wxbot_configure_docker_repository
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y \
    docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}

wxbot_install_compose_plugin() {
  wxbot_log "Docker Compose v2 was not found; installing the Compose plugin"
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get update
  if wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2; then
    return
  fi
  wxbot_configure_docker_repository
  wxbot_run_as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin
}

wxbot_start_docker_service() {
  docker info >/dev/null 2>&1 && return
  if command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then
    return
  fi
  wxbot_log "Starting Docker service"
  if command -v systemctl >/dev/null 2>&1; then
    wxbot_run_as_root systemctl enable --now docker && return
  fi
  if command -v service >/dev/null 2>&1; then
    wxbot_run_as_root service docker start && return
  fi
  wxbot_die "Docker is installed but its daemon could not be started"
}

wxbot_select_docker() {
  command -v docker >/dev/null 2>&1 || wxbot_die "Docker is not installed; run --install"
  if docker info >/dev/null 2>&1; then
    WXBOT_DOCKER_CMD=(docker)
  elif command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then
    WXBOT_DOCKER_CMD=(sudo docker)
  else
    wxbot_die "cannot connect to the Docker daemon; start Docker and grant this user access"
  fi
  wxbot_log "Docker Engine: available"
}

wxbot_docker() {
  "${WXBOT_DOCKER_CMD[@]}" "$@"
}

wxbot_compose() {
  local options=(
    compose
    --project-directory "${WXBOT_DEPLOY_DIR}"
    --project-name "${WXBOT_PROJECT_NAME}"
    -f "${WXBOT_COMPOSE_FILE}"
  )
  if (( WXBOT_USE_FULL_BUILD )); then
    options+=(-f "${WXBOT_COMPOSE_FULL_FILE}")
  fi
  if [[ -f "${WXBOT_ENV_FILE}" ]]; then
    options+=(--env-file "${WXBOT_ENV_FILE}")
  fi
  wxbot_docker "${options[@]}" "$@"
}

wxbot_check_compose() {
  wxbot_docker compose version >/dev/null 2>&1 || wxbot_die "Docker Compose v2 is not available; run --install"
  wxbot_log "Docker Compose v2: available"
}

wxbot_container_project() {
  local container_name="$1"
  wxbot_docker inspect \
    --format '{{ index .Config.Labels "com.docker.compose.project" }}' \
    "${container_name}" 2>/dev/null || true
}

wxbot_resolve_project_name() {
  local configured="" main_container portal_container main_project portal_project discovered=""
  if [[ -f "${WXBOT_ENV_FILE}" ]]; then
    configured="$(wxbot_env_get COMPOSE_PROJECT_NAME "")"
  fi
  main_container="$(wxbot_env_get WXBOT_CONTAINER_NAME wx-bot-linux-xfce)"
  portal_container="$(wxbot_env_get WXBOT_PORTAL_CONTAINER_NAME wx-bot-linux-portal)"
  main_project="$(wxbot_container_project "${main_container}")"
  portal_project="$(wxbot_container_project "${portal_container}")"

  if [[ -n "${main_project}" && -n "${portal_project}" && "${main_project}" != "${portal_project}" ]]; then
    wxbot_die "existing containers belong to different Compose projects; refusing to guess which volumes to use"
  fi
  discovered="${main_project:-${portal_project}}"
  if [[ -n "${configured}" && -n "${discovered}" && "${configured}" != "${discovered}" ]]; then
    wxbot_die "COMPOSE_PROJECT_NAME=${configured} conflicts with existing project ${discovered}; refusing to detach persistent data"
  fi
  WXBOT_PROJECT_NAME="${configured:-${discovered:-wx-bot-linux}}"
  [[ "${WXBOT_PROJECT_NAME}" =~ ^[a-z0-9][a-z0-9_-]+$ ]] || wxbot_die "invalid Compose project name: ${WXBOT_PROJECT_NAME}"
  wxbot_log "Compose project: ${WXBOT_PROJECT_NAME}"
}

wxbot_prepare_docker_for_install() {
  if ! command -v docker >/dev/null 2>&1; then
    wxbot_install_docker_engine
  fi
  wxbot_start_docker_service
  wxbot_select_docker
  if ! wxbot_docker compose version >/dev/null 2>&1; then
    wxbot_install_compose_plugin
    wxbot_select_docker
  fi
  wxbot_check_compose
  wxbot_resolve_project_name
}

wxbot_prepare_existing_docker() {
  wxbot_select_docker
  wxbot_check_compose
  wxbot_resolve_project_name
}

wxbot_env_has_key() {
  local key="$1"
  [[ -f "${WXBOT_ENV_FILE}" ]] && grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "${WXBOT_ENV_FILE}"
}

wxbot_env_get() {
  local key="$1" default_value="$2" value
  if [[ ! -f "${WXBOT_ENV_FILE}" ]]; then
    printf '%s\n' "${default_value}"
    return
  fi
  value="$(awk -F= -v wanted="${key}" '
    $0 ~ "^[[:space:]]*" wanted "[[:space:]]*=" {
      value = substr($0, index($0, "=") + 1)
      sub(/^[[:space:]]*/, "", value)
      sub(/[[:space:]\r]*$/, "", value)
      gsub(/^['\''"]|['\''"]$/, "", value)
      found = 1
    }
    END { if (found) print value }
  ' "${WXBOT_ENV_FILE}")"
  if wxbot_env_has_key "${key}"; then
    printf '%s\n' "${value}"
  else
    printf '%s\n' "${default_value}"
  fi
}

wxbot_random_secret() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex 24
    return
  fi
  [[ -r /dev/urandom ]] || wxbot_die "cannot generate a secure password: openssl and /dev/urandom are unavailable"
  od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
  printf '\n'
}

wxbot_env_append() {
  local key="$1" value="$2"
  wxbot_env_has_key "${key}" && return
  printf '%s=%s\n' "${key}" "${value}" >> "${WXBOT_ENV_FILE}"
}

wxbot_ensure_env() {
  local created=0
  umask 077
  if [[ ! -e "${WXBOT_ENV_FILE}" ]]; then
    printf '# Generated by install.sh. Keep this file private.\n' > "${WXBOT_ENV_FILE}"
    created=1
  fi
  [[ -f "${WXBOT_ENV_FILE}" && ! -L "${WXBOT_ENV_FILE}" ]] || wxbot_die ".env must be a regular file, not a symlink"
  chmod 600 "${WXBOT_ENV_FILE}"

  wxbot_env_append WXBOT_BIND_HOST 127.0.0.1
  wxbot_env_append WXBOT_VNC_BIND_HOST 127.0.0.1
  wxbot_env_append WXBOT_API_BIND_HOST 127.0.0.1
  wxbot_env_append COMPOSE_PROJECT_NAME "${WXBOT_PROJECT_NAME}"
  wxbot_env_append VNC_PASSWORD "$(wxbot_random_secret)"
  wxbot_env_append PORTAL_USER admin
  wxbot_env_append PORTAL_PASSWORD "$(wxbot_random_secret)"
  wxbot_env_append PORTAL_SESSION_TTL 43200
  wxbot_env_append VNC_RESOLUTION 1280x900
  wxbot_env_append WXBOT_BASE_IMAGE ghcr.io/zhx8702/wx-bot-linux-base:wechat4.1.1.8-ubuntu24.04-amd64-r1
  wxbot_env_append WXBOT_FORCE_FULL_BUILD 0
  wxbot_env_append WXBOT_FULL_BUILD_FALLBACK 1
  wxbot_env_append WXBOT_IMAGE wx-bot-linux-xfce:local
  wxbot_env_append WXBOT_CONTAINER_NAME wx-bot-linux-xfce
  wxbot_env_append WXBOT_SHM_SIZE 1gb
  wxbot_env_append WXBOT_PORTAL_IMAGE wx-bot-linux-portal:local
  wxbot_env_append WXBOT_PORTAL_CONTAINER_NAME wx-bot-linux-portal
  wxbot_env_append WXBOT_PORTAL_PORT 6180
  wxbot_env_append WECHAT_DEB_URL https://dldir1v6.qq.com/weixin/Universal/Linux/WeChatLinux_x86_64.deb
  wxbot_env_append WECHAT_DEB_SHA256 c9765e87ee5133bf4bb50d585c1814fafd995e3fb0da62c5ed07259b43dada7b
  wxbot_env_append WXBOT_DEB_URL ""

  if (( created )); then
    wxbot_log "Created secure configuration: ${WXBOT_ENV_FILE} (mode 600)"
  else
    wxbot_log "Preserved existing .env values; added only missing settings"
  fi
}

wxbot_validate_password() {
  local key="$1" value
  value="$(wxbot_env_get "${key}" "")"
  case "${value}" in
    ""|change-me|change-this-portal-password|replace-with-a-strong-portal-password|replace-with-a-different-strong-password)
      wxbot_die "${key} in .env is empty or a placeholder; set a unique password of at least 16 characters"
      ;;
  esac
  (( ${#value} >= 16 )) || wxbot_die "${key} in .env must contain at least 16 characters"
}

wxbot_validate_env() {
  [[ -f "${WXBOT_ENV_FILE}" ]] || wxbot_die ".env does not exist; run --install"
  wxbot_validate_password PORTAL_PASSWORD
  wxbot_validate_password VNC_PASSWORD

  local bind_host vnc_bind_host api_bind_host portal_port session_ttl force_full fallback_full
  bind_host="$(wxbot_env_get WXBOT_BIND_HOST 127.0.0.1)"
  vnc_bind_host="$(wxbot_env_get WXBOT_VNC_BIND_HOST 127.0.0.1)"
  api_bind_host="$(wxbot_env_get WXBOT_API_BIND_HOST 127.0.0.1)"
  portal_port="$(wxbot_env_get WXBOT_PORTAL_PORT 6180)"
  session_ttl="$(wxbot_env_get PORTAL_SESSION_TTL 43200)"
  force_full="$(wxbot_env_get WXBOT_FORCE_FULL_BUILD 0)"
  fallback_full="$(wxbot_env_get WXBOT_FULL_BUILD_FALLBACK 1)"
  wxbot_validate_ipv4 "${bind_host}" WXBOT_BIND_HOST
  wxbot_validate_ipv4 "${vnc_bind_host}" WXBOT_VNC_BIND_HOST
  wxbot_validate_ipv4 "${api_bind_host}" WXBOT_API_BIND_HOST
  wxbot_validate_port "${portal_port}" WXBOT_PORTAL_PORT
  [[ "${session_ttl}" =~ ^[1-9][0-9]*$ ]] || wxbot_die "PORTAL_SESSION_TTL must be a positive integer"
  [[ "${force_full}" =~ ^[01]$ ]] || wxbot_die "WXBOT_FORCE_FULL_BUILD must be 0 or 1"
  [[ "${fallback_full}" =~ ^[01]$ ]] || wxbot_die "WXBOT_FULL_BUILD_FALLBACK must be 0 or 1"
  if [[ "${bind_host}" != "127.0.0.1" ]]; then
    wxbot_warn "WXBOT_BIND_HOST=${bind_host} exposes Portal; restrict access with a firewall and put Portal behind HTTPS"
  fi
  if [[ "${vnc_bind_host}" != "127.0.0.1" ]]; then
    wxbot_warn "WXBOT_VNC_BIND_HOST=${vnc_bind_host} exposes raw noVNC and VNC outside the authenticated Portal"
  fi
  if [[ "${api_bind_host}" != "127.0.0.1" ]]; then
    wxbot_warn "WXBOT_API_BIND_HOST=${api_bind_host} exposes the SDK API; allow only trusted Agent Console hosts"
  fi
}

wxbot_validate_ipv4() {
  local value="$1" label="$2" octet
  local -a octets=()
  [[ "${value}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || wxbot_die "${label} must be an IPv4 address"
  IFS=. read -r -a octets <<< "${value}"
  for octet in "${octets[@]}"; do
    (( 10#${octet} <= 255 )) || wxbot_die "${label} contains an invalid IPv4 octet"
  done
}

wxbot_validate_port() {
  local port="$1" label="$2" numeric_port
  [[ "${port}" =~ ^(0|[1-9][0-9]*)$ ]] || wxbot_die "${label} must be a numeric TCP port without leading zeroes"
  numeric_port=$(( 10#${port} ))
  (( numeric_port >= 1 && numeric_port <= 65535 )) || wxbot_die "${label} must be between 1 and 65535"
}

wxbot_port_in_use() {
  local port="$1"
  if command -v ss >/dev/null 2>&1; then
    ss -H -ltn 2>/dev/null | awk -v port="${port}" '
      { address = $4; if (address ~ (":" port "$")) found = 1 }
      END { exit(found ? 0 : 1) }
    '
    return
  fi
  if command -v netstat >/dev/null 2>&1; then
    netstat -ltn 2>/dev/null | awk -v port="${port}" '
      NR > 2 { address = $4; if (address ~ (":" port "$")) found = 1 }
      END { exit(found ? 0 : 1) }
    '
    return
  fi
  wxbot_die "cannot inspect listening ports; install iproute2 (ss)"
}

wxbot_project_owns_port() {
  local service="$1" container_port="$2" expected_port="$3" published
  published="$(wxbot_compose port "${service}" "${container_port}" 2>/dev/null || true)"
  [[ "${published}" =~ :${expected_port}$ ]]
}

wxbot_check_one_port() {
  local service="$1" container_port="$2" host_port="$3"
  wxbot_validate_port "${host_port}" "port"
  if ! wxbot_port_in_use "${host_port}"; then
    wxbot_log "Port ${host_port}: available"
    return
  fi
  if wxbot_project_owns_port "${service}" "${container_port}" "${host_port}"; then
    wxbot_log "Port ${host_port}: already owned by this deployment"
    return
  fi
  wxbot_die "TCP port ${host_port} is already in use by another process"
}

wxbot_check_ports() {
  local portal_port
  portal_port="$(wxbot_env_get WXBOT_PORTAL_PORT 6180)"
  wxbot_validate_port "${portal_port}" WXBOT_PORTAL_PORT
  case "${portal_port}" in
    6080|5901|5080) wxbot_die "WXBOT_PORTAL_PORT ${portal_port} conflicts with a required service port" ;;
  esac
  wxbot_check_one_port portal 80 "${portal_port}"
  wxbot_check_one_port wx-bot-linux 6080 6080
  wxbot_check_one_port wx-bot-linux 5900 5901
  wxbot_check_one_port wx-bot-linux 5080 5080
}

wxbot_validate_compose() {
  (cd "${WXBOT_DEPLOY_DIR}" && wxbot_compose config --quiet) || wxbot_die "docker-compose.yml or .env validation failed"
  wxbot_log "Compose configuration: valid"
}

wxbot_preflight_common() {
  wxbot_check_platform
  wxbot_check_layout
  wxbot_check_disk
}

wxbot_pull_build_start() {
  local base_image force_full fallback_full
  base_image="$(wxbot_env_get WXBOT_BASE_IMAGE ghcr.io/zhx8702/wx-bot-linux-base:wechat4.1.1.8-ubuntu24.04-amd64-r1)"
  force_full="$(wxbot_env_get WXBOT_FORCE_FULL_BUILD 0)"
  fallback_full="$(wxbot_env_get WXBOT_FULL_BUILD_FALLBACK 1)"
  if [[ "${force_full}" == "1" ]]; then
    WXBOT_USE_FULL_BUILD=1
    wxbot_warn "WXBOT_FORCE_FULL_BUILD=1; building the complete desktop image locally"
  else
    wxbot_log "Pulling the versioned Linux desktop base image"
    if ! wxbot_docker pull "${base_image}"; then
      if [[ "${fallback_full}" == "1" && -f "${WXBOT_COMPOSE_FULL_FILE}" ]]; then
        WXBOT_USE_FULL_BUILD=1
        wxbot_warn "could not pull ${base_image}; falling back to the complete local build"
      else
        wxbot_die "failed to pull ${base_image}; verify that the GHCR package is public, set WXBOT_BASE_IMAGE to a mirror, or enable WXBOT_FULL_BUILD_FALLBACK"
      fi
    fi
  fi
  wxbot_docker pull nginx:1.27-alpine
  wxbot_log "Building the thin SDK layer and Portal"
  (cd "${WXBOT_DEPLOY_DIR}" && wxbot_compose build --pull)
  wxbot_log "Starting deployment (named volumes are preserved)"
  (cd "${WXBOT_DEPLOY_DIR}" && wxbot_compose up -d --remove-orphans)
  (cd "${WXBOT_DEPLOY_DIR}" && wxbot_compose ps)
}

wxbot_portal_url() {
  local bind_host portal_port display_host
  bind_host="$(wxbot_env_get WXBOT_BIND_HOST 127.0.0.1)"
  portal_port="$(wxbot_env_get WXBOT_PORTAL_PORT 6180)"
  display_host="${bind_host}"
  if [[ "${bind_host}" == "0.0.0.0" ]]; then
    display_host="$(hostname -I 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+\./) { print $i; exit } }')"
    [[ -n "${display_host}" ]] || display_host="127.0.0.1"
  fi
  printf 'http://%s:%s/' "${display_host}" "${portal_port}"
}

wxbot_show_success() {
  local action="$1"
  printf '\n[wx-bot] %s complete.\n' "${action}"
  printf '[wx-bot] Portal URL: %s\n' "$(wxbot_portal_url)"
  printf '[wx-bot] Portal credentials are stored in %s (mode 600).\n' "${WXBOT_ENV_FILE}"
}

wxbot_action_check() {
  wxbot_preflight_common
  wxbot_prepare_existing_docker
  wxbot_check_disk
  wxbot_check_sdk_package
  if [[ -f "${WXBOT_ENV_FILE}" ]]; then
    wxbot_validate_env
    wxbot_validate_compose
  else
    wxbot_log ".env: not created (check mode does not modify the host)"
  fi
  wxbot_check_ports
  wxbot_log "All checks passed"
}

wxbot_action_install() {
  wxbot_check_platform
  wxbot_check_layout
  wxbot_check_disk
  wxbot_ensure_install_tools
  wxbot_prepare_docker_for_install
  wxbot_check_disk
  wxbot_ensure_env
  wxbot_validate_env
  wxbot_check_sdk_package
  wxbot_validate_compose
  wxbot_check_ports
  wxbot_pull_build_start
  wxbot_show_success "Installation"
}

wxbot_action_upgrade() {
  wxbot_preflight_common
  wxbot_prepare_existing_docker
  wxbot_check_disk
  wxbot_ensure_env
  wxbot_validate_env
  wxbot_check_sdk_package
  wxbot_validate_compose
  wxbot_check_ports
  wxbot_pull_build_start
  wxbot_show_success "Upgrade"
}

wxbot_action_uninstall() {
  wxbot_check_platform
  wxbot_check_layout
  wxbot_prepare_existing_docker
  wxbot_log "Stopping and removing containers; .env and named volumes will be retained"
  (cd "${WXBOT_DEPLOY_DIR}" && wxbot_compose down --remove-orphans)
  printf '\n[wx-bot] Uninstall complete. Persistent data was retained.\n'
  printf '[wx-bot] Re-run --install to attach the retained volumes again.\n'
}

wxbot_main() {
  local action="${1:---install}"
  (( $# <= 1 )) || { wxbot_usage >&2; wxbot_die "expected exactly one action"; }
  case "${action}" in
    --check) wxbot_action_check ;;
    --install) wxbot_action_install ;;
    --upgrade) wxbot_action_upgrade ;;
    --uninstall) wxbot_action_uninstall ;;
    -h|--help) wxbot_usage ;;
    *) wxbot_usage >&2; wxbot_die "unknown action: ${action}" ;;
  esac
}
