#!/usr/bin/env bash
# Yoho VPN — Linux CLI. Passwordless email sign-in, then fetch a full smart-routing sing-box config
# from the backend (GET /v1/me/singbox) and run it. TUN mode is the default (full tunnel, needs root);
# --proxy runs a local mixed proxy on 127.0.0.1:2080 with no root. The routing intelligence (China-direct,
# foreign-proxied, ad-block, auto-select, secure DNS) is generated server-side — this client is thin.
set -euo pipefail

API="${YOHO_API:-https://api.yohovpn.com}"
DIR="${XDG_CONFIG_HOME:-$HOME/.config}/yoho-vpn"
TOKEN_FILE="$DIR/token"
CONF="$DIR/singbox.json"
PIDFILE="$DIR/singbox.pid"
LOG="$DIR/singbox.log"
mkdir -p "$DIR"; chmod 700 "$DIR"

c_grn=$'\e[32m'; c_red=$'\e[31m'; c_dim=$'\e[2m'; c_rst=$'\e[0m'
say() { printf '%s\n' "$*"; }
die() { printf '%syoho: %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; }

need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1 — install it and retry"; }
need curl; need jq

# Resolve sing-box: prefer the version-pinned binary shipped with this package (installed to
# /usr/local/lib/yoho-vpn or sitting next to the script) over whatever is on PATH — the config is tuned to
# a known engine version, and a mismatched system sing-box can fail at runtime (e.g. a config that passes
# `check` but is rejected at `run`). YOHO_SINGBOX overrides.
resolve_singbox() {
  local self; self="$(readlink -f "$0" 2>/dev/null || echo "$0")"
  for cand in "${YOHO_SINGBOX:-}" "/usr/local/lib/yoho-vpn/sing-box" "$(dirname "$self")/sing-box" "$(command -v sing-box 2>/dev/null || true)"; do
    [ -n "$cand" ] && [ -x "$cand" ] && { printf '%s' "$cand"; return 0; }
  done
  return 1
}
SB="$(resolve_singbox || true)"
sb() { [ -n "$SB" ] || die "sing-box not found (bundled binary missing and none on PATH)"; "$SB" "$@"; }

token() { [ -f "$TOKEN_FILE" ] && cat "$TOKEN_FILE" || true; }

api() { # api METHOD PATH [json-body]
  local method="$1" path="$2" body="${3:-}"
  if [ -n "$body" ]; then
    curl -fsS -X "$method" "$API$path" -H 'content-type: application/json' --data "$body"
  else
    curl -fsS -X "$method" "$API$path" -H "authorization: Bearer $(token)"
  fi
}

cmd_login() {
  local email code resp session
  read -rp "Email: " email
  [ -n "$email" ] || die "email required"
  # No Origin header → no Turnstile challenge (native/CLI callers are ungated by design).
  api POST /v1/auth/email/request "$(jq -n --arg e "$email" '{email:$e}')" >/dev/null \
    || die "could not send code (rate-limited? try again in a minute)"
  say "${c_dim}A 6-digit code was emailed to $email.${c_rst}"
  read -rp "Code: " code
  resp="$(api POST /v1/auth/email/verify "$(jq -n --arg e "$email" --arg c "$code" '{email:$e,code:$c}')")" \
    || die "verification failed (wrong or expired code)"
  session="$(printf '%s' "$resp" | jq -r '.session // empty')"
  [ -n "$session" ] || die "no session returned"
  printf '%s' "$session" > "$TOKEN_FILE"; chmod 600 "$TOKEN_FILE"
  say "${c_grn}Signed in.${c_rst} Run 'yoho-vpn up' to connect."
}

fetch_config() { # fetch_config <profile> [auto]
  [ -n "$(token)" ] || die "not signed in — run 'yoho-vpn login'"
  local profile="$1" auto="${2:-0}" http
  # clash_api=1 asks the server to embed a loopback control socket, which is how `speed` and `use`
  # read sing-box's OWN per-node latency (real through-tunnel round-trips) instead of guessing from a
  # local TCP handshake. auto=0 (the default) means the server emits NO urltest group at all, so
  # nothing silently moves you off the node you picked.
  http="$(curl -sS -o "$CONF" -w '%{http_code}' "$API/v1/me/singbox?profile=$profile&auto=$auto&clash_api=1" -H "authorization: Bearer $(token)")"
  case "$http" in
    200) : ;;
    401) die "session expired — run 'yoho-vpn login' again" ;;
    403) die "no usable nodes for your plan right now (subscribe, or your free quota may be exhausted)" ;;
    *)   die "server returned HTTP $http" ;;
  esac
}

is_running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }

cmd_up() {
  is_running && die "already connected (yoho-vpn down first)"
  local profile="tun" sudo="sudo" auto=0
  for a in "$@"; do
    case "$a" in
      --proxy) profile="proxy"; sudo="" ;;
      # Smart routing is OFF unless asked for: it lets the engine re-pick your node every few minutes.
      --auto)  auto=1 ;;
    esac
  done
  say "${c_dim}Fetching config ($profile$([ "$auto" = 1 ] && printf ', smart routing'))…${c_rst}"
  fetch_config "$profile" "$auto"
  sb check -c "$CONF" >/dev/null 2>&1 || die "server returned an invalid config (please report)"
  say "${c_dim}Starting sing-box ($profile mode)…${c_rst}"
  # TUN needs CAP_NET_ADMIN; run under sudo unless already root. Proxy mode needs no privilege.
  $sudo sh -c "nohup '$SB' run -c '$CONF' >'$LOG' 2>&1 & echo \$! >'$PIDFILE'"
  sleep 1
  if is_running; then
    if [ "$profile" = proxy ]; then
      say "${c_grn}Connected${c_rst} — local proxy at 127.0.0.1:2080 (set it as your system/HTTP+SOCKS proxy)."
    else
      say "${c_grn}Connected${c_rst} — full tunnel is up. 'yoho-vpn down' to stop."
    fi
  else
    say "${c_red}Failed to start.${c_rst} Last log lines:"; tail -n 8 "$LOG" 2>/dev/null || true; exit 1
  fi
}

cmd_down() {
  is_running || { say "Not connected."; return 0; }
  local pid; pid="$(cat "$PIDFILE")"
  # started under sudo for TUN → needs sudo to kill; fall back to plain kill for proxy mode.
  sudo kill "$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
  rm -f "$PIDFILE"
  say "${c_grn}Disconnected.${c_rst}"
}

cmd_status() {
  [ -n "$(token)" ] && say "Signed in: yes" || say "Signed in: no"
  is_running && say "Connection: ${c_grn}up${c_rst} (pid $(cat "$PIDFILE"))" || say "Connection: down"
}

# --- node latency + manual switching (via sing-box's local clash_api) ---------------------------
# Reads the control socket the server embedded in the config. This measures a real HTTP round-trip
# THROUGH each node, which is a different and much better signal than a local TCP handshake: a
# handshake only proves the entry IP answers, while this proves the node can actually carry traffic
# out and how fast. Requires a live connection — the socket only exists while sing-box runs.
CLASH_URL="http://127.0.0.1:9090"
DELAY_URL="http://cp.cloudflare.com/generate_204"

clash_secret() { jq -r '.experimental.clash_api.secret // empty' "$CONF" 2>/dev/null || true; }

clash_api() { # clash_api METHOD PATH [json-body]
  local secret; secret="$(clash_secret)"
  [ -n "$secret" ] || die "this config has no control socket — reconnect with 'yoho-vpn up' to get one"
  if [ -n "${3:-}" ]; then
    curl -sS -X "$1" "$CLASH_URL$2" -H "authorization: Bearer $secret" -H 'content-type: application/json' -d "$3"
  else
    curl -sS -X "$1" "$CLASH_URL$2" -H "authorization: Bearer $secret"
  fi
}

# The selector group's tag, and the node tags inside it. Read from the config so a server-side rename
# never breaks this.
selector_tag() { jq -r '.outbounds[] | select(.type=="selector") | .tag' "$CONF"; }
node_tags() { jq -r '.outbounds[] | select(.type=="selector") | .outbounds[]' "$CONF"; }

cmd_speed() {
  is_running || die "not connected — 'yoho-vpn up' first (latency is measured through the tunnel)"
  need jq
  local sel current
  sel="$(selector_tag)"
  current="$(clash_api GET "/proxies/$(jq -rn --arg s "$sel" '$s|@uri')" | jq -r '.now // empty')"
  say "${c_dim}Probing each node through the tunnel…${c_rst}"
  # Probe sequentially: these all share one tunnel, so firing them at once would have them queue
  # behind each other and inflate every reading (the same trap the mobile client hit).
  local out=""
  while IFS= read -r tag; do
    [ -n "$tag" ] || continue
    local enc ms
    enc="$(jq -rn --arg s "$tag" '$s|@uri')"
    ms="$(clash_api GET "/proxies/$enc/delay?url=$(jq -rn --arg u "$DELAY_URL" '$u|@uri')&timeout=3000" | jq -r '.delay // empty')"
    [ -n "$ms" ] || ms="-"
    out="$out$ms\t$tag\n"
  done <<EOF
$(node_tags)
EOF
  printf 'MS\tNODE\n'
  # Sort numerically, unreachable ("-") last.
  printf "$out" | sort -t$'\t' -k1,1n | while IFS=$'\t' read -r ms tag; do
    local mark=""
    [ "$tag" = "$current" ] && mark=" ${c_grn}← current${c_rst}"
    printf '%s\t%s%s\n' "$ms" "$tag" "$mark"
  done
}

cmd_use() { # cmd_use <substring>
  is_running || die "not connected — 'yoho-vpn up' first"
  need jq
  local want="${1:-}"
  [ -n "$want" ] || die "usage: yoho-vpn use <node-id or name fragment>   (see 'yoho-vpn speed')"
  local tag
  tag="$(node_tags | grep -i -- "$want" | head -1 || true)"
  [ -n "$tag" ] || die "no node matches '$want' — run 'yoho-vpn speed' to list them"
  local sel; sel="$(selector_tag)"
  clash_api PUT "/proxies/$(jq -rn --arg s "$sel" '$s|@uri')" "$(jq -n --arg n "$tag" '{name:$n}')" >/dev/null
  say "${c_grn}Switched to${c_rst} $tag"
  # Runtime-only: sing-box does not persist a selector change, so a reconnect returns to the config's
  # default. Say so rather than let it look like it silently reverted.
  say "${c_dim}(applies to this session; reconnecting returns to the default node)${c_rst}"
}

cmd_logout() { cmd_down >/dev/null 2>&1 || true; rm -f "$TOKEN_FILE"; say "Signed out."; }

# Install a systemd service so the full tunnel auto-starts on boot and survives logout. Fetches a fresh
# TUN config into /etc/yoho-vpn/ (per-user creds baked in) and enables the unit. Re-run after a plan change.
cmd_install_service() {
  [ -n "$SB" ] || die "sing-box not found"
  say "${c_dim}Fetching config…${c_rst}"
  fetch_config tun
  sb check -c "$CONF" >/dev/null 2>&1 || die "server returned an invalid config (please report)"
  say "${c_dim}Installing systemd unit (sudo)…${c_rst}"
  sudo install -d -m 0755 /etc/yoho-vpn
  sudo install -m 0600 "$CONF" /etc/yoho-vpn/singbox.json
  sudo tee /etc/systemd/system/yoho-vpn.service >/dev/null <<UNIT
[Unit]
Description=Yoho VPN (sing-box)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SB run -c /etc/yoho-vpn/singbox.json
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
NoNewPrivileges=true
DeviceAllow=/dev/net/tun rw
[Install]
WantedBy=multi-user.target
UNIT
  sudo systemctl daemon-reload
  sudo systemctl enable --now yoho-vpn.service
  say "${c_grn}Service installed & started.${c_rst} 'sudo systemctl status yoho-vpn' · re-run 'yoho-vpn install-service' after a plan change."
}

case "${1:-}" in
  login)  cmd_login ;;
  up)     shift; cmd_up "${1:-}" ;;
  down)   cmd_down ;;
  status) cmd_status ;;
  speed)  cmd_speed ;;
  use)    shift; cmd_use "${1:-}" ;;
  logout) cmd_logout ;;
  install-service) cmd_install_service ;;
  *) cat <<EOF
Yoho VPN — Linux CLI

  yoho-vpn login            Sign in with your email (one-time code)
  yoho-vpn up               Connect (full tunnel — TUN, uses sudo)
  yoho-vpn up --proxy       Connect as a local proxy on 127.0.0.1:2080 (no root)
  yoho-vpn up --auto        Connect with smart routing (engine re-picks the fastest node
                            every few minutes; OFF by default so your pick is never moved)
  yoho-vpn down             Disconnect
  yoho-vpn status           Show sign-in + connection state
  yoho-vpn speed            Measure every node THROUGH the tunnel and rank them
  yoho-vpn use <node>       Switch to a node (id or name fragment; this session only)
  yoho-vpn install-service  Auto-start the tunnel on boot (systemd)
  yoho-vpn logout           Sign out

Requires: sing-box, curl, jq.  Override the server with YOHO_API=…
EOF
  ;;
esac
