#!/bin/sh
# routevia — Passwall updater + mode switcher for OpenWrt
# https://github.com/NoSleep-bot/routevia
#
# Usage: wget -qO- https://data.justownit.ru/routevia/update.sh | sh
#        or: sh update.sh

set -e

# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

# --- Localized console messages ---
# Pre-flight errors (before select_language runs) stay English. Every
# user-facing string after select_language goes through these vars so
# switching ru<->en is a single assignment block. Log lines are left
# as-is — they carry whatever was printed to the console.
LANG_CODE="en"

set_messages_en() {
    MSG_FETCH_NONHTTPS="fetch refused non-https URL: %s"
    MSG_STATE_UPDATE_FAIL="Failed to update %s"
    MSG_VERSIONS_FETCH_FAIL="Cannot fetch versions.txt"
    MSG_VERSIONS_EMPTY="versions.txt is empty"
    MSG_NO_VERSIONS="No versions available"
    MSG_GEODATA_NOT_FOUND="geodata.conf not found for %s"
    MSG_GEODATA_EMPTY="geodata.conf is empty for %s"
    MSG_GEODATA_MISSING="Incomplete geodata.conf: missing %s"
    MSG_GEODATA_NOT_HTTPS="geodata.conf: %s must be an https:// URL"
    MSG_DISK_LOW="Low disk space on %s: %sMB free, ~%sMB recommended"
    MSG_LOW_SPACE_BACKUP="Low disk space (need %sKB x2, free %sKB in %s)"
    MSG_PARTIAL_CONFIG="Ctrl+C during apply may leave Passwall with partial config"
    MSG_ROLLING_BACK="Rolling back Passwall config..."
    MSG_ROLLBACK_DONE="Passwall restarted with previous config"
    MSG_SUB_IMPORT_FAIL="uci import failed — subscriptions may need manual restore"
    MSG_SUBSCRIBE_LUA_FAIL="subscribe.lua failed — subscriptions saved, update nodes manually from LuCI"
    MSG_SUBSCRIBE_LUA_MISSING="subscribe.lua not found — subscriptions saved, update nodes manually from LuCI"
    MSG_TAR_REJECTED_PATHS="Config tar rejected (bad paths):"
    MSG_TAR_REJECTED_TYPES="Config tar rejected (non-regular entries):"
    MSG_GH_FAIL="GitHub API request failed"
    MSG_PW_VERSION_FAIL="Cannot determine Passwall version"
    MSG_IPK_NOT_FOUND="Asset '%s' not found in %s"
    MSG_PW_OUTDATED="Passwall is outdated: %s → %s"
    MSG_PW_UPDATE_FIRST="Update Passwall first (option 1) before %s."
    MSG_ACTION_APPLY="applying config"
    MSG_ACTION_CHANGE="changing mode"
    MSG_SPINNER_DONE="done"
    MSG_SPINNER_FAILED="failed"
    MSG_STEP_CHECK_UPDATES="Checking for updates..."
    MSG_STEP_DOWNLOAD_CFG_FMT="Downloading config %s/%s..."
    MSG_STEP_EXTRACT="Extracting config..."
    MSG_STEP_DONE="Done"
    MSG_STEP_GEOIP="Downloading geoip.dat..."
    MSG_STEP_GEOSITE="Downloading geosite.dat..."
    MSG_STEP_STOP_PW="Stopping Passwall..."
    MSG_STEP_START_PW="Starting Passwall..."
    MSG_STEP_UPGRADING_FMT="Upgrading %s..."
    MSG_STEP_INSTALLING_FMT="Installing luci-app-passwall %s..."
    MSG_OPKG_UPDATE_FAIL="opkg update failed (see %s)"
    MSG_OPKG_UPGRADE_FAIL="opkg upgrade failed (see %s)"
    MSG_DOWNLOAD_FAIL="Download failed"
    MSG_LUCI_INSTALL_FAIL="luci-app-passwall install failed (see %s)"
    MSG_APPLY_FAIL_FMT="Failed to apply config %s/%s"
    MSG_APPLY_FAIL_PROXY_FMT="Failed to apply config %s/proxy"
    MSG_SWITCH_FAIL_FMT="Failed to switch mode (config not found for %s/%s)"
    MSG_PASSWALL_START_FAIL="Passwall start failed"
    MSG_UP_TO_DATE_ALL="All packages up to date."
    MSG_UPDATES_AVAILABLE="Updates available:"
    MSG_CONFIG_LINE_FMT="config: none → ${GREEN}%s${NC} (proxy, fresh install)"
    MSG_CONTINUE="Continue? [y/N] "
    MSG_CANCELLED="  Cancelled."
    MSG_CONFIG_UP_TO_DATE_FMT="Config up to date (%s)"
    MSG_CONFIG_UPDATE_LINE_FMT="Config update: ${YELLOW}%s${NC} → ${GREEN}%s${NC}"
    MSG_CURRENT_MODE_LABEL="Current mode"
    MSG_MODE_LABEL="Mode"
    MSG_MODE_NOT_SET="not set"
    MSG_MODE_MENU_PROXY="1. Default Proxy (recommended)"
    MSG_MODE_MENU_DIRECT="2. Default Direct"
    MSG_MODE_MENU_BACK="0. Back"
    MSG_MODE_SELECT="Select [0-2]: "
    MSG_INVALID_OPTION="Invalid option."
    MSG_ALREADY_IN_MODE_FMT="Already in %s mode."
    MSG_SWITCHING_FMT="Switching to ${GREEN}%s${NC} mode (config ${GREEN}%s${NC})"
    MSG_MODE_CHANGED_FMT="Mode changed to %s (config %s, Passwall stopped)"
    MSG_ENABLE_PASSWALL="Enable Passwall manually: Services -> PassWall -> Enable"
    MSG_CONFIG_UPDATED_LINE_FMT="Config updated: %s → %s"
    MSG_MODE_LINE_FMT="Mode: %s"
    MSG_LOG_LINE_FMT="Log: %s"
    MSG_PASSWALL_NOT_INSTALLED="Passwall is not installed."
    MSG_SCRIPT_SCOPE="This script only updates and switches modes on an existing install."
    MSG_MENU_1="1. Update PassWall"
    MSG_MENU_2="2. Update Config"
    MSG_MENU_3="3. Change Route Mode"
    MSG_MENU_4="4. Exit"
    MSG_MENU_SELECT="Select [1-4]: "
    MSG_BYE="  Bye."
}

set_messages_ru() {
    MSG_FETCH_NONHTTPS="fetch: отказ на non-https URL: %s"
    MSG_STATE_UPDATE_FAIL="Не удалось обновить %s"
    MSG_VERSIONS_FETCH_FAIL="Не удалось скачать versions.txt"
    MSG_VERSIONS_EMPTY="versions.txt пустой"
    MSG_NO_VERSIONS="Версий не найдено"
    MSG_GEODATA_NOT_FOUND="geodata.conf не найден для %s"
    MSG_GEODATA_EMPTY="geodata.conf пустой для %s"
    MSG_GEODATA_MISSING="geodata.conf неполный: нет %s"
    MSG_GEODATA_NOT_HTTPS="geodata.conf: %s должен быть https:// URL"
    MSG_DISK_LOW="Мало места на %s: свободно %sMB, рекомендуется ~%sMB"
    MSG_LOW_SPACE_BACKUP="Мало места (нужно %sKB x2, свободно %sKB в %s)"
    MSG_PARTIAL_CONFIG="Ctrl+C во время apply может оставить Passwall с частичным конфигом"
    MSG_ROLLING_BACK="Откат конфига Passwall..."
    MSG_ROLLBACK_DONE="Passwall перезапущен с предыдущим конфигом"
    MSG_SUB_IMPORT_FAIL="uci import упал — возможно, подписки надо восстановить вручную"
    MSG_SUBSCRIBE_LUA_FAIL="subscribe.lua упал — подписки сохранены, обнови ноды вручную через LuCI"
    MSG_SUBSCRIBE_LUA_MISSING="subscribe.lua не найден — подписки сохранены, обнови ноды вручную через LuCI"
    MSG_TAR_REJECTED_PATHS="Config tar отклонён (недопустимые пути):"
    MSG_TAR_REJECTED_TYPES="Config tar отклонён (нерегулярные записи):"
    MSG_GH_FAIL="Ошибка запроса к GitHub API"
    MSG_PW_VERSION_FAIL="Не удалось определить версию Passwall"
    MSG_IPK_NOT_FOUND="Asset '%s' не найден в %s"
    MSG_PW_OUTDATED="Passwall устарел: %s → %s"
    MSG_PW_UPDATE_FIRST="Сначала обнови Passwall (пункт 1), потом %s."
    MSG_ACTION_APPLY="применяй конфиг"
    MSG_ACTION_CHANGE="меняй режим"
    MSG_SPINNER_DONE="готово"
    MSG_SPINNER_FAILED="упал"
    MSG_STEP_CHECK_UPDATES="Проверка обновлений..."
    MSG_STEP_DOWNLOAD_CFG_FMT="Скачивание конфига %s/%s..."
    MSG_STEP_EXTRACT="Распаковка конфига..."
    MSG_STEP_DONE="Готово"
    MSG_STEP_GEOIP="Скачивание geoip.dat..."
    MSG_STEP_GEOSITE="Скачивание geosite.dat..."
    MSG_STEP_STOP_PW="Остановка Passwall..."
    MSG_STEP_START_PW="Запуск Passwall..."
    MSG_STEP_UPGRADING_FMT="Обновление %s..."
    MSG_STEP_INSTALLING_FMT="Установка luci-app-passwall %s..."
    MSG_OPKG_UPDATE_FAIL="opkg update упал (см. %s)"
    MSG_OPKG_UPGRADE_FAIL="opkg upgrade упал (см. %s)"
    MSG_DOWNLOAD_FAIL="Скачивание упало"
    MSG_LUCI_INSTALL_FAIL="luci-app-passwall не установился (см. %s)"
    MSG_APPLY_FAIL_FMT="Не удалось применить конфиг %s/%s"
    MSG_APPLY_FAIL_PROXY_FMT="Не удалось применить конфиг %s/proxy"
    MSG_SWITCH_FAIL_FMT="Не удалось сменить режим (конфиг не найден для %s/%s)"
    MSG_PASSWALL_START_FAIL="Passwall start упал"
    MSG_UP_TO_DATE_ALL="Все пакеты актуальны."
    MSG_UPDATES_AVAILABLE="Доступны обновления:"
    MSG_CONFIG_LINE_FMT="config: нет → ${GREEN}%s${NC} (proxy, fresh install)"
    MSG_CONTINUE="Продолжить? [y/N] "
    MSG_CANCELLED="  Отменено."
    MSG_CONFIG_UP_TO_DATE_FMT="Конфиг актуален (%s)"
    MSG_CONFIG_UPDATE_LINE_FMT="Обновление конфига: ${YELLOW}%s${NC} → ${GREEN}%s${NC}"
    MSG_CURRENT_MODE_LABEL="Текущий режим"
    MSG_MODE_LABEL="Режим"
    MSG_MODE_NOT_SET="не задан"
    MSG_MODE_MENU_PROXY="1. Default Proxy (рекомендуется)"
    MSG_MODE_MENU_DIRECT="2. Default Direct"
    MSG_MODE_MENU_BACK="0. Назад"
    MSG_MODE_SELECT="Выбор [0-2]: "
    MSG_INVALID_OPTION="Неверный пункт."
    MSG_ALREADY_IN_MODE_FMT="Уже в режиме %s."
    MSG_SWITCHING_FMT="Переключение в режим ${GREEN}%s${NC} (конфиг ${GREEN}%s${NC})"
    MSG_MODE_CHANGED_FMT="Режим изменён на %s (конфиг %s, Passwall остановлен)"
    MSG_ENABLE_PASSWALL="Включи Passwall вручную: Services -> PassWall -> Enable"
    MSG_CONFIG_UPDATED_LINE_FMT="Конфиг обновлён: %s → %s"
    MSG_MODE_LINE_FMT="Режим: %s"
    MSG_LOG_LINE_FMT="Лог: %s"
    MSG_PASSWALL_NOT_INSTALLED="Passwall не установлен."
    MSG_SCRIPT_SCOPE="Скрипт только обновляет и переключает режимы на уже установленном Passwall."
    MSG_MENU_1="1. Обновить PassWall"
    MSG_MENU_2="2. Обновить конфиг"
    MSG_MENU_3="3. Сменить режим маршрутизации"
    MSG_MENU_4="4. Выход"
    MSG_MENU_SELECT="Выбор [1-4]: "
    MSG_BYE="  Пока."
}

set_messages() {
    case "$LANG_CODE" in
        ru) set_messages_ru ;;
        *)  set_messages_en ;;
    esac
}

# Interactive language chooser. Bilingual labels so the user sees both
# options regardless of current state. Default Enter = English.
select_language() {
    echo ""
    echo "  Language / Язык"
    echo "    1. English"
    echo "    2. Русский"
    printf "  Select / Выбор [1-2]: "
    read -r _lang_choice < /dev/tty
    case "$_lang_choice" in
        2) LANG_CODE="ru" ;;
        *) LANG_CODE="en" ;;
    esac
}

STATEFILE="/etc/routevia.conf"
REPO_BASE="https://data.justownit.ru/routevia/configs"

# Private per-run temp dir. Avoids predictable /tmp paths that a
# local attacker could symlink to /etc/shadow, etc.
TMPD=$(mktemp -d /tmp/routevia.XXXXXX 2>/dev/null) || {
    printf "  [x] Cannot create temp dir under /tmp\n" >&2
    exit 1
}
trap 'rm -rf "$TMPD"' EXIT

LOGFILE="$TMPD/update.log"

# --- Helpers ---
log()   { printf "  ${GREEN}[+]${NC} %s\n" "$1"; echo "[+] $1" >> "$LOGFILE"; }
warn()  { printf "  ${YELLOW}[!]${NC} %s\n" "$1"; echo "[!] $1" >> "$LOGFILE"; }
die()   { printf "  ${RED}[x]${NC} %s\n" "$1"; echo "[x] $1" >> "$LOGFILE"; exit 1; }

run() {
    echo ">>> $*" >> "$LOGFILE"
    if ! "$@" >> "$LOGFILE" 2>&1; then
        tail -20 "$LOGFILE" >&2
        return 1
    fi
}

# Run a command in the background with a rotating spinner prefix.
# $1 = message, $2... = command. Stdout/stderr go to LOGFILE.
# Returns the command's exit code.
run_with_spinner() {
    _msg=$1
    shift
    echo ">>> $*" >> "$LOGFILE"
    "$@" >> "$LOGFILE" 2>&1 &
    _spin_pid=$!
    _chars='|/-\'
    _i=0
    # Paint the first frame before the loop so the user sees the task
    # label even if the command finishes before the first kill -0 check.
    printf "\r  ${GREEN}[|]${NC} %s" "$_msg"
    while kill -0 "$_spin_pid" 2>/dev/null; do
        _c=$(printf '%s' "$_chars" | cut -c$((_i % 4 + 1)))
        printf "\r  ${GREEN}[%s]${NC} %s" "$_c" "$_msg"
        _i=$((_i + 1))
        sleep 1
    done
    _rc=0
    wait "$_spin_pid" || _rc=$?
    if [ "$_rc" -eq 0 ]; then
        printf "\r\033[K  ${GREEN}[+]${NC} %s %s\n" "$_msg" "${MSG_SPINNER_DONE:-done}"
    else
        printf "\r\033[K  ${RED}[x]${NC} %s %s\n" "$_msg" "${MSG_SPINNER_FAILED:-failed}"
    fi
    return "$_rc"
}

# --- Progress bar ---
TOTAL_STEPS=1
CURRENT_STEP=0

progress_init() { TOTAL_STEPS=$1; CURRENT_STEP=0; }

show_progress() {
    pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
    filled=$((pct / 5))
    bar=""
    i=0
    while [ $i -lt 20 ]; do
        if [ $i -lt $filled ]; then bar="${bar}#"; else bar="${bar}-"; fi
        i=$((i + 1))
    done
    printf "\r  [${GREEN}${bar}${NC}] %d%%" "$pct"
}

step() {
    CURRENT_STEP=$((CURRENT_STEP + 1))
    printf "\r\033[K${GREEN}[+]${NC} %s\n" "$1"
    echo "[+] $1" >> "$LOGFILE"
    show_progress
}

step_log() {
    printf "\r\033[K    %s\n" "$1"
    echo "    $1" >> "$LOGFILE"
    show_progress
}

clear_progress() { printf "\r\033[K"; }

# --- Pre-flight checks ---
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo sh update.sh"
command -v opkg >/dev/null 2>&1 || die "opkg not found"

if [ -f /etc/openwrt_release ]; then
    . /etc/openwrt_release
else
    die "/etc/openwrt_release not found"
fi

RELEASE="${DISTRIB_RELEASE:-unknown}"
ARCH=$(opkg print-architecture | awk '{print $2}' | grep -v "^all$" | grep -v "^noarch$" | tail -1)
[ -z "$ARCH" ] && die "Cannot detect architecture"

# --- HTTPS fetcher ---
# BusyBox wget's TLS verification depends on build (mbedtls vs stub).
# Prefer curl and uclient-fetch, which enforce cert verification by default.
FETCHER=""
if command -v curl >/dev/null 2>&1; then
    FETCHER="curl"
elif command -v uclient-fetch >/dev/null 2>&1; then
    FETCHER="uclient-fetch"
elif command -v wget >/dev/null 2>&1; then
    FETCHER="wget"
else
    die "No HTTPS fetcher (curl, uclient-fetch, or wget) available"
fi

# Download $1 (URL) to $2 (file). Rejects non-https URLs so a future
# typo or redirect cannot leak into plaintext.
fetch() {
    _url=$1
    _out=$2
    case "$_url" in
        https://*) ;;
        *) die "$(printf "$MSG_FETCH_NONHTTPS" "$_url")" ;;
    esac
    case "$FETCHER" in
        curl)          curl -fsS --proto '=https' --tlsv1.2 -o "$_out" "$_url" ;;
        uclient-fetch) uclient-fetch -qO "$_out" "$_url" ;;
        wget)          wget -qO "$_out" "$_url" ;;
    esac
}

# --- State file helpers ---
state_read() {
    # Read a key from state file ($1 = key name)
    if [ -f "$STATEFILE" ]; then
        grep "^${1}=" "$STATEFILE" 2>/dev/null | head -1 | cut -d'=' -f2-
    fi
}

state_write() {
    # Write a key=value to state file ($1 = key, $2 = value).
    # Rewrites the file through a temp copy instead of `sed -i s|..|..|`,
    # so values containing `/`, `&`, `\` or any regex metacharacter are
    # stored verbatim. Rename is atomic within the same filesystem.
    _key=$1
    _val=$2
    _tmp="${STATEFILE}.tmp.$$"
    _found=0

    : > "$_tmp"

    if [ -f "$STATEFILE" ]; then
        while IFS= read -r _line; do
            case "$_line" in
                "${_key}="*)
                    printf '%s=%s\n' "$_key" "$_val" >> "$_tmp"
                    _found=1
                    ;;
                *)
                    printf '%s\n' "$_line" >> "$_tmp"
                    ;;
            esac
        done < "$STATEFILE"
    fi

    [ "$_found" -eq 0 ] && printf '%s=%s\n' "$_key" "$_val" >> "$_tmp"

    mv "$_tmp" "$STATEFILE" 2>/dev/null || { rm -f "$_tmp"; die "$(printf "$MSG_STATE_UPDATE_FAIL" "$STATEFILE")"; }
}

# --- Detect current state ---
PW_INSTALLED=""
PW_CURRENT_VER=""
CURRENT_MODE=""

detect_state() {
    if opkg list-installed 2>/dev/null | grep -q "luci-app-passwall"; then
        PW_INSTALLED="yes"
        PW_CURRENT_VER=$(opkg list-installed 2>/dev/null | grep "luci-app-passwall" | head -1 | awk '{print $3}')
    fi
    CURRENT_MODE=$(state_read MODE)
    CURRENT_MODE="${CURRENT_MODE:-unknown}"
}

# --- Version comparison (BusyBox-compatible, no sort -V) ---
# Returns 0 if $1 > $2. Handles arbitrary version formats by splitting on any
# non-digit separator and comparing numeric segments left-to-right. Missing
# segments are treated as 0 so "26.4.6-1" > "26.4.6".
version_gt() {
    awk -v a="$1" -v b="$2" 'BEGIN {
        na = split(a, ap, /[^0-9]+/)
        nb = split(b, bp, /[^0-9]+/)
        n = na > nb ? na : nb
        for (i = 1; i <= n; i++) {
            x = ap[i] + 0
            y = bp[i] + 0
            if (x > y) exit 0
            if (x < y) exit 1
        }
        exit 1
    }'
}

# Fetch latest config version from versions.txt
# Sets TARGET_VER
get_latest_config_version() {
    VERSIONS_URL="${REPO_BASE}/versions.txt"
    VERSIONS_FILE="$TMPD/versions.txt"

    fetch "$VERSIONS_URL" "$VERSIONS_FILE" 2>/dev/null || die "$MSG_VERSIONS_FETCH_FAIL"
    [ -s "$VERSIONS_FILE" ] || die "$MSG_VERSIONS_EMPTY"

    TARGET_VER=""
    while IFS= read -r _line; do
        _line=$(echo "$_line" | sed 's/\r//g; s/ //g')
        [ -z "$_line" ] && continue
        if [ -z "$TARGET_VER" ]; then
            TARGET_VER="$_line"
        elif version_gt "$_line" "$TARGET_VER"; then
            TARGET_VER="$_line"
        fi
    done < "$VERSIONS_FILE"
    rm -f "$VERSIONS_FILE"

    [ -z "$TARGET_VER" ] && die "$MSG_NO_VERSIONS"
    echo "Latest config version: ${TARGET_VER}" >> "$LOGFILE"
}

# Extract a KEY=VALUE entry from a plain-text config file without sourcing.
# Strips CR and trims at first whitespace so trailing garbage is ignored.
# $1 = file, $2 = key.
_read_conf_url() {
    grep "^${2}=" "$1" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/[[:space:]].*$//'
}

# Validate that a geodata URL is an https:// link, die otherwise.
# $1 = value, $2 = key name (for error message).
_require_https_url() {
    [ -z "$1" ] && die "$(printf "$MSG_GEODATA_MISSING" "$2")"
    case "$1" in
        https://*) return 0 ;;
        *) die "$(printf "$MSG_GEODATA_NOT_HTTPS" "$2")" ;;
    esac
}

# Load geodata URLs from geodata.conf ($1 = version)
# Sets GEOIP_PROXY, GEOSITE_PROXY, GEOIP_DIRECT, GEOSITE_DIRECT.
# Remote content is parsed, never sourced, to block shell-code injection.
load_geodata_conf() {
    _ver=$1
    GEODATA_URL="${REPO_BASE}/${_ver}/geodata.conf"
    GEODATA_FILE="$TMPD/geodata.conf"

    fetch "$GEODATA_URL" "$GEODATA_FILE" 2>/dev/null || die "$(printf "$MSG_GEODATA_NOT_FOUND" "$_ver")"
    [ -s "$GEODATA_FILE" ] || die "$(printf "$MSG_GEODATA_EMPTY" "$_ver")"

    GEOIP_PROXY=$(_read_conf_url "$GEODATA_FILE" GEOIP_PROXY)
    GEOSITE_PROXY=$(_read_conf_url "$GEODATA_FILE" GEOSITE_PROXY)
    GEOIP_DIRECT=$(_read_conf_url "$GEODATA_FILE" GEOIP_DIRECT)
    GEOSITE_DIRECT=$(_read_conf_url "$GEODATA_FILE" GEOSITE_DIRECT)
    rm -f "$GEODATA_FILE"

    _require_https_url "$GEOIP_PROXY"    GEOIP_PROXY
    _require_https_url "$GEOSITE_PROXY"  GEOSITE_PROXY
    _require_https_url "$GEOIP_DIRECT"   GEOIP_DIRECT
    _require_https_url "$GEOSITE_DIRECT" GEOSITE_DIRECT

    echo "Loaded geodata.conf for ${_ver}" >> "$LOGFILE"
}

# Human-readable size for a file path ($1 = path)
human_size() {
    _bytes=$(wc -c < "$1" 2>/dev/null || echo 0)
    echo "$_bytes" | awk '{
        if ($1 >= 1048576) printf "%.1f MB", $1/1048576
        else if ($1 >= 1024) printf "%.1f KB", $1/1024
        else printf "%d B", $1
    }'
}

# Warn (but don't block) if the filesystem holding $2 has less than $1
# MB free. Saves the user from watching a download fail mid-way on a
# flash-constrained device. Rollback covers the actual partial-write
# case; this is purely a UX signal.
check_target_space() {
    _need_mb=$1
    _path=${2:-/usr/share}
    _free_kb=$(df -k "$_path" 2>/dev/null | awk 'NR==2 {print $4}')
    _free_mb=$(( ${_free_kb:-0} / 1024 ))
    if [ "$_free_mb" -lt "$_need_mb" ]; then
        warn "$(printf "$MSG_DISK_LOW" "$_path" "$_free_mb" "$_need_mb")"
    fi
}

# Apply geodata for a given mode ($1 = version, $2 = proxy|direct)
# Produces 2 progress steps (geoip, geosite). Caller must progress_init.
apply_geodata() {
    _ver=$1
    _mode=$2
    GEODIR="/usr/share/v2ray"
    mkdir -p "$GEODIR"

    load_geodata_conf "$_ver"

    if [ "$_mode" = "proxy" ]; then
        _geoip_url="$GEOIP_PROXY"
        _geosite_url="$GEOSITE_PROXY"
    else
        _geoip_url="$GEOIP_DIRECT"
        _geosite_url="$GEOSITE_DIRECT"
    fi

    step "$MSG_STEP_GEOIP"
    echo "URL: $_geoip_url" >> "$LOGFILE"
    fetch "$_geoip_url" "$GEODIR/geoip.dat" || return 1
    step_log "geoip.dat $(human_size "$GEODIR/geoip.dat")"

    step "$MSG_STEP_GEOSITE"
    echo "URL: $_geosite_url" >> "$LOGFILE"
    fetch "$_geosite_url" "$GEODIR/geosite.dat" || return 1
    step_log "geosite.dat $(human_size "$GEODIR/geosite.dat")"

    return 0
}

# Save every Passwall `subscribe_list` section before config overwrite.
# Uses `uci export` so all fields (including DynamicList entries like
# filter_discard_list and any future keys) are captured verbatim.
# The runtime-only `md5` field is stripped so re-import does not lock
# the subscription cache on stale data.
save_subscription() {
    _SUB_BACKUP="$TMPD/subscribe-backup.txt"
    : > "$_SUB_BACKUP"

    uci -q export passwall 2>/dev/null | awk '
        /^package passwall/ { print; next }
        /^config / {
            in_sub = ($2 == "subscribe_list")
            if (in_sub) print
            next
        }
        /^$/ { next }
        in_sub && $1 == "option" && $2 == "md5" { next }
        in_sub { print }
    ' > "$_SUB_BACKUP"

    _SUB_COUNT=$(grep -c "^config subscribe_list" "$_SUB_BACKUP" 2>/dev/null)
    [ -z "$_SUB_COUNT" ] && _SUB_COUNT=0
    [ "$_SUB_COUNT" -gt 0 ] && echo "Saved ${_SUB_COUNT} subscription(s)" >> "$LOGFILE"
}

# Restore saved subscriptions and trigger a node refresh for every one
# of them. Any subscribe_list sections that arrived via the fresh
# tarball are dropped first so we do not end up with duplicates.
restore_subscription() {
    [ -z "${_SUB_BACKUP:-}" ] && return 0
    [ -f "$_SUB_BACKUP" ] && [ -s "$_SUB_BACKUP" ] || return 0
    [ "${_SUB_COUNT:-0}" -eq 0 ] && return 0

    echo "Restoring ${_SUB_COUNT} subscription(s)..." >> "$LOGFILE"

    # Drop any subscribe_list carried in by the new config to avoid duplicates
    while uci -q delete passwall.@subscribe_list[0] 2>/dev/null; do :; done

    if ! uci -q import passwall < "$_SUB_BACKUP"; then
        warn "$MSG_SUB_IMPORT_FAIL"
        return 0
    fi
    uci commit passwall >> "$LOGFILE" 2>&1

    if [ -f /usr/share/passwall/subscribe.lua ]; then
        echo "Fetching nodes for all subscriptions..." >> "$LOGFILE"
        lua /usr/share/passwall/subscribe.lua start all manual >> "$LOGFILE" 2>&1 || {
            warn "$MSG_SUBSCRIBE_LUA_FAIL"
        }
    else
        warn "$MSG_SUBSCRIBE_LUA_MISSING"
    fi

    return 0
}

# Validate that a config tarball only writes to an expected allowlist of
# paths and contains only regular files. Prevents arbitrary filesystem
# overwrite from a tampered archive (e.g. symlink to /etc/shadow,
# ../../usr/bin/opkg, absolute paths). Dies on any violation.
_validate_config_tar() {
    _tar=$1

    _bad_paths=$(tar tzf "$_tar" 2>> "$LOGFILE" | awk '
        {
            if ($0 ~ /(^|\/)\.\.(\/|$)/)              { print "traversal: " $0; next }
            if ($0 ~ /^\//)                           { print "absolute: "  $0; next }
            if ($0 == "etc/config/passwall")          next
            if ($0 == "etc/config/passwall_server")   next
            if ($0 ~ /^usr\/share\/passwall\/rules\/[^\/]+$/) next
            print "not-allowlisted: " $0
        }
    ')
    [ -n "$_bad_paths" ] && die "${MSG_TAR_REJECTED_PATHS}
${_bad_paths}"

    # Reject anything that is not a regular file. BusyBox and GNU/BSD tar
    # all use ls-style perm prefixes ('-' file, 'd' dir, 'l' symlink, ...).
    _bad_types=$(tar tzvf "$_tar" 2>> "$LOGFILE" | awk '/^[^-]/ { print }')
    [ -n "$_bad_types" ] && die "${MSG_TAR_REJECTED_TYPES}
${_bad_types}"

    return 0
}

# --- Rollback protection for apply_mode ---
# Paths touched by apply_mode. Kept as a single source of truth so the
# backup and rollback loops stay in sync.
_ROLLBACK_BAK=""
_ROLLBACK_FILES="/etc/config/passwall /etc/config/passwall_server /usr/share/v2ray/geoip.dat /usr/share/v2ray/geosite.dat"
_ROLLBACK_DIRS="/usr/share/passwall/rules"

# Copy current Passwall config + geodata to a backup dir so we can
# restore it on interrupt/failure. If free space is short, skip the
# backup and warn the user — we don't want to brick the router over
# a rollback buffer.
backup_config() {
    _ROLLBACK_BAK=""
    _bak="$TMPD/backup"

    _need=0
    for _p in $_ROLLBACK_FILES $_ROLLBACK_DIRS; do
        if [ -e "$_p" ]; then
            _sz=$(du -sk "$_p" 2>/dev/null | awk '{print $1}')
            _need=$((_need + ${_sz:-0}))
        fi
    done

    _free=$(df -k "$TMPD" 2>/dev/null | awk 'NR==2 {print $4}')
    _free=${_free:-0}

    # Require 2x headroom: backup + room for the new files to land.
    if [ $((_need * 2)) -gt "$_free" ]; then
        warn "$(printf "$MSG_LOW_SPACE_BACKUP" "$_need" "$_free" "$TMPD")"
        warn "$MSG_PARTIAL_CONFIG"
        return 0
    fi

    mkdir -p "$_bak/etc/config" "$_bak/usr/share/passwall" "$_bak/usr/share/v2ray"
    for _f in $_ROLLBACK_FILES; do
        [ -e "$_f" ] && cp "$_f" "$_bak$(dirname "$_f")/" 2>> "$LOGFILE"
    done
    for _d in $_ROLLBACK_DIRS; do
        [ -d "$_d" ] && cp -a "$_d" "$_bak$(dirname "$_d")/" 2>> "$LOGFILE"
    done

    _ROLLBACK_BAK="$_bak"
    echo "Rollback backup at $_bak" >> "$LOGFILE"
}

# Restore config from backup and start Passwall. Idempotent: the
# sentinel is cleared so a second invocation (e.g. double Ctrl+C) is a
# no-op.
_apply_rollback() {
    [ -z "$_ROLLBACK_BAK" ]   && return 0
    [ ! -d "$_ROLLBACK_BAK" ] && return 0

    warn "$MSG_ROLLING_BACK"
    for _f in $_ROLLBACK_FILES; do
        _src="$_ROLLBACK_BAK$_f"
        [ -e "$_src" ] && cp "$_src" "$_f" 2>> "$LOGFILE"
    done
    for _d in $_ROLLBACK_DIRS; do
        _src="$_ROLLBACK_BAK$_d"
        if [ -d "$_src" ]; then
            rm -rf "$_d"
            cp -a "$_src" "$(dirname "$_d")/" 2>> "$LOGFILE"
        fi
    done

    /etc/init.d/passwall start >/dev/null 2>&1 || true
    warn "$MSG_ROLLBACK_DONE"
    _ROLLBACK_BAK=""
}

# Apply config + geodata for a given mode ($1 = version, $2 = proxy|direct)
# Produces 4 progress steps: download config, extract, geoip, geosite.
# Caller must progress_init with enough capacity.
apply_mode() {
    _ver=$1
    _mode=$2
    CONFIG_URL="${REPO_BASE}/${_ver}/${_mode}.tar.gz"
    CONFIG_FILE="$TMPD/passwall-config.tar.gz"

    # geoip.dat (~5MB) + geosite.dat (~10MB) + config/rules + ipk headroom.
    check_target_space 30 /usr/share

    step "$(printf "$MSG_STEP_DOWNLOAD_CFG_FMT" "$_ver" "$_mode")"
    echo "URL: ${CONFIG_URL}" >> "$LOGFILE"
    if ! fetch "$CONFIG_URL" "$CONFIG_FILE" 2>/dev/null || ! [ -s "$CONFIG_FILE" ]; then
        rm -f "$CONFIG_FILE"
        return 1
    fi
    step_log "${_mode}.tar.gz $(human_size "$CONFIG_FILE")"

    _validate_config_tar "$CONFIG_FILE"

    # Save subscription before overwriting config
    save_subscription

    # Prepare rollback snapshot before the destructive phases.
    backup_config
    trap '_apply_rollback; exit 130' INT TERM

    # Stop Passwall before replacing config (no-op if already stopped)
    /etc/init.d/passwall stop >> "$LOGFILE" 2>&1 || true

    step "$MSG_STEP_EXTRACT"
    if ! tar xzf "$CONFIG_FILE" -C / >> "$LOGFILE" 2>&1; then
        rm -f "$CONFIG_FILE"
        _apply_rollback
        trap - INT TERM
        return 1
    fi
    rm -f "$CONFIG_FILE"
    step_log "$MSG_STEP_DONE"

    if ! apply_geodata "$_ver" "$_mode"; then
        _apply_rollback
        trap - INT TERM
        return 1
    fi

    # Restore subscription + fetch nodes
    restore_subscription

    # Persist MODE and VERSION together so state never reports the new
    # mode while still pointing at the old version (or vice versa).
    state_write MODE "$_mode"
    state_write VERSION "$_ver"
    CURRENT_MODE="$_mode"

    # Disarm rollback: config is now coherent.
    trap - INT TERM
    _ROLLBACK_BAK=""
    return 0
}

detect_state

# --- SourceForge / GitHub patterns ---
case "$RELEASE" in
    *SNAPSHOT*|*snapshot*)
        # OpenWrt main-branch snapshots track the post-25.12 apk layout.
        SF_PATH="snapshots"
        BRANCH="snapshot"
        ;;
    *)
        BRANCH=$(echo "$RELEASE" | sed 's/\([0-9]*\.[0-9]*\).*/\1/')
        SF_PATH="releases/packages-${BRANCH}"
        ;;
esac

SF_BASE="https://downloads.sourceforge.net/project/openwrt-passwall-build/${SF_PATH}/${ARCH}"

case "$BRANCH" in
    snapshot|25.*) IPK_PATTERN="25.12+_luci-app-passwall"; IPK_EXT=".apk" ;;
    23.*|24.*)     IPK_PATTERN="23.05-24.10_luci-app-passwall"; IPK_EXT=".ipk" ;;
    *)             IPK_PATTERN="22.03-_luci-app-passwall"; IPK_EXT=".ipk" ;;
esac

# Fetch latest luci-app-passwall release from GitHub.
# Sets PW_VERSION (tag_name) and IPK_URL (matching IPK_PATTERN).
fetch_luci_release() {
    GH_API="https://api.github.com/repos/Openwrt-Passwall/openwrt-passwall/releases/latest"
    GH_JSON="$TMPD/pw_release.json"
    fetch "$GH_API" "$GH_JSON" || die "$MSG_GH_FAIL"
    sed -i 's/,"/,\n"/g' "$GH_JSON" 2>/dev/null || true
    PW_VERSION=$(grep '"tag_name"' "$GH_JSON" | head -1 | sed 's/.*"tag_name"[^"]*"\([^"]*\)".*/\1/')
    [ -z "$PW_VERSION" ] && die "$MSG_PW_VERSION_FAIL"
    IPK_URL=$(grep '"browser_download_url"' "$GH_JSON" | grep "${IPK_PATTERN}" | grep -v "zh-cn" | head -1 | sed 's/.*"browser_download_url"[^"]*"\([^"]*\)".*/\1/')
    [ -z "$IPK_URL" ] && die "$(printf "$MSG_IPK_NOT_FOUND" "$IPK_PATTERN" "$PW_VERSION")"
    rm -f "$GH_JSON"
}

# Abort caller with a message if installed Passwall is older than the latest
# GitHub release. Configs target the latest Passwall, so mismatches conflict.
# $1 = action phrase ("applying config", "changing mode").
# Returns 1 when outdated so caller can `|| return`.
require_latest_passwall() {
    fetch_luci_release
    _cur_normalized=$(echo "$PW_CURRENT_VER" | sed 's/-r/-/')
    if [ "$_cur_normalized" != "$PW_VERSION" ]; then
        echo ""
        printf "  ${YELLOW}${MSG_PW_OUTDATED}${NC}\n" "$PW_CURRENT_VER" "$PW_VERSION"
        printf "  ${MSG_PW_UPDATE_FIRST}\n" "$1"
        echo ""
        return 1
    fi
    return 0
}

# =====================================================================
# FUNCTION: Update PassWall packages (xray, geoview, luci-app-passwall)
# =====================================================================
do_update_passwall() {
    : > "$LOGFILE"

    echo ""

    # Refresh package lists (needed for list-upgradable)
    if ! run_with_spinner "$MSG_STEP_CHECK_UPDATES" opkg update; then
        die "$(printf "$MSG_OPKG_UPDATE_FAIL" "$LOGFILE")"
    fi

    # Check xray-core / geoview upgradable via opkg
    UPGRADABLE=$(opkg list-upgradable 2>/dev/null || true)
    XRAY_UPGRADABLE=""
    GEOVIEW_UPGRADABLE=""
    echo "$UPGRADABLE" | grep -q "^xray-core " && XRAY_UPGRADABLE="yes"
    echo "$UPGRADABLE" | grep -q "^geoview " && GEOVIEW_UPGRADABLE="yes"

    # Fetch latest luci-app-passwall version from GitHub Releases
    fetch_luci_release

    # Normalize opkg version ("26.4.6-r1") to GitHub tag format ("26.4.6-1")
    LUCI_UPGRADABLE=""
    _cur_normalized=$(echo "$PW_CURRENT_VER" | sed 's/-r/-/')
    [ "$_cur_normalized" != "$PW_VERSION" ] && LUCI_UPGRADABLE="yes"

    # Fresh install: Passwall present but no routevia config applied yet.
    # Trigger initial config apply (mode=proxy) after package update.
    NEEDS_CONFIG=""
    [ -z "$(state_read VERSION)" ] && NEEDS_CONFIG="yes"

    # Nothing to do?
    if [ -z "$XRAY_UPGRADABLE" ] && [ -z "$GEOVIEW_UPGRADABLE" ] && [ -z "$LUCI_UPGRADABLE" ] && [ -z "$NEEDS_CONFIG" ]; then
        echo ""
        printf "  ${GREEN}%s${NC}\n" "$MSG_UP_TO_DATE_ALL"
        printf "    Passwall: ${GREEN}%s${NC}\n" "$PW_CURRENT_VER"
        echo ""
        return
    fi

    # Resolve latest config version for fresh install
    if [ -n "$NEEDS_CONFIG" ]; then
        get_latest_config_version
    fi

    # Show what will be updated
    echo ""
    printf "  ${YELLOW}%s${NC}\n" "$MSG_UPDATES_AVAILABLE"
    if [ -n "$LUCI_UPGRADABLE" ]; then
        printf "    luci-app-passwall: %s → ${GREEN}%s${NC}\n" "$PW_CURRENT_VER" "$PW_VERSION"
    fi
    if [ -n "$XRAY_UPGRADABLE" ]; then
        _xray_line=$(echo "$UPGRADABLE" | grep "^xray-core " | head -1)
        printf "    %s\n" "$_xray_line"
    fi
    if [ -n "$GEOVIEW_UPGRADABLE" ]; then
        _geoview_line=$(echo "$UPGRADABLE" | grep "^geoview " | head -1)
        printf "    %s\n" "$_geoview_line"
    fi
    if [ -n "$NEEDS_CONFIG" ]; then
        printf "    ${MSG_CONFIG_LINE_FMT}\n" "$TARGET_VER"
    fi
    echo ""
    printf "  %s" "$MSG_CONTINUE"
    read -r confirm < /dev/tty
    case "$confirm" in y|Y) ;; *) echo "$MSG_CANCELLED"; return ;; esac
    echo ""

    # Count steps: stop + [deps upgrade] + [luci install] + [apply_mode = 4] + start
    _steps=2
    if [ -n "$XRAY_UPGRADABLE" ] || [ -n "$GEOVIEW_UPGRADABLE" ]; then
        _steps=$((_steps + 1))
    fi
    if [ -n "$LUCI_UPGRADABLE" ]; then
        _steps=$((_steps + 1))
    fi
    if [ -n "$NEEDS_CONFIG" ]; then
        _steps=$((_steps + 4))
    fi
    progress_init $_steps

    # Stop Passwall
    step "$MSG_STEP_STOP_PW"
    /etc/init.d/passwall stop >> "$LOGFILE" 2>&1 || true
    step_log "$MSG_STEP_DONE"

    # Upgrade xray-core / geoview (only those that need it)
    if [ -n "$XRAY_UPGRADABLE" ] || [ -n "$GEOVIEW_UPGRADABLE" ]; then
        _pkgs=""
        if [ -n "$XRAY_UPGRADABLE" ]; then
            _pkgs="xray-core"
        fi
        if [ -n "$GEOVIEW_UPGRADABLE" ]; then
            _pkgs="${_pkgs:+$_pkgs }geoview"
        fi
        step "$(printf "$MSG_STEP_UPGRADING_FMT" "$_pkgs")"
        run opkg upgrade $_pkgs || warn "$(printf "$MSG_OPKG_UPGRADE_FAIL" "$LOGFILE")"
        step_log "$MSG_STEP_DONE"
    fi

    # Download + install luci-app-passwall
    if [ -n "$LUCI_UPGRADABLE" ]; then
        step "$(printf "$MSG_STEP_INSTALLING_FMT" "$PW_VERSION")"
        IPK_FILE="$TMPD/luci-app-passwall${IPK_EXT}"
        fetch "$IPK_URL" "$IPK_FILE" || die "$MSG_DOWNLOAD_FAIL"
        if ! run opkg install --force-reinstall "$IPK_FILE"; then die "$(printf "$MSG_LUCI_INSTALL_FAIL" "$LOGFILE")"; fi
        rm -f "$IPK_FILE"
        step_log "luci-app-passwall ${PW_VERSION}"
    fi

    # Apply initial config on fresh install (mode=proxy)
    if [ -n "$NEEDS_CONFIG" ]; then
        if ! apply_mode "$TARGET_VER" "proxy"; then
            die "$(printf "$MSG_APPLY_FAIL_PROXY_FMT" "$TARGET_VER")"
        fi
    fi

    # Start Passwall
    step "$MSG_STEP_START_PW"
    /etc/init.d/passwall start >> "$LOGFILE" 2>&1 || warn "$MSG_PASSWALL_START_FAIL"
    step_log "$MSG_STEP_DONE"

    PERSISTENT_LOG="/etc/routevia-update.log"
    cp "$LOGFILE" "$PERSISTENT_LOG" 2>/dev/null || PERSISTENT_LOG="$LOGFILE"

    clear_progress

    echo ""
    printf "${GREEN}"
    echo "  ========================================="
    echo "    Passwall: ${PW_VERSION}"
    command -v xray >/dev/null 2>&1 && echo "    Xray: $(xray version 2>/dev/null | head -1)"
    [ -n "$NEEDS_CONFIG" ] && echo "    Config: ${TARGET_VER} (proxy)"
    printf "    ${MSG_LOG_LINE_FMT}\n" "$PERSISTENT_LOG"
    echo "  ========================================="
    printf "${NC}"
    echo ""
}

# =====================================================================
# FUNCTION: Update Config (routevia per-version config + geodata)
# =====================================================================
do_update_config() {
    : > "$LOGFILE"

    require_latest_passwall "$MSG_ACTION_APPLY" || return

    # Current config version from state file (empty means no config applied yet)
    CURRENT_VER=$(state_read VERSION)

    # Check for newer config version on the server
    get_latest_config_version

    # Skip comparison on empty state — always apply fresh config
    if [ -n "$CURRENT_VER" ] && ! version_gt "$TARGET_VER" "$CURRENT_VER"; then
        echo ""
        printf "  ${GREEN}${MSG_CONFIG_UP_TO_DATE_FMT}${NC}\n" "$CURRENT_VER"
        echo ""
        return
    fi

    [ -z "$CURRENT_VER" ] && CURRENT_VER="none"

    CURRENT_MODE=$(state_read MODE)
    CURRENT_MODE="${CURRENT_MODE:-proxy}"

    echo ""
    printf "  ${MSG_CONFIG_UPDATE_LINE_FMT}\n" "$CURRENT_VER" "$TARGET_VER"
    echo ""
    printf "  %s" "$MSG_CONTINUE"
    read -r confirm < /dev/tty
    case "$confirm" in y|Y) ;; *) echo "$MSG_CANCELLED"; return ;; esac
    echo ""

    # apply_mode produces 4 steps + 1 for start passwall
    progress_init 5
    if ! apply_mode "$TARGET_VER" "$CURRENT_MODE"; then
        die "$(printf "$MSG_APPLY_FAIL_FMT" "$TARGET_VER" "$CURRENT_MODE")"
    fi

    step "$MSG_STEP_START_PW"
    /etc/init.d/passwall start >> "$LOGFILE" 2>&1 || warn "$MSG_PASSWALL_START_FAIL"
    step_log "$MSG_STEP_DONE"

    PERSISTENT_LOG="/etc/routevia-update.log"
    cp "$LOGFILE" "$PERSISTENT_LOG" 2>/dev/null || PERSISTENT_LOG="$LOGFILE"

    clear_progress

    echo ""
    printf "${GREEN}"
    echo "  ========================================="
    printf "    ${MSG_CONFIG_UPDATED_LINE_FMT}\n" "$CURRENT_VER" "$TARGET_VER"
    printf "    ${MSG_MODE_LINE_FMT}\n" "$CURRENT_MODE"
    printf "    ${MSG_LOG_LINE_FMT}\n" "$PERSISTENT_LOG"
    echo "  ========================================="
    printf "${NC}"
    echo ""
}

# =====================================================================
# FUNCTION: Change Route Mode
# =====================================================================
do_change_mode() {
    echo ""
    if [ "$CURRENT_MODE" != "unknown" ]; then
        printf "  %s: ${GREEN}%s${NC}\n" "$MSG_CURRENT_MODE_LABEL" "$CURRENT_MODE"
    else
        printf "  %s: ${YELLOW}%s${NC}\n" "$MSG_CURRENT_MODE_LABEL" "$MSG_MODE_NOT_SET"
    fi
    echo ""
    echo "  $MSG_MODE_MENU_PROXY"
    echo "  $MSG_MODE_MENU_DIRECT"
    echo "  $MSG_MODE_MENU_BACK"
    echo ""
    printf "  %s" "$MSG_MODE_SELECT"
    read -r mode_choice < /dev/tty

    case "$mode_choice" in
        1) _target_mode="proxy" ;;
        2) _target_mode="direct" ;;
        0|"") return ;;
        *) echo ""; printf "  ${RED}%s${NC}\n" "$MSG_INVALID_OPTION"; echo ""; return ;;
    esac

    if [ "$CURRENT_MODE" = "$_target_mode" ]; then
        echo ""
        printf "  ${GREEN}${MSG_ALREADY_IN_MODE_FMT}${NC}\n" "$_target_mode"
        echo ""
        return
    fi

    : > "$LOGFILE"

    require_latest_passwall "$MSG_ACTION_CHANGE" || return

    # Always pull the latest available config version from the server
    get_latest_config_version

    echo ""
    printf "  ${MSG_SWITCHING_FMT}\n" "$_target_mode" "$TARGET_VER"
    echo ""

    # apply_mode produces 4 steps (download, extract, geoip, geosite)
    progress_init 4

    if ! apply_mode "$TARGET_VER" "$_target_mode"; then
        die "$(printf "$MSG_SWITCH_FAIL_FMT" "$TARGET_VER" "$_target_mode")"
    fi

    clear_progress
    echo ""
    log "$(printf "$MSG_MODE_CHANGED_FMT" "$_target_mode" "$TARGET_VER")"
    warn "$MSG_ENABLE_PASSWALL"
    echo ""
}

# =====================================================================
# MAIN MENU
# =====================================================================
select_language
set_messages

DEVICE_NAME=$(cat /tmp/sysinfo/model 2>/dev/null || uci get system.@system[0].hostname 2>/dev/null || echo "OpenWrt")

echo ""
echo "  ${DEVICE_NAME}"
echo "  OpenWrt ${RELEASE}, ${ARCH}"

if [ "$PW_INSTALLED" = "yes" ]; then
    if [ "$CURRENT_MODE" != "unknown" ]; then
        printf "  Passwall: ${GREEN}%s${NC} | %s: ${GREEN}%s${NC}\n" "$PW_CURRENT_VER" "$MSG_MODE_LABEL" "$CURRENT_MODE"
    else
        printf "  Passwall: ${GREEN}%s${NC}\n" "$PW_CURRENT_VER"
    fi
fi

if [ "$PW_INSTALLED" != "yes" ]; then
    echo ""
    printf "  ${RED}%s${NC}\n" "$MSG_PASSWALL_NOT_INSTALLED"
    printf "  ${YELLOW}%s${NC}\n" "$MSG_SCRIPT_SCOPE"
    echo ""
    exit 1
fi

echo ""
echo "  $MSG_MENU_1"
echo "  $MSG_MENU_2"
echo "  $MSG_MENU_3"
echo "  $MSG_MENU_4"
echo ""
printf "  %s" "$MSG_MENU_SELECT"
read -r choice < /dev/tty

case "$choice" in
    1) do_update_passwall ;;
    2) do_update_config ;;
    3) do_change_mode ;;
    4|"") echo "$MSG_BYE"; exit 0 ;;
    *) echo "  $MSG_INVALID_OPTION"; exit 1 ;;
esac
