#!/bin/sh
# Copyright (c) 2026 Dab Co. Limited. All rights reserved.
#
# This software and its documentation are the confidential and
# proprietary information of Dab Co. Limited. Redistribution or use
# in source or binary forms, with or without modification, is not
# permitted without the express written consent of Dab Co. Limited.
# pps-vpn-manager — Pairpoint Secure VPN menu-driven CLI manager
#
# Interactive menu mode:   pps-vpn-manager
# Direct subcommand mode:  pps-vpn-manager <cmd> [args]
#
# Subcommands:
#   status                 Show service + tunnel status
#   start                  Start VPN service
#   stop                   Stop VPN service
#   restart                Restart VPN service
#   config show            Show current configuration
#   config set <key> <val> Set a config value
#   config get <key>       Get a config value
#   bandwidth              Show bandwidth stats
#   logs [N]               Show last N log lines (default 20)
#   diagnose               Run diagnostics
#   fingerprint            Capture DUT fingerprint (test evidence)

# Resolve base dir from this script's location so the CLI works whether
# invoked as /usr/bin/pps-vpn-manager (symlink) or directly via full path.
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || echo "$0")"
BASE_DIR="$(cd "$(dirname "$SCRIPT_PATH")/.." 2>/dev/null && pwd)"
[ -z "$BASE_DIR" ] || [ ! -d "$BASE_DIR/scripts" ] && BASE_DIR="${PPS_VPN_HOME:-/usr/lib/pps-vpn}"
SCRIPTS="$BASE_DIR/scripts"

# ── Source common helpers ────────────────────────────────────
if [ -f "$SCRIPTS/pps-vpn-common.sh" ]; then
    . "$SCRIPTS/pps-vpn-common.sh"
else
    # Minimal fallback if common not available
    RED=$(printf '\033[0;31m'); GREEN=$(printf '\033[0;32m')
    YELLOW=$(printf '\033[1;33m'); BLUE=$(printf '\033[0;34m'); NC=$(printf '\033[0m')
    # G13 — fallback matches canonical pattern in pps-vpn-common.sh.
    # PID-file + cmdline verification, NO pgrep fallback.
    is_vpn_running() {
        local pf="/var/run/pps-vpn.pid"
        [ -f "$pf" ] || return 1
        local pid cmd
        pid=$(cat "$pf" 2>/dev/null)
        [ -z "$pid" ] && return 1
        kill -0 "$pid" 2>/dev/null || return 1
        cmd=$(cat "/proc/${pid}/cmdline" 2>/dev/null | tr '\0' ' ')
        echo "$cmd" | grep -qE "(bin/pps-vpn|openvpn).*client\.conf"
    }
    # Must match pps-vpn-common.sh:is_tunnel_up() — _poll_until running
    # depends on it, so `start --wait` would break without it.
    is_tunnel_up() {
        ip link show tun0 2>/dev/null | grep -qE '<[^>]*\bUP\b' && \
        ip addr show tun0 2>/dev/null | grep -q 'inet '
    }
    format_bytes() { echo "${1:-0}B"; }
fi

INIT="$SCRIPTS/init.sh"
CFG_MGR="$SCRIPTS/config_manager.sh"

# ── Helpers ──────────────────────────────────────────────────

_status_line() {
    local _tun_ip _srv_ip _srv_port running
    running=0
    is_vpn_running 2>/dev/null && running=1

    # TUN devices report `state UNKNOWN` (no carrier in kernel sense),
    # NOT `state UP` — checking `state UP` is a permanent false-negative.
    # Truth source: the `UP` flag in `<...>` and presence of an inet addr.
    _tun_ip=$(ip addr show tun0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1)
    _srv_ip=$(uci -q get ppsvpn.config.server_address 2>/dev/null)
    _srv_port=$(uci -q get ppsvpn.config.server_port 2>/dev/null)

    if [ "$running" = 1 ]; then
        printf "  Status : ${GREEN}● RUNNING${NC}\n"
        if [ -n "$_srv_ip" ] && [ -n "$_srv_port" ]; then
            printf "  Server : ${GREEN}%s:%s${NC}\n" "$_srv_ip" "$_srv_port"
        fi
        if ip link show tun0 2>/dev/null | grep -qE '<[^>]*\bUP\b' && [ -n "$_tun_ip" ]; then
            printf "  Tunnel : ${GREEN}tun0 UP (%s)${NC}\n" "$_tun_ip"
        else
            printf "  Tunnel : ${RED}tun0 DOWN${NC}\n"
        fi
    else
        printf "  Status : ${RED}● STOPPED${NC}\n"
    fi
}

# G15 — read installed-package version from the VERSION file build-ipk.sh
# stamps into INSTALL_DIR. "unknown" on dev installs that bypassed build-ipk.
_get_version() {
    cat "$BASE_DIR/VERSION" 2>/dev/null || echo "unknown"
}

_header() {
    local _ver _title _w _line
    _ver=$(_get_version)
    _title="Pairpoint Secure VPN Manager  v${_ver}"
    # Box sized to content — dev versions (-rc.N-g<hash>-dirty) vary in length
    _w=$(( ${#_title} + 4 ))
    _line=$(printf '%*s' "$_w" '' | sed 's/ /═/g')
    clear
    printf "${BLUE}╔%s╗${NC}\n" "$_line"
    printf "${BLUE}║${NC}  ${BLUE}%s${NC}  ${BLUE}║${NC}\n" "$_title"
    printf "${BLUE}╠%s╣${NC}\n" "$_line"
    _status_line
    printf "${BLUE}╚%s╝${NC}\n" "$_line"
    printf "\n"
}

_press_enter() {
    printf "\nPress Enter to continue..."
    read -r _dummy
}

_confirm() {
    # _confirm "Are you sure?" → returns 0 for yes, 1 for no
    printf "${YELLOW}%s [y/N]: ${NC}" "$1"
    read -r _ans
    case "$_ans" in y|Y|yes|YES) return 0 ;; esac
    return 1
}

# ── Sub-menus ────────────────────────────────────────────────

menu_service() {
    while true; do
        _header
        printf "  Service Control\n\n"
        printf "  1. Start VPN\n"
        printf "  2. Stop VPN\n"
        printf "  3. Restart VPN\n"
        printf "  4. Show Status\n"
        printf "  5. Back\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1)
                if is_vpn_running 2>/dev/null; then
                    printf "\n  ${YELLOW}VPN is already running.${NC}\n"
                else
                    printf "\n  Starting VPN...\n"
                    sh "$INIT" start
                    _rc=$?
                    # Skip the poll when preflight aborted — otherwise the
                    # user sees "Waiting..." for 90s after a FAIL banner
                    # they've already read. Preflight fast-fails with rc=3
                    # on missing config, rc=1 on transient (cellular/DNS),
                    # rc=4 on SIM/Pairpoint. Any non-zero means the service
                    # was not launched, so nothing to poll for.
                    if [ "$_rc" = 0 ]; then
                        # 90s to match WebUI's STARTING_TIMEOUT_MS.
                        # Old 15s was way under normal connect time (~25-30s).
                        _poll_until running 90
                    fi
                fi
                _press_enter
                ;;
            2)
                if ! is_vpn_running 2>/dev/null; then
                    printf "\n  ${YELLOW}VPN is not running.${NC}\n"
                elif _confirm "Stop the VPN?"; then
                    printf "\n  Stopping VPN...\n"
                    sh "$INIT" stop
                    _poll_until stopped 30
                fi
                _press_enter
                ;;
            3)
                if _confirm "Restart the VPN?"; then
                    printf "\n  Restarting VPN...\n"
                    # Same rationale as Start above — skip the poll on a
                    # preflight failure so the FAIL banner isn't followed by
                    # 90s of pointless "Waiting...". Tested directly: $? after
                    # the fact breaks the moment a line is inserted between.
                    if sh "$INIT" restart; then
                        _poll_until running 90
                    fi
                fi
                _press_enter
                ;;
            4)
                printf "\n"
                sh "$INIT" status
                _press_enter
                ;;
            5|"") return ;;
        esac
    done
}

menu_config() {
    while true; do
        _header
        printf "  Configuration\n\n"
        printf "  1. Interactive setup wizard\n"
        printf "  2. Show current config\n"
        printf "  3. Set a value\n"
        printf "  4. Get a value\n"
        printf "  5. Reset to defaults\n"
        printf "  6. Back\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1)
                printf "\n"
                sh "$CFG_MGR" configure
                ;;
            2)
                printf "\n"
                sh "$CFG_MGR" show
                _press_enter
                ;;
            3)
                printf "\n  Key: "; read -r _key
                printf "  Value: "; read -r _val
                if [ -n "$_key" ] && [ -n "$_val" ]; then
                    sh "$CFG_MGR" set "$_key" "$_val"
                    printf "\n  ${GREEN}Set $_key = $_val${NC}\n"
                else
                    printf "\n  ${YELLOW}Cancelled.${NC}\n"
                fi
                _press_enter
                ;;
            4)
                printf "\n  Key: "; read -r _key
                if [ -n "$_key" ]; then
                    val=$(sh "$CFG_MGR" get "$_key" 2>/dev/null)
                    printf "\n  ${BLUE}%s${NC} = %s\n" "$_key" "${val:-<not set>}"
                fi
                _press_enter
                ;;
            5)
                if _confirm "Reset all config to defaults?"; then
                    sh "$CFG_MGR" reset
                    printf "\n  ${GREEN}Reset complete.${NC}\n"
                fi
                _press_enter
                ;;
            6|"") return ;;
        esac
    done
}

menu_monitor() {
    while true; do
        _header
        printf "  Monitoring & Bandwidth\n\n"
        printf "  1. Live status summary\n"
        printf "  2. Bandwidth stats\n"
        printf "  3. Connection quality (ping)\n"
        printf "  4. Back\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1)
                printf "\n"
                sh "$INIT" status
                printf "\n"
                if ip link show tun0 >/dev/null 2>&1; then
                    TUN_IP=$(ip addr show tun0 2>/dev/null | grep 'inet ' | awk '{print $2}')
                    printf "  Tunnel IP : ${GREEN}%s${NC}\n" "${TUN_IP:-unknown}"
                    GW=$(ip route show dev tun0 2>/dev/null | awk '/via/{print $3}' | head -1)
                    [ -n "$GW" ] && printf "  VPN GW    : %s\n" "$GW"
                fi
                _press_enter
                ;;
            2)
                printf "\n"
                sh "$SCRIPTS/pps-vpn-bandwidth.sh"
                _press_enter
                ;;
            3)
                printf "\n"
                PEER=$(ip addr show tun0 2>/dev/null \
                       | grep -o 'peer [0-9.]*' | awk '{print $2}')
                if [ -z "$PEER" ]; then
                    PEER=$(ip addr show tun0 2>/dev/null \
                           | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 \
                           | sed 's/\.[0-9]*$/.1/')
                fi
                if [ -n "$PEER" ]; then
                    printf "  Pinging tunnel peer %s...\n" "$PEER"
                    ping -c 3 -W 2 "$PEER" 2>&1 | grep -E 'bytes from|packet loss|rtt|round-trip'
                else
                    printf "  ${YELLOW}tun0 not up — cannot determine peer IP${NC}\n"
                fi
                _press_enter
                ;;
            4|"") return ;;
        esac
    done
}

menu_logs() {
    while true; do
        _header
        printf "  View Logs\n\n"
        printf "  1. Last 20 lines\n"
        printf "  2. Last N lines\n"
        printf "  3. Follow (tail -f)\n"
        printf "  4. Back\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1)
                printf "\n"
                sh "$INIT" log 20
                _press_enter
                ;;
            2)
                printf "\n  Lines: "; read -r _n
                _n=${_n:-20}
                printf "\n"
                sh "$INIT" log "$_n"
                _press_enter
                ;;
            3)
                printf "\n  ${YELLOW}Following log — press Ctrl+C to stop${NC}\n\n"
                LOG_FILE="$BASE_DIR/logs/pps-vpn.log"
                if [ -f "$LOG_FILE" ]; then
                    tail -f "$LOG_FILE" || true
                else
                    printf "  ${YELLOW}Log file not found: %s${NC}\n" "$LOG_FILE"
                fi
                _press_enter
                ;;
            4|"") return ;;
        esac
    done
}

menu_diagnose() {
    while true; do
        _header
        printf "  Diagnostics\n\n"
        printf "  1. Ping tunnel peer\n"
        printf "  2. Active VPN routes\n"
        printf "  3. Physical gateway\n"
        printf "  4. tun0 interface details\n"
        printf "  5. Device fingerprint (test evidence)\n"
        printf "  6. Back\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1)
                printf "\n"
                PEER=$(ip addr show tun0 2>/dev/null \
                       | grep -o 'peer [0-9.]*' | awk '{print $2}')
                if [ -z "$PEER" ]; then
                    PEER=$(ip addr show tun0 2>/dev/null \
                           | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 \
                           | sed 's/\.[0-9]*$/.1/')
                fi
                if [ -n "$PEER" ]; then
                    printf "  Pinging %s via tun0...\n\n" "$PEER"
                    ping -c 4 -W 2 "$PEER" || true
                else
                    printf "  ${YELLOW}tun0 not up${NC}\n"
                fi
                _press_enter
                ;;
            2)
                printf "\n  VPN routes (tun0):\n\n"
                ip route show dev tun0 2>/dev/null || printf "  ${YELLOW}No tun0 routes${NC}\n"
                _press_enter
                ;;
            3)
                printf "\n  Physical default gateway:\n\n"
                ip route show | grep '^default' | grep -v tun0 || printf "  ${YELLOW}Not found${NC}\n"
                printf "\n  Physical interface stats:\n"
                ip route show | grep '^default' | grep -v tun0 | awk '{print $5}' | head -1 \
                    | xargs -I{} ip addr show {} 2>/dev/null || true
                _press_enter
                ;;
            4)
                printf "\n  tun0 interface:\n\n"
                ip addr show tun0 2>/dev/null || printf "  ${YELLOW}tun0 not found${NC}\n"
                printf "\n  tun0 link stats:\n"
                ip -s link show tun0 2>/dev/null | grep -A2 'RX:\|TX:' || true
                _press_enter
                ;;
            5)
                printf "\n"
                sh "$SCRIPTS/capture-fingerprint.sh" || \
                    printf "  ${YELLOW}fingerprint capture failed${NC}\n"
                _press_enter
                ;;
            6|"") return ;;
        esac
    done
}

# ── Main menu ────────────────────────────────────────────────

main_menu() {
    while true; do
        _header
        printf "  1. Service Control\n"
        printf "  2. Configuration\n"
        printf "  3. Monitoring & Bandwidth\n"
        printf "  4. View Logs\n"
        printf "  5. Diagnostics\n"
        printf "  6. Exit\n\n"
        printf "  Choice: "
        read -r choice
        case "$choice" in
            1) menu_service  ;;
            2) menu_config   ;;
            3) menu_monitor  ;;
            4) menu_logs     ;;
            5) menu_diagnose ;;
            6|q|Q|"exit"|"quit") clear; exit 0 ;;
        esac
    done
}

# ── Trap for clean exit ──────────────────────────────────────
trap 'clear; exit 0' INT TERM

# ═════════════════════════════════════════════════════════════
# Automation / output helpers (ported from NTC-552 pps-vpn-manager,
# adapted for UCI backend + procd service)
# ═════════════════════════════════════════════════════════════

# Canonical config keys — NTC-5xx Interface Requirements schema (Notion
# schema). Accepted by `config get/set`, shown by `config show --json`.
CONFIG_KEYS="server_address server_port reneg_interval reneg_bytes \
log_level key_cache_enable \
keepalive_interval keepalive_timeout reconnect_delay max_reconnect_delay \
simtrust_host simtrust_port serial_port baudrate naf_id \
mtu mtu_auto bind_dev"

# Canonical→UCI key mapping. UCI now stores under canonical names; this
# function is an identity map, kept as a stable extension point in case a
# future device introduces platform-legacy names again. Old callers were
# switched over at the UCI-rename commit (postinst migrates existing installs).
_canonical_to_uci() { echo "$1"; }

_is_tty() { [ -t 1 ]; }

# Want JSON output? true if --json is present OR stdout is not a terminal.
_want_json() {
    for _a in "$@"; do [ "$_a" = "--json" ] && return 0; done
    if _is_tty; then return 1; else return 0; fi
}

_json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

# _poll_until <running|stopped> <max_secs> — wait for state transition.
#
# "running" waits for a USABLE tunnel (process + tun0 address), not merely a
# live PID. It previously returned as soon as the process existed, which on
# both G526 and X303 was ~6s before tun0 had an address — so
# `pps-vpn-manager start --wait && <use the tunnel>` raced and
# failed intermittently. Waiting on the weaker condition made --wait's whole
# contract meaningless, since the caller's reason for passing --wait is to be
# able to use the tunnel on the next line.
#
# Consequence worth knowing: if the process starts but the tunnel never comes
# up, --wait now times out and returns non-zero instead of reporting success.
# That is the point — a caller must not proceed in that case.
_poll_until() {
    _want="$1"; _max="${2:-15}"; _el=0
    printf "  Waiting"
    while [ "$_el" -lt "$_max" ]; do
        sleep 2; _el=$(( _el + 2 )); printf "."
        if [ "$_want" = "running" ] && is_vpn_running 2>/dev/null && is_tunnel_up 2>/dev/null; then
            printf " ${GREEN}done${NC}\n"; return 0
        fi
        if [ "$_want" = "stopped" ] && ! is_vpn_running 2>/dev/null; then
            printf " ${GREEN}done${NC}\n"; return 0
        fi
    done
    printf " ${YELLOW}timed out${NC}\n"; return 1
}

# Resolve the passphrase and echo it on stdout. Never accepts it as a
# positional arg (would leak via ps / shell history). See skill
# guides/device-ui-generic.md "Passphrase input safety ordering".
_obtain_passphrase() {
    _mode="$1"; _file="$2"; _sec=""
    case "$_mode" in
        value)   _sec="$_file" ;;
        file)
            if [ -z "$_file" ] || [ ! -r "$_file" ]; then
                echo "password-file not readable: ${_file:-<none>}" >&2; return 2
            fi
            _sec=$(cat "$_file") ;;
        stdin)   _sec=$(cat) ;;
        auto)
            if [ -n "${PP_PASSPHRASE:-}" ]; then _sec="$PP_PASSPHRASE"
            elif [ ! -t 0 ];              then _sec=$(cat)
            else
                printf "Enter passphrase (20-256 chars): " >&2
                stty -echo 2>/dev/null; read -r _sec; stty echo 2>/dev/null; printf "\n" >&2
            fi
            ;;
        *) echo "internal: bad passphrase mode '$_mode'" >&2; return 2 ;;
    esac
    [ -n "$_sec" ] || { echo "empty passphrase — nothing stored" >&2; return 2; }
    printf '%s' "$_sec"
}

# Reject shell/sed/TOML metacharacters (skill gotcha #169).
_pp_has_dangerous_chars() {
    case "$1" in
        *';'*|*'<'*|*'>'*|*'&'*|*'|'*|*\\*|*'$'*|*'('*|*')'*|*'{'*|*'}'*|*'['*|*']'*|*"'"*|*'"'*|*'`'*) return 0 ;;
    esac
    return 1
}

# Store passphrase — G526 UCI-backed flow (see skill decision matrix note).
# This is the established path; the canonical CLI/pp-secure-storage path is a
# tracked follow-up.
_passphrase_set() {
    _pp=$(_obtain_passphrase "$1" "$2") || return $?
    if [ "${#_pp}" -lt 20 ] || [ "${#_pp}" -gt 256 ]; then
        echo "passphrase must be 20-256 characters" >&2
        unset _pp; return 4
    fi
    if _pp_has_dangerous_chars "$_pp"; then
        echo "passphrase must not contain any of: ; < > & | \\ \$ ( ) { } [ ] ' \" \`" >&2
        unset _pp; return 4
    fi
    uci -q set ppsvpn.config.passphrase="$_pp"    || { unset _pp; return 4; }
    uci -q commit ppsvpn                          || { unset _pp; return 4; }
    unset _pp
    rm -f /var/run/pps-vpn/auth-failed /tmp/.pps-vpn-auth-failed 2>/dev/null || true
    return 0
}

_check_passphrase() {
    _set=$(uci -q get ppsvpn.config.passphrase 2>/dev/null)
    if [ -z "$_set" ]; then
        echo "Passphrase is NOT set." >&2; return 4
    fi
    # Regenerate config.toml (embeds passphrase) then attempt orange-key gen
    # at index 1 as a smoke test. If pp returns "material" the passphrase
    # is valid end-to-end.
    sh "$CFG_MGR" generate_sdk >/dev/null 2>&1
    _out=$(cd "$BASE_DIR/pps-crypto" 2>/dev/null && ./pp --method best -ko -i 1 -t I -x 2>&1)
    if echo "$_out" | grep -q '"material"'; then
        echo "Passphrase verified — orange-key generation succeeded." >&2
        return 0
    fi
    echo "Passphrase verification FAILED." >&2
    printf '%s\n' "$_out" | head -3 >&2
    return 4
}

# config show as JSON (adapts NTC-552's _config_json to UCI + canonical
# aliases). Emits canonical key names on the wire.
_config_json() {
    printf '{'
    _first=1
    for _k in $CONFIG_KEYS; do
        _uci_k=$(_canonical_to_uci "$_k")
        _v=$(uci -q get "ppsvpn.config.$_uci_k" 2>/dev/null || echo "")
        [ "$_first" = 1 ] && _first=0 || printf ','
        printf '"%s":"%s"' "$_k" "$(_json_escape "$_v")"
    done
    _ps=$(uci -q get ppsvpn.config.passphrase 2>/dev/null)
    printf ',"passphrase_set":%s}' "$([ -n "$_ps" ] && echo true || echo false)"
    printf '\n'
}

# Pre-flight for `start` / `setup --start`.
_preflight() {
    _addr=$(uci -q get ppsvpn.config.server_address 2>/dev/null || echo "")
    _naf=$(uci -q get ppsvpn.config.naf_id 2>/dev/null || echo "")
    _pp=$(uci -q get ppsvpn.config.passphrase 2>/dev/null || echo "")
    if [ -z "$_addr" ] || [ -z "$_naf" ] || [ -z "$_pp" ]; then
        echo "Cannot start: VPN is not fully configured." >&2
        [ -z "$_addr" ] && echo "  - server_address not set  (pps-vpn-manager config set server_address <ip>)" >&2
        [ -z "$_naf" ]  && echo "  - naf_id not set          (pps-vpn-manager config set naf_id <id>)" >&2
        [ -z "$_pp" ]   && echo "  - passphrase not set      (pps-vpn-manager passphrase set)" >&2
        return 3
    fi
    return 0
}

_usage() {
    cat <<'USAGE'
Pairpoint Secure VPN — pps-vpn-manager

Usage:
  pps-vpn-manager                       Interactive menu (on a terminal)
  pps-vpn-manager <command> [options]   Scriptable command

Commands:
  status [--json]               Service + tunnel status. The .status field is
                                one of: connected (process up AND tunnel up —
                                the only state in which the tunnel is usable),
                                connecting (process up, tunnel not up yet),
                                disconnected (not running).
  start [--wait]                Start VPN (exit 3 if not configured). --wait
                                returns only once the tunnel is usable, and
                                exits non-zero if it never comes up.
  stop                          Stop VPN
  restart                       Restart VPN
  config show [--json]          Show configuration
  config get <key>              Get one config value
  config set <key> <value>      Set one config value
  config reset <key>            Reset one key to its default
  config reset [--yes|-y]       Reset ALL config to defaults. Prompts on a
                                terminal; --yes is required non-interactively
                                (exits 2 rather than silently doing nothing)
  passphrase set [--stdin|--password-file <f>|--passphrase <v>]
                                Store passphrase (also reads $PP_PASSPHRASE;
                                interactive prompt when on a terminal).
                                --passphrase <v> is convenient but appears in
                                shell history / ps — prefer stdin/file in scripts.
  passphrase verify             Verify stored passphrase generates a key
  passphrase status [--json]    Report whether a passphrase is set
  setup [--server <ip> --port <p> --naf-id <id>
         (--password-file <f>|--stdin|--passphrase <v>) [--start]]
                                One-shot provision (wizard on a terminal)
  bandwidth [--json]            Bandwidth stats
  logs [-n N] [-f]              Last N log lines (default 20), or follow (-f)
  diagnose [--json]             Run diagnostics
  fingerprint | fp              Capture DUT fingerprint (hardware, firmware,
                                modem, bearer/RAT, package) as test evidence;
                                prints a paste-ready docs/TEST-MATRIX.md row
  uninstall [--full] [--yes]    Remove package (--full = purge config too)
  version                       Print installed version
  help                          Show this help

Passphrase inputs, safest first: stdin, --password-file, the PP_PASSPHRASE
environment variable, the interactive prompt, or --passphrase <v>. The
--passphrase flag is the least safe (the value appears in shell history and in
ps aux while running) — use it only for quick manual runs, not in scripts.

Exit codes: 0 ok · 2 usage error · 3 not configured · 4 passphrase failure · 1 other

Automation examples:
  printf '%s' "$SECRET" | pps-vpn-manager passphrase set --stdin
  PP_PASSPHRASE="$SECRET" pps-vpn-manager passphrase set
  pps-vpn-manager setup --server 1.2.3.4 --port 1194 --naf-id test-nafid \
                        --password-file /run/secrets/ppsvpn-pass --start
  pps-vpn-manager status --json | jq -r .status
USAGE
}

# ── Direct subcommand mode ───────────────────────────────────
case "$1" in
    version|--version|-V|-v)
        printf "pps-vpn-manager v%s\n" "$(_get_version)"
        if [ -f "$BASE_DIR/BUILD-INFO.txt" ]; then
            grep -E '^(Built|Commit|Branch|Env|Pairpoint Server):' \
                "$BASE_DIR/BUILD-INFO.txt" 2>/dev/null | sed 's/^/  /'
        fi
        ;;
    status)
        shift
        if _want_json "$@"; then
            sh "$SCRIPTS/pps-vpn-status.sh" 2>/dev/null
        else
            sh "$INIT" status
        fi
        ;;
    start)
        shift
        _preflight || exit 3
        # say so when there is nothing to do. start_service() now
        # short-circuits on an already-running service (it must: returning
        # without declaring the procd instance made procd kill the running
        # one), which also means the init.d pre-flight banner no longer
        # prints. Without this line a redundant `start` is completely silent
        # and looks like it failed.
        if [ -f /var/run/pps-vpn.pid ] && kill -0 "$(cat /var/run/pps-vpn.pid 2>/dev/null)" 2>/dev/null; then
            echo "PPS-VPN is already running (PID $(cat /var/run/pps-vpn.pid))."
            exit 0
        fi
        /etc/init.d/pps-vpn start || exit 1
        # match WebUI's STARTING_TIMEOUT_MS (90s). Normal connect is
        # ~25-30s; worst-case pp -kgf under eventsms contention can push it
        # past 60s. Old value (20s) reliably returned "failed" while the
        # tunnel was still coming up.
        # The timeout must reach the caller. This used to be
        #   [ "$1" = "--wait" ] && _poll_until running 90
        #   exit 0
        # so `start --wait` returned 0 even when _poll_until printed
        # "timed out" — verified on G526 against an unroutable server: the
        # wait correctly gave up after 90s and the command still exited 0.
        # A caller doing `start --wait && <use the tunnel>` proceeded anyway.
        if [ "$1" = "--wait" ]; then
            _poll_until running 90 || {
                echo "Started, but the tunnel did not come up within 90s." >&2
                echo "Check 'pps-vpn-manager logs' — the service is still running and may yet connect." >&2
                exit 1
            }
        fi
        exit 0
        ;;
    stop)
        /etc/init.d/pps-vpn stop
        ;;
    restart)
        /etc/init.d/pps-vpn restart
        ;;
    config)
        shift
        case "$1" in
            show)            shift; if _want_json "$@"; then _config_json; else sh "$CFG_MGR" show; fi ;;
            set)             shift
                             # Translate canonical→UCI key name transparently.
                             if [ $# -ge 2 ]; then
                                 _k=$(_canonical_to_uci "$1"); shift
                                 sh "$CFG_MGR" set "$_k" "$@"
                             else
                                 sh "$CFG_MGR" set "$@"
                             fi
                             ;;
            get)             shift
                             if [ $# -ge 1 ]; then
                                 _k=$(_canonical_to_uci "$1"); shift
                                 sh "$CFG_MGR" get "$_k" "$@"
                             else
                                 sh "$CFG_MGR" get "$@"
                             fi
                             ;;
            reset)           shift
                             # Pass flags and key through. Same canonical→UCI
                             # translation as set/get, except the first arg may
                             # be a flag (--yes) rather than a key.
                             case "$1" in
                                 ""|-*) sh "$CFG_MGR" reset "$@" ;;
                                 *)     _k=$(_canonical_to_uci "$1"); shift
                                        sh "$CFG_MGR" reset "$_k" "$@" ;;
                             esac
                             ;;
            -h|--help)       _usage ;;
            *)               sh "$CFG_MGR" configure ;;
        esac
        ;;
    passphrase|pass)
        shift
        case "$1" in
            set)
                shift
                _pmode="auto"; _pfile=""
                while [ $# -gt 0 ]; do
                    case "$1" in
                        --stdin)              _pmode="stdin" ;;
                        --password-file)      shift; _pmode="file"; _pfile="$1" ;;
                        --password-file=*)    _pmode="file"; _pfile="${1#*=}" ;;
                        --passphrase)         shift; _pmode="value"; _pfile="$1" ;;
                        --passphrase=*)       _pmode="value"; _pfile="${1#*=}" ;;
                        -h|--help)            _usage; exit 0 ;;
                        *) echo "unknown option: $1" >&2; exit 2 ;;
                    esac
                    shift
                done
                if _passphrase_set "$_pmode" "$_pfile"; then
                    echo "Passphrase stored." >&2; exit 0
                else
                    _rc=$?; echo "Failed to store passphrase (rc=$_rc)." >&2; exit "$_rc"
                fi
                ;;
            verify|"") _check_passphrase ;;
            status)
                shift
                _pp=$(uci -q get ppsvpn.config.passphrase 2>/dev/null)
                if _want_json "$@"; then
                    printf '{"passphrase_set":%s}\n' "$([ -n "$_pp" ] && echo true || echo false)"
                else
                    [ -n "$_pp" ] && echo "Passphrase is set." || echo "Passphrase is NOT set."
                fi
                ;;
            -h|--help) _usage ;;
            *) echo "unknown: passphrase $1" >&2; exit 2 ;;
        esac
        ;;
    setup)
        shift
        # Interactive wizard on TTY with no flags.
        if [ -z "$1" ] && _is_tty; then
            sh "$CFG_MGR" configure
            printf "\n"
            _passphrase_set auto "" && echo "Passphrase stored." >&2
            exit 0
        fi
        _server=""; _port=""; _naf=""; _pmode=""; _pfile=""; _do_start=0
        while [ $# -gt 0 ]; do
            case "$1" in
                --server)          shift; _server="$1" ;;
                --server=*)        _server="${1#*=}" ;;
                --port)            shift; _port="$1" ;;
                --port=*)          _port="${1#*=}" ;;
                --naf-id)          shift; _naf="$1" ;;
                --naf-id=*)        _naf="${1#*=}" ;;
                --stdin)           _pmode="stdin" ;;
                --password-file)   shift; _pmode="file"; _pfile="$1" ;;
                --password-file=*) _pmode="file"; _pfile="${1#*=}" ;;
                --passphrase)      shift; _pmode="value"; _pfile="$1" ;;
                --passphrase=*)    _pmode="value"; _pfile="${1#*=}" ;;
                --start)           _do_start=1 ;;
                -h|--help)         _usage; exit 0 ;;
                *) echo "unknown option: $1" >&2; exit 2 ;;
            esac
            shift
        done
        [ -n "$_server" ] && sh "$CFG_MGR" set server_address "$_server"
        [ -n "$_port" ]   && sh "$CFG_MGR" set server_port    "$_port"
        [ -n "$_naf" ]    && sh "$CFG_MGR" set naf_id     "$_naf"
        # Set passphrase only when explicitly requested. server-only
        # re-provisioning leaves an already-stored passphrase untouched.
        if [ "$_pmode" = "stdin" ] || [ "$_pmode" = "file" ] || [ "$_pmode" = "value" ] || [ -n "${PP_PASSPHRASE:-}" ]; then
            [ -z "$_pmode" ] && _pmode="auto"
            if _passphrase_set "$_pmode" "$_pfile"; then
                echo "Passphrase stored." >&2
            else
                _rc=$?; echo "Failed to store passphrase (rc=$_rc)." >&2; exit "$_rc"
            fi
        fi
        if [ "$_do_start" = 1 ]; then
            _preflight || exit 3
            /etc/init.d/pps-vpn start || exit 1
            _poll_until running 90
        fi
        exit 0
        ;;
    bandwidth|bw)
        shift
        if _want_json "$@"; then
            sh "$SCRIPTS/pps-vpn-bandwidth.sh" --json 2>/dev/null || sh "$SCRIPTS/pps-vpn-bandwidth.sh"
        else
            sh "$SCRIPTS/pps-vpn-bandwidth.sh"
        fi
        ;;
    logs|log)
        shift
        _follow=0; _n=20
        while [ $# -gt 0 ]; do
            case "$1" in
                -f|--follow)  _follow=1 ;;
                -n|--lines)   shift; _n="$1" ;;
                [0-9]*)       _n="$1" ;;
                -h|--help)    echo "Usage: pps-vpn-manager logs [-n N] [-f]"; exit 0 ;;
                *) echo "unknown option: $1" >&2; exit 2 ;;
            esac
            shift
        done
        if [ "$_follow" = 1 ]; then
            _lf="$BASE_DIR/logs/pps-vpn.log"
            if [ -f "$_lf" ]; then
                tail -f "$_lf"
            else
                echo "log file not found: $_lf" >&2
                exit 1
            fi
        else
            sh "$INIT" log "$_n"
        fi
        ;;
    diagnose|diag)
        shift
        if _want_json "$@"; then
            _running=false; is_vpn_running 2>/dev/null && _running=true
            _tunip=$(ip addr show tun0 2>/dev/null | grep 'inet ' | awk '{print $2}' | head -1)
            printf '{"running":%s,"tun0_ip":"%s"}\n' \
                "$_running" "$(_json_escape "$_tunip")"
            exit 0
        fi
        printf "\n${BLUE}=== Diagnostics ===${NC}\n\n"
        printf "${BLUE}Service status:${NC}\n"
        sh "$INIT" status
        printf "\n${BLUE}Bandwidth:${NC}\n"
        sh "$SCRIPTS/pps-vpn-bandwidth.sh"
        printf "\n${BLUE}tun0 interface:${NC}\n"
        ip addr show tun0 2>/dev/null || printf "  tun0 not found\n"
        printf "\n${BLUE}VPN routes:${NC}\n"
        ip route show dev tun0 2>/dev/null || printf "  No tun0 routes\n"
        printf "\n${BLUE}Physical gateway:${NC}\n"
        ip route show | grep '^default' | grep -v tun0 || printf "  Not found\n"
        ;;
    fingerprint|fp)
        shift
        # Read-only DUT fingerprint (hardware + firmware + modem + BEARER +
        # package) for test evidence. Ends with a paste-ready row for
        # docs/TEST-MATRIX.md. Redirect to a file to keep it as evidence.
        sh "$SCRIPTS/capture-fingerprint.sh" "$@"
        ;;
    uninstall)
        shift
        _full=0; _yes=0
        while [ $# -gt 0 ]; do
            case "$1" in
                --full)  _full=1 ;;
                --yes|-y) _yes=1 ;;
                -h|--help) _usage; exit 0 ;;
                *) echo "unknown option: $1" >&2; exit 2 ;;
            esac
            shift
        done
        if [ "$_yes" != 1 ]; then
            printf "This will remove pps-vpn"
            [ "$_full" = 1 ] && printf " AND wipe all config/credentials"
            printf ". Continue? [y/N] "
            read -r _ans
            case "$_ans" in y|Y|yes|YES) ;; *) echo "aborted."; exit 0 ;; esac
        fi
        # --full purge flag consumed by prerm (see packaging/CONTROL/prerm G18).
        [ "$_full" = 1 ] && touch /tmp/pps-vpn-purge
        opkg remove pps-vpn
        ;;
    help|-h|--help)
        _usage
        ;;
    ""|menu)
        main_menu
        ;;
    *)
        echo "unknown command: $1" >&2
        echo "Run 'pps-vpn-manager help' for usage." >&2
        exit 2
        ;;
esac
