#!/usr/bin/env bash

PACKAGE_INSTALLER_BUNDLED='1'

PACKAGE_API_ORIGIN='https://package.think-link.net'

PACKAGE_DOWNLOAD_ORIGIN='https://hongkong-thinklink.oss-cn-hongkong.aliyuncs.com'

PACKAGE_ADMIN_USER='admin'
PACKAGE_ADMIN_PASSWORD='TKedge_0801'
PACKAGE_EMQX_ADMIN_USER='admin'
PACKAGE_EMQX_ADMIN_PASSWORD='TKedge_0801'
PACKAGE_BROKER_SUPERUSER='user'
PACKAGE_BROKER_SUPERUSER_PASSWORD='MTuser_0801'
PACKAGE_INSTALL_ROOT='/opt/thinklink'
PACKAGE_MINIO_IMAGE=thinklink1705/tkl-minio:latest
PACKAGE_MINIO_ROOT_USER=admin
PACKAGE_MINIO_ROOT_PASSWORD=TKedge_0801

package_fail() {
  local code message
  code="$1"
  message="$2"
  printf '%s: %s\n' "$code" "$message" >&2
  return "${3:-1}"
}

package_require_commands() {
  local command_name
  for command_name in "$@"; do
    command -v "$command_name" >/dev/null 2>&1 \
      || { package_fail 'COMMAND_REQUIRED' "missing command: ${command_name}" 27; return; }
  done
}

package_cleanup() {
  if [ -n "${PACKAGE_WORK_DIR:-}" ] && [ -d "$PACKAGE_WORK_DIR" ]; then
    rm -rf -- "$PACKAGE_WORK_DIR"
  fi
}

package_platform_error() {
  printf '%s\n' "$1" >&2
  return "$2"
}

package_os_release_value() {
  local value
  value="$1"
  case "$value" in
    \"*\") value="${value#\"}"; value="${value%\"}" ;;
    \'*\') value="${value#\'}"; value="${value%\'}" ;;
  esac
  case "$value" in
    *'$('*|*'`'*|*';'*|*'&'*|*'|'*|*'<'*|*'>'*|*'\\'*) return 1 ;;
  esac
  printf '%s' "$value"
}

package_parse_os_release() {
  local file line key raw value id version codename seen
  file="$1"
  id=''
  version=''
  codename=''
  seen='|'
  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in
      ID=*|VERSION_ID=*|VERSION_CODENAME=*)
        key="${line%%=*}"
        raw="${line#*=}"
        case "$seen" in *"|${key}|"*) package_platform_error 'OS_RELEASE_UNSAFE' 25; return ;; esac
        value="$(package_os_release_value "$raw")" \
          || { package_platform_error 'OS_RELEASE_UNSAFE' 25; return; }
        seen="${seen}${key}|"
        case "$key" in
          ID) id="$value" ;;
          VERSION_ID) version="$value" ;;
          VERSION_CODENAME) codename="$value" ;;
        esac
        ;;
    esac
  done < "$file"
  case "$id" in ''|*[!a-z0-9._-]*) package_platform_error 'OS_RELEASE_UNSAFE' 25; return ;; esac
  case "$version" in ''|*[!0-9.]*) package_platform_error 'OS_RELEASE_UNSAFE' 25; return ;; esac
  case "$codename" in *[!a-z0-9._-]*) package_platform_error 'OS_RELEASE_UNSAFE' 25; return ;; esac
  printf '%s|%s|%s\n' "$id" "$version" "$codename"
}

package_detect_platform() {
  local architecture os_release parsed id version codename major mode
  architecture="${PACKAGE_UNAME_M:-$(uname -m)}"
  case "$architecture" in
    x86_64|amd64) architecture='amd64' ;;
    *) package_platform_error 'UNSUPPORTED_ARCH: ThinkLink Package only supports x86_64/amd64' 20; return ;;
  esac

  os_release="${PACKAGE_OS_RELEASE:-/etc/os-release}"
  if [ ! -r "$os_release" ]; then
    package_platform_error 'OS_RELEASE_UNAVAILABLE' 25
    return
  fi
  parsed="$(package_parse_os_release "$os_release")" || return
  IFS='|' read -r id version codename <<< "$parsed"
  major="${version%%.*}"
  mode=''
  case "$id:$major" in
    debian:11|debian:12|debian:13) mode='auto' ;;
    ubuntu:22) mode='auto' ;;
    ubuntu:24)
      case "$version" in
        24.04) mode='auto' ;;
        *) package_platform_error 'UNSUPPORTED_PLATFORM' 25; return ;;
      esac
      ;;
    ubuntu:20) mode='compat' ;;
    rocky:9|almalinux:9) mode='auto'; codename='' ;;
    centos:7|centos:8) mode='compat'; codename='' ;;
    *) package_platform_error 'UNSUPPORTED_PLATFORM' 25; return ;;
  esac
  printf '%s|%s|%s|%s|%s\n' "$id" "$version" "$codename" "$mode" "$architecture"
}

package_preflight_error() {
  printf '%s\n' "$1" >&2
  return "$2"
}

package_check_resources() {
  local effective_uid cpu_count memory_kib disk_kib
  effective_uid="${PACKAGE_EFFECTIVE_UID:-$(id -u)}"
  if [ "$effective_uid" != '0' ]; then
    package_preflight_error 'ROOT_REQUIRED: run with sudo' 21
    return
  fi
  cpu_count="${PACKAGE_CPU_COUNT:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '0')}"
  case "$cpu_count" in *[!0-9]*|'') cpu_count=0 ;; esac
  if [ "$cpu_count" -lt 2 ]; then
    package_preflight_error 'CPU_TOO_LOW: at least 2 CPU cores are required' 28
    return
  fi
  memory_kib="${PACKAGE_MEMORY_KIB:-$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)}"
  case "$memory_kib" in *[!0-9]*|'') memory_kib=0 ;; esac
  if [ "$memory_kib" -lt 4194304 ]; then
    package_preflight_error 'MEMORY_TOO_LOW: at least 4 GiB is required' 22
    return
  fi
  disk_kib="${PACKAGE_DISK_KIB:-$(df -Pk /opt 2>/dev/null | awk 'NR == 2 { print $4 }')}"
  case "$disk_kib" in *[!0-9]*|'') disk_kib=0 ;; esac
  if [ "$disk_kib" -lt 20971520 ]; then
    package_preflight_error 'DISK_TOO_LOW: at least 20 GiB is required' 23
    return
  fi
  printf '%s\n' 'RESOURCE_CHECK_OK'
}

package_port_is_listening() {
  local output port
  output="$1"
  port="$2"
  printf '%s\n' "$output" | awk -v port="$port" '
    { for (field = 1; field <= NF; field += 1) {
        if ($field ~ (":" port "$")) found = 1
      }
    }
    END { exit(found ? 0 : 1) }
  '
}

package_existing_thinklink() {
  if [ -n "${PACKAGE_EXISTING_THINKLINK+x}" ]; then
    [ "$PACKAGE_EXISTING_THINKLINK" = '1' ]
    return
  fi
  command -v docker >/dev/null 2>&1 \
    && [ -n "$(docker ps --filter label=com.docker.compose.project=thinklink \
      --format '{{.ID}}' 2>/dev/null | head -n 1)" ]
}

package_choose_alternate_web_ports() {
  local tcp tty choice
  tcp="$1"
  if package_port_is_listening "$tcp" 8080; then
    package_preflight_error 'ALTERNATE_PORT_IN_USE: TCP 8080' 29
    return
  fi
  tty="${PACKAGE_TTY_PATH:-/dev/tty}"
  if [ ! -r "$tty" ]; then
    package_preflight_error 'PORT_SELECTION_REQUIRED: rerun from an interactive terminal' 29
    return
  fi
  printf '%s\n' \
    '检测到 TCP 80 已被占用。' \
    '1. 使用备用端口 8080 继续安装' \
    '2. 退出安装' \
    '请选择 [1/2]：' >&2
  if ! IFS= read -r choice < "$tty"; then
    package_preflight_error 'PORT_SELECTION_REQUIRED: unable to read terminal choice' 29
    return
  fi
  case "$choice" in
    1)
      PACKAGE_HTTP_PORT=8080
      export PACKAGE_HTTP_PORT
      printf '%s\n' 'PORT_CHECK_ALTERNATE: TCP 8080'
      ;;
    2) package_preflight_error 'INSTALL_CANCELLED: no services were changed' 29 ;;
    *) package_preflight_error 'PORT_SELECTION_INVALID: choose 1 or 2' 29 ;;
  esac
}

package_check_ports() {
  local tcp udp port web_conflicts other_conflicts conflicts protocol
  PACKAGE_HTTP_PORT="${PACKAGE_HTTP_PORT:-80}"
  export PACKAGE_HTTP_PORT
  if [ -n "${PACKAGE_SS_TCP+x}" ]; then tcp="$PACKAGE_SS_TCP"
  else tcp="$(ss -H -ltnp 2>/dev/null || true)"; fi
  if [ -n "${PACKAGE_SS_UDP+x}" ]; then udp="$PACKAGE_SS_UDP"
  else udp="$(ss -H -lunp 2>/dev/null || true)"; fi
  web_conflicts=''
  other_conflicts=''
  if package_port_is_listening "$tcp" 80; then web_conflicts=' TCP 80'; fi
  for port in 1883 8083 8084 18083 9000 9001; do
    if package_port_is_listening "$tcp" "$port"; then
      other_conflicts="${other_conflicts} TCP ${port}"
    fi
  done
  for port in 1700; do
    if package_port_is_listening "$udp" "$port"; then
      other_conflicts="${other_conflicts} UDP ${port}"
    fi
  done
  conflicts="${web_conflicts}${other_conflicts}"
  if [ -z "$conflicts" ]; then printf '%s\n' 'PORT_CHECK_OK'; return 0; fi
  if package_existing_thinklink; then printf '%s\n' 'PORT_CHECK_OWNED'; return 0; fi
  if [ -z "$other_conflicts" ]; then
    package_choose_alternate_web_ports "$tcp"
    return
  fi
  protocol="${conflicts# }"
  package_preflight_error "PORT_IN_USE: ${protocol}" 29
}

package_docker_error() {
  printf '%s\n' "$1" >&2
  return "$2"
}

PACKAGE_DOCKER_KEY_FINGERPRINT='9DC858229FC7DD38854AE2D88D81803C0EBFCD88'

package_docker_install_plan() {
  case "$1" in
    debian) printf '%s\n' 'apt|debian' ;;
    ubuntu) printf '%s\n' 'apt|ubuntu' ;;
    rocky|almalinux) printf '%s\n' 'dnf|centos' ;;
    centos) package_docker_error 'DOCKER_REQUIRED_COMPAT' 24 ;;
    *) package_docker_error 'DOCKER_PLATFORM_INVALID' 25 ;;
  esac
}

package_docker_ready() {
  command -v docker >/dev/null 2>&1 \
    && docker version >/dev/null 2>&1 \
    && docker compose version >/dev/null 2>&1
}

package_verify_docker_key() {
  local key_path fingerprints
  key_path="$1"
  if [ "${PACKAGE_DOCKER_DRY_RUN:-0}" = '1' ]; then
    printf 'gpg --batch --show-keys --with-colons %s # %s\n' \
      "$key_path" "$PACKAGE_DOCKER_KEY_FINGERPRINT"
    return 0
  fi
  fingerprints="$(gpg --batch --show-keys --with-colons "$key_path" 2>/dev/null \
    | awk -F: '$1 == "fpr" { print $10 }')" \
    || { package_docker_error 'DOCKER_KEY_INVALID' 30; return; }
  printf '%s\n' "$fingerprints" | grep -Fxq "$PACKAGE_DOCKER_KEY_FINGERPRINT" \
    || { package_docker_error 'DOCKER_KEY_INVALID' 30; return; }
}

package_docker_run() {
  if [ "${PACKAGE_DOCKER_DRY_RUN:-0}" = '1' ]; then
    printf '%s' "$1"
    shift
    printf ' %s' "$@"
    printf '\n'
    return 0
  fi
  "$@"
}

package_write_apt_source() {
  local platform codename source
  platform="$1"
  codename="$2"
  source="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${platform} ${codename} stable"
  if [ "${PACKAGE_DOCKER_DRY_RUN:-0}" = '1' ]; then
    printf '%s\n' "$source"
  else
    printf '%s\n' "$source" > /etc/apt/sources.list.d/docker.list
  fi
}

package_install_docker() {
  local platform codename mode
  platform="$1"
  codename="$2"
  mode="$3"
  if package_docker_ready; then
    printf '%s\n' 'DOCKER_READY'
    return 0
  fi
  if [ "$mode" != 'auto' ]; then
    package_docker_error 'DOCKER_REQUIRED_COMPAT' 24
    return
  fi
  case "$platform" in
    debian|ubuntu)
      package_docker_run apt-get update
      package_docker_run apt-get install -y ca-certificates curl gnupg
      package_docker_run install -m 0755 -d /etc/apt/keyrings
      package_docker_run curl -fsSL --tlsv1.2 --tls-max 1.2 \
        "https://download.docker.com/linux/${platform}/gpg" \
        -o /etc/apt/keyrings/docker.asc
      package_verify_docker_key /etc/apt/keyrings/docker.asc
      package_docker_run chmod a+r /etc/apt/keyrings/docker.asc
      package_write_apt_source "$platform" "$codename"
      package_docker_run apt-get update
      package_docker_run apt-get install -y docker-ce docker-ce-cli containerd.io \
        docker-buildx-plugin docker-compose-plugin jq
      ;;
    rocky|almalinux)
      package_docker_run dnf install -y dnf-plugins-core curl gnupg2 jq
      package_docker_run curl -fsSL --tlsv1.2 --tls-max 1.2 \
        https://download.docker.com/linux/centos/gpg \
        -o /etc/pki/rpm-gpg/docker.asc
      package_verify_docker_key /etc/pki/rpm-gpg/docker.asc
      package_docker_run rpm --import /etc/pki/rpm-gpg/docker.asc
      package_docker_run dnf config-manager --add-repo \
        https://download.docker.com/linux/centos/docker-ce.repo
      package_docker_run dnf install -y docker-ce docker-ce-cli containerd.io \
        docker-buildx-plugin docker-compose-plugin
      ;;
    *) package_docker_error 'DOCKER_PLATFORM_INVALID' 25; return ;;
  esac
  package_docker_run systemctl enable --now docker
  if [ "${PACKAGE_DOCKER_DRY_RUN:-0}" != '1' ] && ! package_docker_ready; then
    package_docker_error 'DOCKER_START_FAILED' 26
    return
  fi
}

package_validate_download_response() {
  local response now_epoch response_size
  response="$1"
  response_size="$(wc -c < "$response" | tr -d ' ')"
  [ "$response_size" -le 1048576 ] \
    || { package_fail 'DOWNLOAD_RESPONSE_INVALID' 'download response is too large' 30; return; }
  now_epoch="${PACKAGE_NOW_EPOCH:-$(date +%s)}"
  jq -e --arg origin "$PACKAGE_DOWNLOAD_ORIGIN" --argjson now "$now_epoch" '
    .downloadId as $downloadId |
    (keys == ["downloadId","expiresAt","files","release"]) and
    (.downloadId | type == "string" and test("^dl_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) and
    (.expiresAt | type == "string" and
      (sub("\\.[0-9]{1,9}Z$"; "Z") | fromdateiso8601) > ($now + 30)) and
    (.release | keys == ["id","version"]) and
    (.release.id | type == "string" and test("^rel_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) and
    (.release.version | type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) and
    ([.files[] | [.type, .filename]] == [
      ["application", "application.tar"],
      ["compose", "docker-compose.yml"],
      ["image-emqx", "tkl-emqx.tar"],
      ["image-nsbrg", "tkl-nsbrg.tar"],
      ["image-postgres", "tkl-postgres.tar"],
      ["image-redis", "tkl-redis.tar"],
      ["image-thinklink", "tkl-thinklink.tar"],
      ["image-thinkone", "tkl-thinkone.tar"],
      ["image-minio", "tkl-minio.tar"]
    ]) and
    (all(.files[];
      (keys == ["filename","type","url"]) and
      (.url | type == "string" and startswith($origin + "/") and
        contains("#") == false and contains("@") == false and
        test("[?&]downloadId=" + $downloadId + "(&|$)"))))
  ' "$response" >/dev/null \
    || { package_fail 'DOWNLOAD_RESPONSE_INVALID' 'invalid download response' 30; return; }
}

package_api_request_download() {
  local body output
  body="$1"
  output="$2"
  curl --fail --silent --show-error --proto '=https' --tlsv1.2 --tls-max 1.2 \
    --connect-timeout 10 --max-time 60 --max-filesize 1048576 \
    -H 'Content-Type: application/json' --data "$body" \
    "${PACKAGE_API_ORIGIN}/api/v1/downloads" -o "$output" || return
  package_validate_download_response "$output"
}

package_api_create_download() {
  package_api_request_download \
    '{"channel":"stable","architecture":"amd64","source":"installer"}' "$1"
}

package_api_refresh_download() {
  local current output download_id body
  current="$1"
  output="$2"
  download_id="$(jq -r '.downloadId' "$current")"
  body="$(jq -cn --arg id "$download_id" \
    '{channel:"stable",architecture:"amd64",source:"installer",downloadId:$id}')"
  package_api_request_download "$body" "$output" || return
  [ "$(jq -r '.downloadId' "$output")" = "$download_id" ] \
    && [ "$(jq -cS '.release' "$output")" = "$(jq -cS '.release' "$current")" ] \
    && [ "$(jq -c '[.files[] | [.type, .filename, (.url | split("?")[0])]]' "$output")" = \
         "$(jq -c '[.files[] | [.type, .filename, (.url | split("?")[0])]]' "$current")" ] \
    || { package_fail 'DOWNLOAD_REFRESH_INVALID' 'download refresh changed its identity, release or artifact paths' 30; return; }
}

package_api_event() {
  local event error_code body
  event="$1"
  error_code="${2:-}"
  [ "${PACKAGE_OFFLINE:-0}" != 1 ] || return 0
  [ -n "${PACKAGE_DOWNLOAD_ID:-}" ] || return 0
  if [ "$event" = 'failed' ]; then
    body="$(jq -cn --arg id "$PACKAGE_DOWNLOAD_ID" --arg event "$event" \
      --arg code "${error_code:-INSTALL_FAILED}" \
      '{downloadId:$id,event:$event,errorCode:$code}')"
  else
    body="$(jq -cn --arg id "$PACKAGE_DOWNLOAD_ID" --arg event "$event" \
      '{downloadId:$id,event:$event}')"
  fi
  curl -fsS --tlsv1.2 --tls-max 1.2 --connect-timeout 3 --max-time 5 \
    -H 'Content-Type: application/json' \
    --data "$body" "${PACKAGE_API_ORIGIN}/api/v1/install-events" >/dev/null 2>&1 || true
}

package_artifact_path() {
  case "$1" in
    application) printf '%s/application.tar\n' "$PACKAGE_WORK_DIR" ;;
    compose) printf '%s/docker-compose.yml\n' "$PACKAGE_WORK_DIR" ;;
    image-emqx) printf '%s/tkl-emqx.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-nsbrg) printf '%s/tkl-nsbrg.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-postgres) printf '%s/tkl-postgres.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-redis) printf '%s/tkl-redis.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-thinklink) printf '%s/tkl-thinklink.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-minio) printf '%s/tkl-minio.tar\n' "$PACKAGE_WORK_DIR" ;;
    image-thinkone) printf '%s/tkl-thinkone.tar\n' "$PACKAGE_WORK_DIR" ;;
    *) return 31 ;;
  esac
}

package_download_artifact() {
  local response artifact_type url target refresh
  response="$1"
  artifact_type="$2"
  target="$(package_artifact_path "$artifact_type")" \
    || { package_fail 'ARTIFACT_TYPE_INVALID' 'unexpected artifact type' 31; return; }
  url="$(jq -er --arg type "$artifact_type" '.files[] | select(.type == $type) | .url' "$response")" \
    || { package_fail 'ARTIFACT_TYPE_INVALID' 'file is absent from response' 31; return; }
  printf '正在下载 %s...\n' "$artifact_type"
  if curl --fail --silent --show-error --proto '=https' --tlsv1.2 --tls-max 1.2 \
    --location --max-redirs 0 --connect-timeout 15 --max-time 7200 \
    --output "$target" "$url"; then
    return 0
  fi
  rm -f -- "$target"
  refresh="${response}.refresh"
  package_api_refresh_download "$response" "$refresh" || return
  mv -f -- "$refresh" "$response"
  url="$(jq -er --arg type "$artifact_type" '.files[] | select(.type == $type) | .url' "$response")" \
    || return 31
  if curl --fail --silent --show-error --proto '=https' --tlsv1.2 --tls-max 1.2 \
    --location --max-redirs 0 --connect-timeout 15 --max-time 7200 \
    --output "$target" "$url"; then
    return 0
  fi
  rm -f -- "$target"
  package_fail 'ARTIFACT_DOWNLOAD_FAILED' "$artifact_type download failed" 31
}

package_download_artifacts() {
  local response artifact_type
  response="$1"
  shift
  if [ "$#" -eq 0 ]; then
    set -- application compose image-emqx image-nsbrg image-postgres image-redis \
      image-thinklink image-thinkone image-minio
  fi
  for artifact_type in "$@"; do
    package_download_artifact "$response" "$artifact_type" || return
  done
}



package_write_install_info() {
  local target server_ip temporary information http_port address
  target="$1"
  server_ip="$2"
  http_port="${PACKAGE_HTTP_PORT:-80}"
  address="http://${server_ip}"
  if [ "$http_port" != '80' ]; then address="${address}:${http_port}"; fi
  if [ -z "${PACKAGE_ADMIN_USER:-}" ] || [ -z "${PACKAGE_ADMIN_PASSWORD:-}" ] \
    || [ -z "${PACKAGE_EMQX_ADMIN_USER:-}" ] \
    || [ -z "${PACKAGE_EMQX_ADMIN_PASSWORD:-}" ] \
    || [ -z "${PACKAGE_BROKER_SUPERUSER:-}" ] \
    || [ -z "${PACKAGE_BROKER_SUPERUSER_PASSWORD:-}" ] \
    || [ -z "${PACKAGE_MINIO_ROOT_USER:-}" ] \
    || [ -z "${PACKAGE_MINIO_ROOT_PASSWORD:-}" ] \
    || [ -z "${PACKAGE_INSTALL_ROOT:-}" ] || [ -z "$target" ] || [ -z "$server_ip" ]; then
    printf '%s\n' 'INSTALL_INFO_CONFIG_INVALID' >&2
    return 40
  fi
  temporary="${target}.tmp.$$"
  information="$(printf '%s\n' \
    'ThinkLink 安装成功' \
    '' \
    'ThinkLink 管理平台' \
    "访问地址：${address}" \
    "账号：${PACKAGE_ADMIN_USER}" \
    "密码：${PACKAGE_ADMIN_PASSWORD}" \
    '' \
    'EMQX 管理控制台' \
    "访问地址：http://${server_ip}:18083" \
    "账号：${PACKAGE_EMQX_ADMIN_USER}" \
    "密码：${PACKAGE_EMQX_ADMIN_PASSWORD}" \
    '' \
    'MQTT Broker 超级账号' \
    "Broker 地址：mqtt://${server_ip}:1883" \
    "账号：${PACKAGE_BROKER_SUPERUSER}" \
    "密码：${PACKAGE_BROKER_SUPERUSER_PASSWORD}" \
    '' \
    'MinIO 对象存储' \
    "S3 API：http://${server_ip}:9000" \
    "管理控制台：http://${server_ip}:9001" \
    '容器内 S3 API：http://minio:9000' \
    "账号：${PACKAGE_MINIO_ROOT_USER}" \
    "密码：${PACKAGE_MINIO_ROOT_PASSWORD}" \
    '' \
    'LoRaWAN GWMP' \
    "服务器地址：${server_ip}" \
    '端口：UDP 1700' \
    '' \
    '开放端口' \
    "TCP：${http_port}、1883、8083、8084、18083、9000、9001" \
    'UDP：1700' \
    '' \
    '安全提醒：首次登录后请修改 ThinkLink、EMQX 和 MinIO 默认密码。' \
    "安装信息：${target}")"
  umask 077
  if ! printf '%s\n' "$information" > "$temporary"; then
    rm -f -- "$temporary"
    return 41
  fi
  chmod 0600 "$temporary"
  mv -f -- "$temporary" "$target"
  printf '%s\n' "$information"
}

package_apply_ports() {
  local compose temporary http_port
  compose="$1"
  http_port="${PACKAGE_HTTP_PORT:-80}"
  temporary="${compose}.ports.$$"
  if ! awk -v http_port="$http_port" '
    function print_with_ports(line) {
      gsub(/"80:80"/, "\"" http_port ":80\"", line)
      print line
    }
    pending != "" {
      if ($0 ~ /^[[:space:]]*-[[:space:]]*"5432:5432"[[:space:]]*$/) {
        pending = ""
        next
      }
      print_with_ports(pending)
      pending = ""
    }
    /^version:[[:space:]]*/ { next }
    /^[[:space:]]*-[[:space:]]*"443:433"[[:space:]]*(#.*)?$/ { next }
    /^[[:space:]]*-[[:space:]]*"47808:47808"[[:space:]]*(#.*)?$/ { next }
    /^[[:space:]]*ports:[[:space:]]*$/ { pending = $0; next }
    { print_with_ports($0) }
    END { if (pending != "") print_with_ports(pending) }
  ' "$compose" > "$temporary"; then
    rm -f -- "$temporary"
    return 42
  fi
  chmod 0640 "$temporary"
  mv -f -- "$temporary" "$compose"
}

package_reuse_minio_credentials() {
  local compose previous
  compose="$1"
  [ -f "$compose" ] || return 0
  previous="${PACKAGE_WORK_DIR}/previous-compose.json"
  umask 077
  docker compose -f "$compose" config --format json > "$previous" || return
  if jq -e '.services.minio != null' "$previous" >/dev/null; then
    PACKAGE_MINIO_ROOT_USER="$(jq -er '
      .services.minio.environment.MINIO_ROOT_USER | select(type == "string" and length >= 3)
    ' "$previous")" || return 43
    PACKAGE_MINIO_ROOT_PASSWORD="$(jq -er '
      .services.minio.environment.MINIO_ROOT_PASSWORD | select(type == "string" and length >= 8)
    ' "$previous")" || return 43
  fi
  rm -f -- "$previous"
}

package_add_minio() {
  local compose normalized temporary
  compose="$1"
  umask 077
  normalized="${compose}.normalized.$$"
  temporary="${compose}.minio.$$"
  if ! docker compose -f "$compose" config --format json > "$normalized"; then
    rm -f -- "$normalized"
    return 43
  fi
  if ! printf '%s\0%s\0%s' "$PACKAGE_MINIO_IMAGE" \
    "$PACKAGE_MINIO_ROOT_USER" "$PACKAGE_MINIO_ROOT_PASSWORD" \
    | jq -Rse --slurpfile configuration "$normalized" '
    split("\u0000") as $settings | $configuration[0] |
    if .services.minio != null then error("MINIO_SERVICE_ALREADY_CONFIGURED") else . end |
    .services.minio = {
      image: $settings[0],
      container_name: "tkl-minio",
      restart: "always",
      command: ["server", "/data", "--address", ":9000", "--console-address", ":9001"],
      environment: {
        MINIO_ROOT_USER: ($settings[1] | gsub("\\$"; "$$")),
        MINIO_ROOT_PASSWORD: ($settings[2] | gsub("\\$"; "$$"))
      },
      ports: ["9000:9000", "9001:9001"],
      volumes: ["minio_data:/data"],
      networks: ((.services.tkl.networks // {default: {}}) | with_entries(.value = {}))
    } |
    .volumes.minio_data = {}
  ' > "$temporary"; then
    rm -f -- "$normalized" "$temporary"
    return 43
  fi
  rm -f -- "$normalized"
  chmod 0600 "$temporary"
  mv -f -- "$temporary" "$compose"
}

package_wait_for_health() {
  local compose attempt attempts interval http_port
  compose="$1"
  http_port="${PACKAGE_HTTP_PORT:-80}"
  attempts="${PACKAGE_HEALTH_ATTEMPTS:-60}"
  interval="${PACKAGE_HEALTH_INTERVAL_SECONDS:-2}"
  for attempt in $(seq 1 "$attempts"); do
    if curl --fail --silent --show-error --connect-timeout 2 --max-time 3 \
      "http://127.0.0.1:${http_port}/" >/dev/null 2>&1 \
      && curl --fail --silent --show-error --connect-timeout 2 --max-time 3 \
        'http://127.0.0.1:9000/minio/health/ready' >/dev/null 2>&1 \
      && docker compose -f "$compose" exec -T minio /thinklink-minio check-ready >/dev/null 2>&1; then
      return 0
    fi
    sleep "$interval"
  done
  printf '%s\n' 'HEALTH_CHECK_FAILED' >&2
  docker compose -f "$compose" ps >&2 || true
  return 36
}

package_write_minio_client_config() {
  local target temporary
  target="${PACKAGE_INSTALL_ROOT}/minio-client.json"
  temporary="${target}.tmp.$$"
  umask 077
  if ! printf '%s\0%s' "$PACKAGE_MINIO_ROOT_USER" "$PACKAGE_MINIO_ROOT_PASSWORD" \
    | jq -Rs 'split("\u0000") as $credentials | {minio: {
      endPoint: "127.0.0.1", port: 9000, useSSL: false,
      accessKey: $credentials[0], secretKey: $credentials[1], bucket: "thinklink",
      presignedExpirySeconds: 604800
    }}' > "$temporary"; then
    rm -f -- "$temporary"
    return 44
  fi
  chmod 0600 "$temporary"
  mv -f -- "$temporary" "$target"
}

package_deploy_release() {
  local compose server_ip image release_dir artifact_dir
  release_dir="$PACKAGE_INSTALL_ROOT"
  artifact_dir="${PACKAGE_ARTIFACT_DIR:-$PACKAGE_WORK_DIR}"
  compose="${PACKAGE_INSTALL_ROOT}/docker-compose.yml"
  package_reuse_minio_credentials "$compose" || return
  install -m 0750 -d "$release_dir" || return
  tar -xf "${artifact_dir}/application.tar" -C "$release_dir" || return
  install -m 0640 "${artifact_dir}/docker-compose.yml" "$compose" || return
  package_apply_ports "$compose" || return
  package_add_minio "$compose" || return
  for image in emqx nsbrg postgres redis thinklink thinkone minio; do
    docker load -i "${artifact_dir}/tkl-${image}.tar" || return
  done
  docker compose -f "$compose" config --quiet || return
  if [ "${PACKAGE_OFFLINE:-0}" = 1 ]; then
    package_check_offline_images "$compose" || return
    docker compose -f "$compose" up -d --pull never --no-build || return
  else
    docker compose -f "$compose" up -d --pull never || return
  fi
  package_api_event 'containers_started'
  package_wait_for_health "$compose" || return
  package_write_minio_client_config || return
  printf '%s\n' "$PACKAGE_RELEASE_VERSION" > "${PACKAGE_INSTALL_ROOT}/.package-version"
  chmod 0600 "${PACKAGE_INSTALL_ROOT}/.package-version"
  server_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
  [ -n "$server_ip" ] || server_ip='127.0.0.1'
  package_write_install_info "${PACKAGE_INSTALL_ROOT}/install-info.txt" "$server_ip"
}

# The bundle uses the current nine-file download contract, plus local transfer metadata.
package_bundle_files() {
  printf '%s\n' application.tar docker-compose.yml tkl-emqx.tar tkl-nsbrg.tar \
    tkl-postgres.tar tkl-redis.tar tkl-thinklink.tar tkl-thinkone.tar tkl-minio.tar \
    release.json install.sh install-offline.sh README.txt
}

package_usage() {
  cat <<'USAGE'
ThinkLink 安装工具
  bash install.sh                          联网安装（需要 root）
  bash install.sh --download-only 目录      联网下载离线包，不安装 Docker 或应用
  bash install.sh --verify-only 目录        仅校验离线包，不安装
  sudo bash install.sh --offline 目录       校验并引导离线全新安装
  sudo bash install.sh --offline 目录 --yes 明确确认安装，跳过最终确认提示
  bash install.sh --help                   查看帮助

离线包目录必须尚不存在，父目录须已存在且空间充足。下载机只需 Bash、curl、jq、tar、sha256sum。
下载脚本须先保存为本地文件再执行。离线机须提前安装并启动 Docker Engine、Compose V2，
准备 curl、jq、tar、sha256sum、ss、hostname。仅支持 Linux amd64；离线模式不安装系统依赖。
离线安装默认写入 /opt/thinklink；检测到已有安装、容器或数据卷会停止。
USAGE
}

package_export_installer() {
  local target module
  target="$1"
  if [ "${PACKAGE_INSTALLER_BUNDLED:-0}" = 1 ]; then
    [ -f "$PACKAGE_SCRIPT_PATH" ] \
      || { package_fail 'SCRIPT_FILE_REQUIRED' '请先下载 install.sh 为本地文件，再执行 --download-only' 45; return; }
    cp -- "$PACKAGE_SCRIPT_PATH" "$target" || return
  else
    {
      printf '#!/usr/bin/env bash\nPACKAGE_INSTALLER_BUNDLED=1\n'
      printf 'PACKAGE_API_ORIGIN=%q\nPACKAGE_DOWNLOAD_ORIGIN=%q\n' "$PACKAGE_API_ORIGIN" "$PACKAGE_DOWNLOAD_ORIGIN"
      cat "${package_installer_dir}/config/defaults.env"
      for module in common platform preflight docker api download verify deploy offline; do
        printf '\n'
        cat "${package_installer_dir}/lib/${module}.sh"
      done
      printf '\n'
      cat "${package_installer_dir}/install.sh"
    } > "$target"
  fi
  chmod 0700 "$target"
}

package_verify_bundle() {
  local bundle filename hash recorded extra
  bundle="$1"
  package_require_commands jq sha256sum || return
  [ -d "$bundle" ] && [ ! -L "$bundle" ] \
    || { package_fail 'OFFLINE_BUNDLE_INVALID' '离线包目录不存在或为符号链接' 46; return; }
  [ -f "$bundle/SHA256SUMS" ] && [ ! -L "$bundle/SHA256SUMS" ] \
    || { package_fail 'OFFLINE_BUNDLE_INVALID' '缺少 SHA256SUMS' 46; return; }
  # Only fixed, ordered basenames are accepted; never follow manifest-selected paths.
  while IFS= read -r filename; do
    [ -f "$bundle/$filename" ] && [ ! -L "$bundle/$filename" ] \
      || { package_fail 'OFFLINE_BUNDLE_INVALID' "文件缺失或为链接：$filename" 46; return; }
  done < <(package_bundle_files)
  [ "$(wc -l < "$bundle/SHA256SUMS" | tr -d ' ')" = 13 ] \
    || { package_fail 'OFFLINE_BUNDLE_INVALID' '校验清单条目数量错误' 46; return; }
  {
    while IFS= read -r filename; do
      IFS=' ' read -r hash recorded extra <&3 \
        || { package_fail 'OFFLINE_BUNDLE_INVALID' '校验清单不完整' 46; return; }
      [[ "$hash" =~ ^[0-9a-f]{64}$ ]] && [ "$recorded" = "$filename" ] && [ -z "$extra" ] \
        || { package_fail 'OFFLINE_BUNDLE_INVALID' '校验清单格式或路径错误' 46; return; }
    done < <(package_bundle_files)
    extra=''
    if IFS= read -r extra <&3 || [ -n "$extra" ]; then
      package_fail 'OFFLINE_BUNDLE_INVALID' '校验清单包含额外条目' 46
      return
    fi
  } 3< "$bundle/SHA256SUMS"
  (cd -- "$bundle" && sha256sum -c SHA256SUMS) \
    || { package_fail 'OFFLINE_CHECKSUM_FAILED' '文件损坏，请重新传输完整离线包' 46; return; }
  jq -e '
    (keys == ["architecture","channel","release","schemaVersion"]) and
    .schemaVersion == 1 and .architecture == "amd64" and .channel == "stable" and
    (.release | keys == ["id","version"]) and
    (.release.id | type == "string" and test("^rel_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) and
    (.release.version | type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
  ' "$bundle/release.json" >/dev/null \
    || { package_fail 'OFFLINE_RELEASE_INVALID' '离线包版本信息无效' 46; return; }
  PACKAGE_RELEASE_VERSION="$(jq -r '.release.version' "$bundle/release.json")"
  printf '离线包校验通过，版本：%s（amd64）\n' "$PACKAGE_RELEASE_VERSION"
}

package_prepare_bundle() {
  local destination parent response filename
  destination="$1"
  package_require_commands curl jq tar sha256sum || return
  [ ! -e "$destination" ] && [ ! -L "$destination" ] \
    || { package_fail 'OFFLINE_OUTPUT_EXISTS' '请指定尚不存在的离线包目录，避免覆盖已有文件' 45; return; }
  parent="$(cd -- "$(dirname -- "$destination")" && pwd)"
  destination="${parent}/$(basename -- "$destination")"
  umask 077
  PACKAGE_WORK_DIR="$(mktemp -d "${parent}/.thinklink-offline.XXXXXX")"
  trap package_cleanup EXIT
  trap package_on_error ERR
  printf '%s\n' '[1/3] 获取 stable 发布信息；下载九个制品，耗时取决于网络与镜像大小。'
  # Fail early if invoked through stdin: the portable installer must be available on disk.
  package_export_installer "$PACKAGE_WORK_DIR/install.sh"
  response="$PACKAGE_WORK_DIR/download.json"
  package_api_create_download "$response"
  printf '下载版本：%s\n' "$(jq -r '.release.version' "$response")"
  package_download_artifacts "$response"
  printf '%s\n' '[2/3] 生成离线安装入口、版本记录与校验清单。'
  jq '{schemaVersion: 1, channel: "stable", architecture: "amd64", release: .release}' \
    "$response" > "$PACKAGE_WORK_DIR/release.json"
  rm -f -- "$response" "${response}.refresh"
  cat > "$PACKAGE_WORK_DIR/install-offline.sh" <<'WRAPPER'
#!/usr/bin/env bash
set -Eeuo pipefail
bundle_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != --yes ]; }; then
  printf '%s\n' '用法：sudo bash install-offline.sh [--yes]' >&2
  exit 2
fi
exec bash "$bundle_dir/install.sh" --offline "$bundle_dir" "$@"
WRAPPER
  chmod 0700 "$PACKAGE_WORK_DIR/install-offline.sh"
  cat > "$PACKAGE_WORK_DIR/README.txt" <<'README'
ThinkLink 离线安装包（Linux amd64，全新安装）
1. 将此完整目录复制到离线服务器。离线材料请限制访问权限。
2. 在服务器提前安装 Docker Engine、Compose V2、curl、jq、tar、sha256sum、ss、hostname。
   启动 Docker；至少 2 核、4 GiB 内存、20 GiB 可用磁盘，另留离线包与解压空间。
3. 进入本目录执行：bash install.sh --verify-only .
4. 执行：sudo bash install-offline.sh，按提示确认安装和端口。
   自动化可用 sudo bash install-offline.sh --yes；端口冲突仍需人工处理。
5. 等待健康检查通过，按输出访问应用。安装信息在 /opt/thinklink/install-info.txt。
   MinIO 客户端配置在 /opt/thinklink/minio-client.json。
   异常查看：sudo docker compose -f /opt/thinklink/docker-compose.yml logs --tail=200
本包不包含 Docker 或操作系统依赖；离线安装不会拉取镜像、构建镜像或上报安装事件。
已有安装或残留容器/数据卷时停止，请先制定备份和升级方案，不要直接清理数据后重试。
SHA256SUMS 用于核对文件转移完整性，不是发布方数字签名。
README
  (cd -- "$PACKAGE_WORK_DIR"; while IFS= read -r filename; do sha256sum "$filename"; done < <(package_bundle_files)) \
    > "$PACKAGE_WORK_DIR/SHA256SUMS"
  printf '%s\n' '[3/3] 校验完整离线包。'
  package_verify_bundle "$PACKAGE_WORK_DIR"
  [ ! -e "$destination" ] && [ ! -L "$destination" ] \
    || { package_fail 'OFFLINE_OUTPUT_EXISTS' '目标目录已被创建，停止交付' 45; return; }
  mv -- "$PACKAGE_WORK_DIR" "$destination"
  PACKAGE_WORK_DIR=''
  trap - ERR
  printf '\n离线包已准备好：%s\n复制整个目录到离线服务器，进入目录后执行：\n  sudo bash install-offline.sh\n' "$destination"
}

package_check_offline_target() {
  local containers volumes
  [ ! -e "$PACKAGE_INSTALL_ROOT" ] && [ ! -L "$PACKAGE_INSTALL_ROOT" ] \
    || { package_fail 'OFFLINE_EXISTING_INSTALL' '安装目录已存在；离线模式仅支持全新安装，请先制定备份和升级方案' 47; return; }
  containers="$(docker ps -a --filter name='^/tkl-' --format '{{.ID}}')" || return
  containers+="$(docker ps -a --filter label=com.docker.compose.project=thinklink --format '{{.ID}}')" || return
  volumes="$(docker volume ls --filter label=com.docker.compose.project=thinklink --format '{{.Name}}')" || return
  volumes+="$(docker volume ls --filter name='^thinklink_' --format '{{.Name}}')" || return
  [ -z "$containers$volumes" ] \
    || { package_fail 'OFFLINE_EXISTING_INSTALL' '发现 ThinkLink 容器或数据卷；请先制定备份和升级方案' 47; return; }
}

package_validate_application_archive() {
  local archive
  archive="$1"
  tar -tf "$archive" > "$PACKAGE_WORK_DIR/archive.paths" || return
  awk '
    /^\// { exit 1 }
    { n = split($0, parts, "/"); for (i = 1; i <= n; i++) if (parts[i] == "..") exit 1 }
  ' "$PACKAGE_WORK_DIR/archive.paths" \
    || { package_fail 'OFFLINE_ARCHIVE_UNSAFE' '应用归档包含越界路径' 46; return; }
  tar -tvf "$archive" > "$PACKAGE_WORK_DIR/archive.types" || return
  LC_ALL=C awk '
    substr($0, 1, 1) == "-" || substr($0, 1, 1) == "d" { next }
    substr($0, 1, 1) == "l" {
      # Reject ambiguous listing syntax and any link that could leave its containing directory.
      if (split($0, link, " -> ") != 2 || link[2] !~ /^[A-Za-z0-9_.+\/-]+$/ || link[2] ~ /^\//) exit 1
      n = split(link[2], parts, "/")
      for (i = 1; i <= n; i++) if (parts[i] == "..") exit 1
      next
    }
    { exit 1 }
  ' "$PACKAGE_WORK_DIR/archive.types" \
    || { package_fail 'OFFLINE_ARCHIVE_UNSAFE' '应用归档含不安全链接或特殊文件；只允许普通文件、目录和不含越界路径的相对链接' 46; return; }
}

package_install_offline() {
  local bundle choice tty platform_data
  PACKAGE_OFFLINE=1
  PACKAGE_DOWNLOAD_ID=''
  bundle="$1"
  printf '%s\n' '[1/5] 校验离线包完整性。'
  package_verify_bundle "$bundle"
  bundle="$(cd -- "$bundle" && pwd)"
  printf '%s\n' '[2/5] 检查 Linux amd64、资源、Docker 和端口。'
  platform_data="$(package_detect_platform)"
  package_check_resources
  package_require_commands curl jq tar sha256sum ss docker hostname
  package_docker_ready \
    || { package_fail 'OFFLINE_DOCKER_REQUIRED' '请提前离线安装并启动 Docker Engine 和 Compose V2；本模式不会联网安装依赖' 24; return; }
  package_check_offline_target
  package_check_ports
  printf '\n版本：%s\n安装目录：%s\nHTTP 端口：%s\n将导入七个镜像并启动 ThinkLink、MinIO 等服务。\n' \
    "$PACKAGE_RELEASE_VERSION" "$PACKAGE_INSTALL_ROOT" "$PACKAGE_HTTP_PORT"
  if [ "$2" != 1 ]; then
    tty="${PACKAGE_TTY_PATH:-/dev/tty}"
    printf '确认开始安装？[y/N]：' >&2
    if ! IFS= read -r choice < "$tty"; then
      package_fail 'OFFLINE_CONFIRMATION_REQUIRED' '请在交互终端执行，或明确使用 --yes 确认安装' 48
      return
    fi
    case "$choice" in y|Y|yes|YES) ;; *) package_fail 'INSTALL_CANCELLED' '已取消，未导入镜像或修改安装目录' 48; return ;; esac
  fi
  umask 077
  PACKAGE_WORK_DIR="$(mktemp -d /tmp/thinklink-offline.XXXXXX)"
  trap package_cleanup EXIT
  trap package_on_error ERR
  # Artifacts remain in the user's bundle; only temporary normalization files use /tmp.
  PACKAGE_ARTIFACT_DIR="$bundle"
  package_validate_application_archive "$bundle/application.tar"
  printf '%s\n' '[3/5] 解压应用、配置 Compose 和 MinIO、导入七个镜像。'
  package_deploy_release
  printf '%s\n' '[5/5] 离线安装完成，请验证登录和实际业务功能。'
  trap - ERR
}

package_check_offline_images() {
  local compose image images
  compose="$1"
  images="$(docker compose -f "$compose" config --images)" || return
  [ -n "$images" ] || { package_fail 'OFFLINE_IMAGES_MISSING' 'Compose 中没有可用镜像' 49; return; }
  while IFS= read -r image; do
    docker image inspect "$image" >/dev/null 2>&1 \
      || { package_fail 'OFFLINE_IMAGES_MISSING' "缺少 Compose 所需镜像：${image}；请交付同一版本的完整制品" 49; return; }
  done <<< "$images"
  printf '%s\n' '[4/5] 启动本地镜像（禁止拉取与构建），等待 ThinkLink 和 MinIO 健康检查。'
}

set -Eeuo pipefail
PACKAGE_SCRIPT_PATH="${BASH_SOURCE[0]:-}"

if [ "${PACKAGE_INSTALLER_BUNDLED:-0}" != '1' ]; then
  package_installer_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  # shellcheck source=config/defaults.env
  . "${package_installer_dir}/config/defaults.env"
  for package_module in common platform preflight docker api download verify deploy offline; do
    # shellcheck disable=SC1090
    . "${package_installer_dir}/lib/${package_module}.sh"
  done
  PACKAGE_API_ORIGIN="${PACKAGE_API_ORIGIN:-https://package.think-link.net}"
  PACKAGE_DOWNLOAD_ORIGIN="${PACKAGE_DOWNLOAD_ORIGIN:-https://hongkong-thinklink.oss-cn-hongkong.aliyuncs.com}"
fi

package_on_error() {
  local status="$?"
  trap - ERR
  package_api_event 'failed' 'INSTALL_FAILED'
  printf '安装失败（退出码 %s）。退出时清理临时文件。\n' "$status" >&2
  if [ "${PACKAGE_OFFLINE:-0}" = 1 ]; then
    printf '%s\n' '离线包和已写入的安装数据已保留，请检查后制定恢复方案。' >&2
    printf '查看状态：sudo docker compose -f %q ps\n' "${PACKAGE_INSTALL_ROOT}/docker-compose.yml" >&2
    printf '查看日志：sudo docker compose -f %q logs --tail=200\n' "${PACKAGE_INSTALL_ROOT}/docker-compose.yml" >&2
  fi
  return "$status"
}

package_main() {
  local platform_data platform_id platform_version platform_codename platform_mode architecture
  local response
  # Parse the entire CLI before any preflight or side effect.
  case "${1:-}" in
    --help|-h)
      [ "$#" -eq 1 ] || { package_fail 'ARGUMENT_INVALID' '请使用 --help 查看用法' 2; return; }
      package_usage; return ;;
    --download-only|--verify-only)
      [ "$#" -eq 2 ] && [ -n "$2" ] && [[ "$2" != --* ]] \
        || { package_fail 'ARGUMENT_INVALID' '请指定一个目录；使用 --help 查看用法' 2; return; }
      if [ "$1" = --download-only ]; then package_prepare_bundle "$2"
      else package_verify_bundle "$2"; fi
      return ;;
    --offline)
      { [ "$#" -eq 2 ] || { [ "$#" -eq 3 ] && [ "$3" = --yes ]; }; } \
        && [ -n "$2" ] && [[ "$2" != --* ]] \
        || { package_fail 'ARGUMENT_INVALID' '用法：--offline 目录 [--yes]' 2; return; }
      package_install_offline "$2" "$([ "$#" -eq 3 ] && printf 1 || printf 0)"
      return ;;
    '') ;;
    *) package_fail 'ARGUMENT_INVALID' '未知参数；请使用 --help 查看用法' 2; return ;;
  esac
  [ "$#" -eq 0 ] || { package_fail 'ARGUMENT_INVALID' '请使用 --help 查看用法' 2; return; }
  PACKAGE_OFFLINE=0
  PACKAGE_ARTIFACT_DIR=''
  platform_data="$(package_detect_platform)"
  IFS='|' read -r platform_id platform_version platform_codename platform_mode architecture \
    <<< "$platform_data"
  package_check_resources
  package_install_docker "$platform_id" "$platform_codename" "$platform_mode"
  package_require_commands curl jq tar ss docker hostname
  package_check_ports
  PACKAGE_WORK_DIR="$(mktemp -d /tmp/thinklink-package.XXXXXX)"
  export PACKAGE_WORK_DIR
  trap package_cleanup EXIT
  trap package_on_error ERR
  response="${PACKAGE_WORK_DIR}/download.json"
  package_api_create_download "$response"
  PACKAGE_DOWNLOAD_ID="$(jq -r '.downloadId' "$response")"
  PACKAGE_RELEASE_VERSION="$(jq -r '.release.version' "$response")"
  export PACKAGE_DOWNLOAD_ID PACKAGE_RELEASE_VERSION
  package_api_event 'started'
  package_api_event 'environment_ready'
  package_download_artifacts "$response"
  package_api_event 'artifacts_downloaded'
  package_deploy_release
  package_api_event 'healthy'
  trap - ERR
}

package_main "$@"
