#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob

SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
SB_ROOT="${SING_BOX_ROOT:-$SCRIPT_DIR}"
SB_BIN="${SING_BOX_BIN:-${SB_ROOT}/sing-box/sing-box}"
SB_SYSTEM="${SB_ROOT}/system/config.json"
SB_BYPASS="${SB_ROOT}/bypass.list"
SB_PROFILES="${SB_ROOT}/profiles"
SB_CURRENT="${SB_ROOT}/current.profile"
SB_SETTINGS="${SB_ROOT}/settings.json"
SB_RUN_DIR="${SING_BOX_RUN_DIR:-${SB_ROOT}/.runtime}"
SB_CONFIG="${SB_RUN_DIR}/config.json"
SB_RULES="${SB_RUN_DIR}/bypass-rules.json"
SB_PID_FILE="${SB_RUN_DIR}/run.pid"
SB_LOG="${SB_RUN_DIR}/sing-box.log"
SB_PORT="${SING_BOX_PORT:-7890}"
SB_API_PORT="${SING_BOX_API_PORT:-9090}"
SB_API="http://127.0.0.1:${SB_API_PORT}"

die() { printf 'sb: %s\n' "$*" >&2; exit 1; }
info() { printf 'sb: %s\n' "$*"; }
warn() { printf 'sb: warning: %s\n' "$*" >&2; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }

init_layout() {
  [[ -x "$SB_BIN" ]] || die "sing-box binary is not executable: $SB_BIN"
  [[ -f "$SB_SYSTEM" ]] || die "system config not found: $SB_SYSTEM"
  [[ -f "$SB_BYPASS" ]] || die "bypass list not found: $SB_BYPASS"
  need jq
  mkdir -p "$SB_PROFILES" "$SB_RUN_DIR"
  if [[ ! -f "$SB_SETTINGS" ]]; then
    printf '%s\n' '{"mode":"rule","tun":false,"cliproxy":false,"sysproxy":false}' >"$SB_SETTINGS"
  fi
}

setting() { jq -r --arg key "$1" '.[$key]' "$SB_SETTINGS"; }

set_setting() {
  local key="$1" value="$2"
  if [[ "$value" == true || "$value" == false ]]; then
    jq --arg key "$key" --argjson value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp"
  else
    jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp"
  fi
  mv -f "${SB_SETTINGS}.tmp" "$SB_SETTINGS"
}

profile_dir() { printf '%s/%s\n' "$SB_PROFILES" "$1"; }
provider_file() { printf '%s/provider.json\n' "$(profile_dir "$1")"; }
metadata_file() { printf '%s/source.json\n' "$(profile_dir "$1")"; }
validate_name() { [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]] || die 'profile name contains unsupported characters'; }

current_profile() {
  [[ -f "$SB_CURRENT" ]] || return 1
  local name
  name="$(tr -d '[:space:]' <"$SB_CURRENT")"
  [[ -n "$name" && -f "$(provider_file "$name")" ]] || return 1
  printf '%s\n' "$name"
}

list_profiles() {
  local file
  for file in "$SB_PROFILES"/*/provider.json; do
    [[ -f "$file" ]] && basename "$(dirname "$file")"
  done | sort
}

validate_provider() {
  jq -e 'type == "object" and (.outbounds | type == "array") and (.outbounds | length > 0)' "$1" >/dev/null \
    || die "provider must be sing-box JSON with non-empty .outbounds: $1"
}

fetch_provider() {
  local src="$1" dest="$2"
  if [[ "$src" =~ ^https?:// ]]; then
    need curl
    curl -fL --connect-timeout 15 --max-time 90 -A 'sb/1.0 sing-box-provider' "$src" -o "$dest"
  elif [[ -f "$src" ]]; then
    cp -f -- "$src" "$dest"
  else
    die "source is neither an HTTP URL nor a file: $src"
  fi
  validate_provider "$dest"
}

add_cmd() {
  local name="${1:-}" src="${2:-}" dir tmp kind saved_src
  [[ -n "$name" && -n "$src" ]] || die 'usage: sb add <name> <url|file>'
  validate_name "$name"
  dir="$(profile_dir "$name")"
  [[ ! -e "$dir" ]] || die "profile already exists: $name"
  mkdir -p "$dir"
  tmp="${dir}/provider.json.tmp"
  if [[ "$src" =~ ^https?:// ]]; then kind=http; saved_src="$src"; else kind=file; saved_src="$(readlink -f "$src")"; fi
  if ! fetch_provider "$src" "$tmp"; then rm -f "$tmp"; rmdir "$dir" 2>/dev/null || true; return 1; fi
  mv -f "$tmp" "${dir}/provider.json"
  jq -n --arg name "$name" --arg type "$kind" --arg source "$saved_src" --arg added_at "$(date -Iseconds)" \
    '{name:$name,type:$type,source:$source,added_at:$added_at}' >"${dir}/source.json"
  printf '%s\n' "$name" >"$SB_CURRENT"
  info "added profile=${name} type=${kind}"
  info "run: sb start ${name}"
}

compile_bypass_rules() {
  local line value prefix
  : >"${SB_RULES}.ndjson"
  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"
    line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"
    [[ -n "$line" ]] || continue
    if [[ "$line" == */* ]]; then
      jq -nc --arg v "$line" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
    elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
      jq -nc --arg v "${line}/32" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
    elif [[ "$line" =~ ^(10|127|172|192)(\..*)?\*$ ]]; then
      prefix="${BASH_REMATCH[1]}"
      case "$prefix" in 10) value='10.0.0.0/8';; 127) value='127.0.0.0/8';; 172) value='172.16.0.0/12';; 192) value='192.168.0.0/16';; esac
      jq -nc --arg v "$value" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
    elif [[ "$line" == \** || "$line" == .* ]]; then
      value="${line#\*}"; value="${value#.}"
      [[ -n "$value" ]] && jq -nc --arg v "$value" '{domain_suffix:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
    else
      jq -nc --arg v "$line" '{domain:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
    fi
  done <"$SB_BYPASS"
  jq -s 'unique' "${SB_RULES}.ndjson" >"${SB_RULES}.tmp"
  mv -f "${SB_RULES}.tmp" "$SB_RULES"
  rm -f "${SB_RULES}.ndjson"
}

proxy_bypass_items() {
  local mode="$1" line value
  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"
    line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"
    [[ -n "$line" ]] || continue
    value="$line"
    if [[ "$mode" == no_proxy && "$value" == \*.* ]]; then
      value=".${value#*.}"
    elif [[ "$mode" == gsettings && "$value" == .* ]]; then
      value="*${value}"
    fi
    printf '%s\n' "$value"
  done <"$SB_BYPASS" | awk '!seen[$0]++'
}

no_proxy_value() { proxy_bypass_items no_proxy | paste -sd, -; }
gsettings_bypass_value() { proxy_bypass_items gsettings | jq -Rsc 'split("\n") | map(select(length > 0))'; }

build_config() {
  local profile="$1" provider mode tun
  provider="$(provider_file "$profile")"
  [[ -f "$provider" ]] || die "profile not found: $profile"
  validate_provider "$provider"
  compile_bypass_rules
  mode="$(setting mode)"; tun="$(setting tun)"
  jq -n --slurpfile provider "$provider" --slurpfile system "$SB_SYSTEM" --slurpfile bypass "$SB_RULES" \
    --arg mode "$mode" --argjson tun "$tun" --argjson port "$SB_PORT" --arg controller "127.0.0.1:${SB_API_PORT}" '
      ($provider[0]) as $p | ($system[0]) as $s | ($bypass[0]) as $b
      | ($p.outbounds // []) as $original
      | ($original | map(.tag // empty)) as $tags0
      | ($original + if ($tags0|index("DIRECT")) == null then [{type:"direct",tag:"DIRECT"}] else [] end) as $direct_added
      | ($direct_added | map(.tag // empty)) as $tags1
      | ($direct_added + if ($tags1|index("Proxy")) == null then
          [{type:"selector",tag:"Proxy",outbounds:[$direct_added[]|select(.type!="direct" and .type!="block" and .type!="dns" and .type!="selector")|.tag]}]
        else [] end) as $outbounds
      | ($outbounds | map(.tag // empty)) as $outbound_tags
      | ([{type:"mixed",tag:"mixed-in",listen:"127.0.0.1",listen_port:$port,set_system_proxy:false}]
         + if $tun then [($p.inbounds // [] | map(select(.type=="tun")) | .[0]
             // {type:"tun",tag:"tun-in",address:["172.19.0.1/30","fdfe:dcba:9876::1/126"],auto_route:true,strict_route:true})] else [] end) as $inbounds
      | ([{action:"sniff"}] + if $tun then [{protocol:"dns",action:"hijack-dns"}] else [] end + $b) as $system_rules
      | (($p.route.rules // []) | map(select(.action!="sniff" and .action!="hijack-dns"))) as $provider_rules
      | (if $mode=="rule" then $system_rules+$provider_rules else $system_rules end) as $rules
      | (if $mode=="direct" then "DIRECT" elif $mode=="global" then "Proxy"
         elif (($p.route.final // "") as $f | ($outbound_tags|index($f)) != null) then $p.route.final else "Proxy" end) as $final
      | $p
      | .log=$s.log | .dns=$s.dns | .inbounds=$inbounds | .outbounds=$outbounds
      | .route=(($p.route // {}) * ($s.route // {}))
      | .route.auto_detect_interface=$tun | .route.rules=$rules | .route.final=$final
      | .experimental=(($p.experimental // {}) * ($s.experimental // {}))
      | .experimental.clash_api.external_controller=$controller
    ' >"${SB_CONFIG}.tmp"
  mv -f "${SB_CONFIG}.tmp" "$SB_CONFIG"
}

process_alive() {
  local pid="$1" state _
  [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1
  read -r _ _ state _ <"/proc/${pid}/stat" 2>/dev/null || return 1
  [[ "$state" != Z ]]
}

is_running() {
  [[ -f "$SB_PID_FILE" ]] || return 1
  process_alive "$(<"$SB_PID_FILE")"
}

port_in_use() {
  ss -ltnH 2>/dev/null | awk -v suffix=":$1" 'substr($4,length($4)-length(suffix)+1)==suffix{f=1} END{exit !f}'
}

api_curl() {
  local secret="$(jq -r '.experimental.clash_api.secret // empty' "$SB_CONFIG" 2>/dev/null || true)"
  if [[ -n "$secret" ]]; then curl -fsS --noproxy '*' -H "Authorization: Bearer ${secret}" "$@"; else curl -fsS --noproxy '*' "$@"; fi
}

wait_api() { local i; for i in {1..50}; do api_curl "${SB_API}/proxies" >/dev/null 2>&1 && return 0; sleep 0.1; done; return 1; }

stop_cmd() {
  if ! is_running; then rm -f "$SB_PID_FILE"; info 'not running'; return 0; fi
  local pid="$(<"$SB_PID_FILE")" expected actual comm cmdline i
  expected="$(readlink -f "$SB_BIN")"
  actual="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)"
  comm="$(cat "/proc/${pid}/comm" 2>/dev/null || true)"
  cmdline="$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true)"
  if [[ -z "$actual" ]] && ! process_alive "$pid"; then
    rm -f "$SB_PID_FILE"; info 'process already exited'; return 0
  fi
  if [[ "$actual" != "$expected" ]]; then
    [[ "$comm" == "$(basename "$SB_BIN")" && "$cmdline" == "$SB_BIN run "* && "$cmdline" == *" -c $SB_CONFIG"* ]] \
      || die "refusing to stop pid ${pid}: process identity does not match sb runtime"
  fi
  kill "$pid" 2>/dev/null || true
  for i in {1..30}; do process_alive "$pid" || break; sleep 0.1; done
  process_alive "$pid" && die "pid ${pid} did not stop"
  rm -f "$SB_PID_FILE"; info "stopped pid ${pid}"
}

choose_profile() {
  local requested="${1:-}" current pick i
  local -a profiles
  mapfile -t profiles < <(list_profiles)
  (( ${#profiles[@]} )) || die 'no providers; use: sb add <name> <url|file>'
  current="$(current_profile 2>/dev/null || true)"
  if [[ -n "$requested" ]]; then [[ -f "$(provider_file "$requested")" ]] || die "profile not found: $requested"; echo "$requested"; return; fi
  if [[ ! -t 0 ]]; then [[ -n "$current" ]] || die 'non-interactive start requires profile name'; echo "$current"; return; fi
  echo 'Available providers:' >&2
  for i in "${!profiles[@]}"; do
    [[ "${profiles[$i]}" == "$current" ]] && printf '  %d) %s (current)\n' "$((i+1))" "${profiles[$i]}" >&2 || printf '  %d) %s\n' "$((i+1))" "${profiles[$i]}" >&2
  done
  while true; do
    read -r -p "Choose provider (1-${#profiles[@]}, 0 cancel): " pick
    [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.' >&2; continue; }
    (( pick==0 )) && return 1
    (( pick>=1 && pick<=${#profiles[@]} )) || { echo 'Out of range.' >&2; continue; }
    echo "${profiles[$((pick-1))]}"; return
  done
}

start_cmd() {
  local profile pid i
  profile="$(choose_profile "${1:-}")" || { info 'cancelled'; return; }
  is_running && stop_cmd
  build_config "$profile"
  "$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG"
  need ss
  port_in_use "$SB_PORT" && die "port ${SB_PORT} is in use (stop mihomo before sb)"
  port_in_use "$SB_API_PORT" && die "API port ${SB_API_PORT} is in use (stop mihomo before sb)"
  : >"$SB_LOG"
  nohup "$SB_BIN" run -D "$SB_RUN_DIR" -c "$SB_CONFIG" >>"$SB_LOG" 2>&1 & pid=$!
  echo "$pid" >"$SB_PID_FILE"; echo "$profile" >"$SB_CURRENT"
  for i in {1..40}; do
    if ! kill -0 "$pid" 2>/dev/null; then rm -f "$SB_PID_FILE"; tail -n 50 "$SB_LOG" >&2 || true; die 'sing-box exited during startup'; fi
    if port_in_use "$SB_PORT" && port_in_use "$SB_API_PORT"; then
      info "started profile=${profile} pid=${pid} mode=$(setting mode) tun=$(setting tun)"; wait_api || warn 'API not ready'; return
    fi
    sleep 0.1
  done
  tail -n 50 "$SB_LOG" >&2 || true; die 'startup timed out'
}

restart_if_running() {
  local profile="$(current_profile 2>/dev/null || true)"
  if is_running; then stop_cmd; start_cmd "$profile"; fi
}

require_api() { is_running || die 'sing-box is not running'; api_curl "${SB_API}/proxies" >/dev/null || die "API unavailable: $SB_API"; }
urlencode() { jq -nr --arg v "$1" '$v|@uri'; }

api_delay() {
  local node="$1" timeout="${2:-1800}" encoded url
  encoded="$(urlencode "$node")"; url="$(urlencode 'https://www.gstatic.com/generate_204')"
  api_curl "${SB_API}/proxies/${encoded}/delay?timeout=${timeout}&url=${url}" 2>/dev/null \
    | jq -r 'if (.delay|type)=="number" then .delay else 999999 end' 2>/dev/null || echo 999999
}

delay_text() {
  local delay="${1:-}"
  [[ "$delay" =~ ^[0-9]+$ && "$delay" != 999999 ]] && printf '%sms\n' "$delay" || printf 'timeout\n'
}

main_group() { jq -r 'if .proxies.Proxy.all then "Proxy" else [.proxies|to_entries[]|select(.value.all!=null)][0].key//empty end' <<<"$1"; }

display_node_grid() {
  local -n ref=$1
  local count=${#ref[@]} width cols=1 rows r c idx
  width="$(tput cols 2>/dev/null || echo 120)"
  (( count>18 && width>=110 )) && cols=2
  (( count>36 && width>=170 )) && cols=3
  rows=$(((count+cols-1)/cols))
  for ((r=0;r<rows;r++)); do
    for ((c=0;c<cols;c++)); do
      idx=$((r+c*rows)); ((idx<count)) || continue
      if ((c+1<cols && idx+rows<count)); then printf '%-56s' "${ref[$idx]}"; else printf '%s' "${ref[$idx]}"; fi
    done
    echo
  done
}

list_nodes_cmd() {
  local keyword="${1:-}" json group now candidates tmp running=0 i d pick encoded body
  local -a nodes sorted_nodes=() sorted_delays=() display=()
  require_api; json="$(api_curl "${SB_API}/proxies")"; group="$(main_group "$json")"; [[ -n "$group" ]] || die 'no selectable group'
  now="$(jq -r --arg g "$group" '.proxies[$g].now//empty' <<<"$json")"
  candidates="$(jq -r --arg g "$group" --arg kw "$keyword" '
    def lc: ascii_downcase; . as $r | .proxies[$g].all[] as $n | ($r.proxies[$n].type//"") as $t
    | select(["Direct","Reject","Block","Selector","URLTest","Fallback","LoadBalance"]|index($t)==null)
    | select($kw=="" or ($n|contains($kw)) or (($n|lc)|contains($kw|lc))) | $n' <<<"$json")"
  [[ -n "$candidates" ]] || die "no nodes matched: ${keyword:-<all>}"; mapfile -t nodes <<<"$candidates"
  tmp="$(mktemp -d "${SB_RUN_DIR}/delay.XXXXXX")"; info "testing ${#nodes[@]} nodes (1800ms, 8 jobs)..."
  for i in "${!nodes[@]}"; do
    (api_delay "${nodes[$i]}" >"${tmp}/${i}") & running=$((running+1))
    if ((running>=8)); then wait -n 2>/dev/null || wait; running=$((running-1)); fi
  done
  wait; : >"${tmp}/index"
  for i in "${!nodes[@]}"; do d="$(<"${tmp}/${i}")"; [[ "$d" =~ ^[0-9]+$ ]] || d=999999; printf '%s\t%s\n' "$d" "$i" >>"${tmp}/index"; done
  while IFS=$'\t' read -r d i; do sorted_delays+=("$d"); sorted_nodes+=("${nodes[$i]}"); done < <(sort -n "${tmp}/index")
  rm -rf -- "$tmp"
  printf 'Group: %s  Current: %s' "$group" "$now"; [[ -n "$keyword" ]] && printf '  Filter: %s' "$keyword"; echo
  for i in "${!sorted_nodes[@]}"; do
    d="$(delay_text "${sorted_delays[$i]}")"
    [[ "${sorted_nodes[$i]}" == "$now" ]] && display+=("[$((i+1))] * ${sorted_nodes[$i]}  ${d}") || display+=("[$((i+1))]   ${sorted_nodes[$i]}  ${d}")
  done
  display_node_grid display
  [[ -t 0 ]] || return
  while true; do
    read -r -p "Choose node (1-${#sorted_nodes[@]}, 0 cancel): " pick
    [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; }
    ((pick==0)) && { info cancelled; return; }
    ((pick>=1 && pick<=${#sorted_nodes[@]})) || { echo 'Out of range.'; continue; }; break
  done
  encoded="$(urlencode "$group")"; body="$(jq -nc --arg name "${sorted_nodes[$((pick-1))]}" '{name:$name}')"
  api_curl -X PUT -H 'Content-Type: application/json' --data "$body" "${SB_API}/proxies/${encoded}" >/dev/null
  info "${group} -> ${sorted_nodes[$((pick-1))]} (${sorted_delays[$((pick-1))]}ms)"
}

show_env() {
  local no_proxy
  no_proxy="$(no_proxy_value)"
  cat <<EOF
export http_proxy="http://127.0.0.1:${SB_PORT}"
export https_proxy="http://127.0.0.1:${SB_PORT}"
export all_proxy="socks5h://127.0.0.1:${SB_PORT}"
export HTTP_PROXY="\$http_proxy"
export HTTPS_PROXY="\$https_proxy"
export ALL_PROXY="\$all_proxy"
EOF
  printf 'export NO_PROXY=%q\n' "$no_proxy"
  printf '%s\n' 'export no_proxy="$NO_PROXY"'
}
show_noenv() { echo 'unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY no_proxy'; }

shell_init_cmd() {
  local real="$(printf '%q' "$SCRIPT_PATH")"
  cat <<EOF
sb() {
  SB_SHELL_HOOK=1 ${real} "\$@"
  local rc=\$?
  if ((rc==0)) && [[ "\${1:-}" == set ]] && { [[ "\${2:-}" == cliproxy ]] || [[ "\${2:-}" == proxy ]]; }; then
    if [[ "\${3:-}" == on ]]; then eval "\$(${real} env)"; else eval "\$(${real} noenv)"; fi
  fi
  return \$rc
}
EOF
  [[ "$(setting cliproxy)" == true ]] && show_env || show_noenv
}

sysproxy_cmd() {
  local action="$1" ignore_hosts; need gsettings
  case "$action" in
    on)
      ignore_hosts="$(gsettings_bypass_value)"
      gsettings set org.gnome.system.proxy mode manual
      gsettings set org.gnome.system.proxy.http host 127.0.0.1; gsettings set org.gnome.system.proxy.http port "$SB_PORT"
      gsettings set org.gnome.system.proxy.https host 127.0.0.1; gsettings set org.gnome.system.proxy.https port "$SB_PORT"
      gsettings set org.gnome.system.proxy.socks host 127.0.0.1; gsettings set org.gnome.system.proxy.socks port "$SB_PORT"
      gsettings set org.gnome.system.proxy ignore-hosts "$ignore_hosts"
      set_setting sysproxy true; info "system proxy ON -> 127.0.0.1:${SB_PORT}" ;;
    off) gsettings set org.gnome.system.proxy mode none; set_setting sysproxy false; info 'system proxy OFF' ;;
    status) printf 'sysproxy setting=%s gnome-mode=%s\n' "$(setting sysproxy)" "$(gsettings get org.gnome.system.proxy mode)" ;;
    *) die 'usage: sb set sysproxy on|off' ;;
  esac
}

default_bypass() {
  cat <<'EOF'
# sb system bypass rules (one item per line)
localhost
*.local
127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
100.64.0.2
100.64.0.0/10
::1/128
fc00::/7
fe80::/10
EOF
}

rules_cmd() {
  case "${1:-show}" in
    show) echo "bypass.list: $SB_BYPASS"; nl -ba "$SB_BYPASS" ;;
    update) compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info "compiled $(jq length "$SB_RULES") bypass rules"; restart_if_running ;;
    reset) default_bypass >"${SB_BYPASS}.tmp"; mv -f "${SB_BYPASS}.tmp" "$SB_BYPASS"; compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info 'bypass.list reset'; restart_if_running ;;
    *) die 'usage: sb set rules show|update|reset' ;;
  esac
}

set_cmd() {
  local target="${1:-}" action="${2:-}"
  [[ -n "$target" ]] || { jq . "$SB_SETTINGS"; sysproxy_cmd status 2>/dev/null || true; return; }
  case "$target" in
    cliproxy)
      [[ "$action" == on || "$action" == off ]] || die 'usage: sb set cliproxy on|off'
      set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; info "CLI proxy ${action}"
      [[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;;
    sysproxy) sysproxy_cmd "$action" ;;
    proxy)
      [[ "$action" == on || "$action" == off ]] || die 'usage: sb set proxy on|off'
      set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; sysproxy_cmd "$action"; info "CLI + system proxy ${action}"
      [[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;;
    tun)
      [[ "$action" == on || "$action" == off ]] || die 'usage: sb set tun on|off'
      set_setting tun "$([[ "$action" == on ]] && echo true || echo false)"
      if [[ "$action" == on ]] && ! getcap "$SB_BIN" 2>/dev/null | grep -q cap_net_admin; then warn "TUN may need: sudo setcap cap_net_admin,cap_net_raw+ep '$SB_BIN'"; fi
      info "TUN ${action}"; restart_if_running ;;
    rules) rules_cmd "$action" ;;
    *) die 'usage: sb set cliproxy|sysproxy|proxy|tun|rules ...' ;;
  esac
}

update_cmd() {
  local profile meta source type dir tmp running=false
  profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no current profile'
  meta="$(metadata_file "$profile")"; [[ -f "$meta" ]] || die "missing metadata: $meta"
  type="$(jq -r .type "$meta")"; source="$(jq -r .source "$meta")"; [[ "$type" == http ]] || die 'current provider is a local file, not HTTP'
  is_running && running=true; dir="$(profile_dir "$profile")"; tmp="${dir}/provider.json.tmp"
  fetch_provider "$source" "$tmp"; mv -f "$tmp" "${dir}/provider.json"
  jq --arg t "$(date -Iseconds)" '.updated_at=$t' "$meta" >"${meta}.tmp"; mv -f "${meta}.tmp" "$meta"
  info "updated HTTP provider: $profile"; [[ "$running" == true ]] && { stop_cmd; start_cmd "$profile"; }
}

mode_cmd() {
  local mode="${1:-}"; [[ "$mode" == rule || "$mode" == global || "$mode" == direct ]] || die 'usage: sb mode rule|global|direct'
  set_setting mode "$mode"; info "mode -> $mode"; restart_if_running
}

status_cmd() {
  local profile="$(current_profile 2>/dev/null || true)" now='' delay=''
  if is_running; then
    now="$(api_curl "${SB_API}/proxies/Proxy" 2>/dev/null | jq -r '.now//empty' || true)"
    [[ -n "$now" ]] && delay="$(delay_text "$(api_delay "$now")")"
    printf 'sing-box: RUNNING pid=%s\n' "$(<"$SB_PID_FILE")"
  else
    echo 'sing-box: STOPPED'
  fi
  printf 'root:     %s\nprofile:  %s\nmode:     %s\ntun:      %s\ncliproxy: %s\nsysproxy: %s\nmixed:    127.0.0.1:%s\napi:      127.0.0.1:%s\n' \
    "$SB_ROOT" "${profile:-<none>}" "$(setting mode)" "$(setting tun)" "$(setting cliproxy)" "$(setting sysproxy)" "$SB_PORT" "$SB_API_PORT"
  [[ -n "$now" ]] && printf 'node:     %s  %s\n' "$now" "$delay"
}

check_cmd() {
  local profile="${1:-}"; [[ -n "$profile" ]] || profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no profile to check'
  build_config "$profile"; "$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG"; info "configuration OK: profile=$profile"
}

db_ports() {
  echo "== listening proxy ports =="
  ss -ltnp 2>/dev/null | awk -v p1=":${SB_PORT}" -v p2=":${SB_API_PORT}" 'NR==1 || index($4,p1) || index($4,p2)'
  if ! port_in_use "$SB_PORT" && ! port_in_use "$SB_API_PORT"; then echo 'no sb ports listening'; fi
}

db_process() {
  local pid="${1:-}"
  [[ -n "$pid" ]] || { [[ -f "$SB_PID_FILE" ]] && pid="$(<"$SB_PID_FILE")" || true; }
  [[ "$pid" =~ ^[0-9]+$ ]] || { echo 'no managed PID'; return 1; }
  ps -p "$pid" -o pid,ppid,user,state,etime,%cpu,%mem,cmd
  echo
  printf 'comm: '; cat "/proc/${pid}/comm" 2>/dev/null || echo '<unavailable>'
  printf 'cmdline: '; tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true; echo
}

db_probe_one() {
  local mode="$1" url="$2"
  case "$mode" in
    direct) curl -fsSIL --max-time 10 --noproxy '*' "$url" -o /dev/null ;;
    proxy) curl -fsSIL --max-time 10 --proxy "http://127.0.0.1:${SB_PORT}" "$url" -o /dev/null ;;
  esac
}

db_net() {
  local mode="${1:-proxy}" url direct_result proxy_result
  local -a urls=(
    'https://www.google.com'
    'https://www.cloudflare.com'
    'https://www.github.com'
    'https://www.youtube.com'
    'https://www.baidu.com'
    'https://www.bing.com'
  )
  case "$mode" in
    proxy)
      is_running || die 'sing-box is not running; proxy test unavailable'
      echo "== proxy network test (127.0.0.1:${SB_PORT}) =="
      for url in "${urls[@]}"; do db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL; printf '%-32s PROXY:%s\n' "$url" "$proxy_result"; done
      ;;
    direct)
      echo '== direct network test =='
      for url in "${urls[@]}"; do db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL; printf '%-32s DIRECT:%s\n' "$url" "$direct_result"; done
      ;;
    compare)
      is_running || die 'sing-box is not running; comparison needs the mixed proxy'
      echo '== DIRECT vs PROXY =='
      for url in "${urls[@]}"; do
        db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL
        db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL
        printf '%-32s DIRECT:%-4s PROXY:%-4s\n' "$url" "$direct_result" "$proxy_result"
      done
      ;;
    *) die 'usage: sb db net [proxy|direct] | sb db compare' ;;
  esac
}

db_deps() {
  local item path rc=0
  local -a required=(jq curl ss sed sort readlink)
  local -a optional=(gsettings getcap ip resolvectl tput)
  echo '== required commands =='
  for item in "${required[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK      %-12s %s\n' "$item" "$path" || { printf 'MISSING %s\n' "$item"; rc=1; }; done
  echo '== optional commands =='
  for item in "${optional[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK      %-12s %s\n' "$item" "$path" || printf 'MISSING %s\n' "$item"; done
  [[ -x "$SB_BIN" ]] && echo "OK      sing-box     $SB_BIN" || { echo "MISSING sing-box     $SB_BIN"; rc=1; }
  jq -e . "$SB_SYSTEM" "$SB_SETTINGS" >/dev/null && echo 'OK      system/settings JSON' || rc=1
  return "$rc"
}

db_interfaces() {
  echo '== interfaces =='
  if command -v ip >/dev/null 2>&1; then ip -brief link show; else echo 'ip command not found'; fi
  echo
  echo '== TUN expectation =='
  printf 'setting: %s\n' "$(setting tun)"
  if command -v ip >/dev/null 2>&1; then ip -brief link show | awk '$1 ~ /^(tun|sing|utun)/ {print}' || true; fi
}

db_dns() {
  echo '== sb DNS configuration =='
  jq .dns "$SB_SYSTEM"
  echo
  echo '== host resolver =='
  if command -v resolvectl >/dev/null 2>&1; then resolvectl status 2>/dev/null | sed -n '1,140p'; else cat /etc/resolv.conf; fi
}

db_routes() {
  if ! command -v ip >/dev/null 2>&1; then echo 'ip command not found'; return 1; fi
  echo '== routes =='; ip route show
  echo; echo '== policy rules =='; ip rule show
  echo; echo '== IPv6 routes =='; ip -6 route show 2>/dev/null || true
}

db_api() {
  local json
  require_api
  echo "== Clash API ${SB_API} =="
  api_curl "${SB_API}/version" | jq .
  json="$(api_curl "${SB_API}/proxies")"
  echo; echo '== selector groups =='
  jq -r '.proxies | to_entries[] | select(.value.all != null) | "\(.key) -> \(.value.now // "-")  (\(.value.all|length) choices)"' <<<"$json"
}

db_config() {
  local profile
  profile="$(current_profile 2>/dev/null || true)"
  [[ -n "$profile" ]] || die 'no current profile'
  check_cmd "$profile"
  jq '{log,inbounds,outbounds:(.outbounds|map({type,tag})),dns,route:{final:.route.final,rule_count:(.route.rules|length),rule_set_count:(.route.rule_set//[]|length)},experimental}' "$SB_CONFIG"
}

db_log() {
  local lines="${1:-80}"
  [[ "$lines" =~ ^[0-9]+$ ]] || die 'usage: sb db log [lines]'
  [[ -f "$SB_LOG" ]] && tail -n "$lines" "$SB_LOG" || echo 'no log file'
}

db_menu() {
  local pick i
  local -a commands=(ports process net compare deps interfaces dns routes api config log)
  local -a descriptions=(
    'proxy/API port listeners'
    'managed process details'
    'proxy-only network test'
    'direct versus proxy network test'
    'required and optional dependencies'
    'network interfaces and TUN expectation'
    'sing-box DNS and host resolver'
    'routes and policy routing'
    'Clash API and selector groups'
    'merged configuration summary and check'
    'recent sing-box log'
  )
  echo '== sb db =='
  for i in "${!commands[@]}"; do printf '  %2d) %-12s %s\n' "$((i+1))" "${commands[$i]}" "${descriptions[$i]}"; done
  [[ -t 0 ]] || return 0
  while true; do
    read -r -p "Choose debug action (1-${#commands[@]}, 0 cancel): " pick
    [[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; }
    (( pick==0 )) && { info cancelled; return 0; }
    (( pick>=1 && pick<=${#commands[@]} )) || { echo 'Out of range.'; continue; }
    db_cmd "${commands[$((pick-1))]}"; return
  done
}

db_cmd() {
  local action="${1:-menu}"; shift || true
  case "$action" in
    menu) db_menu;; ports) db_ports;; process) db_process "${1:-}";;
    net) db_net "${1:-proxy}";; compare) db_net compare;; deps) db_deps;;
    interfaces|ifaces) db_interfaces;; dns) db_dns;; routes|route) db_routes;;
    api) db_api;; config) db_config;; log) db_log "${1:-80}";;
    help|-h|--help) db_menu;;
    *) die 'usage: sb db [ports|process|net|compare|deps|interfaces|dns|routes|api|config|log]' ;;
  esac
}

doctor_cmd() {
  echo '== sb doctor =='; status_cmd; echo; "$SB_BIN" version | sed -n '1,2p'; getcap "$SB_BIN" 2>/dev/null || true
  echo; echo '== profiles =='; list_profiles || true; echo; echo '== config =='; check_cmd
  echo; echo '== log =='; [[ -f "$SB_LOG" ]] && tail -n 30 "$SB_LOG" || echo 'no log'
}

help_cmd() {
  local profile="$(current_profile 2>/dev/null || true)" count="$(list_profiles | wc -l)"
  cat <<EOF
sb - sing-box profile/provider CLI

  add <name> <url|file>       Add a sing-box JSON provider
  start [name]                Choose and start a provider profile
  stop | end                  Stop managed sing-box
  update                      Update current HTTP provider
  -l, --list [keyword]        Speed-test, sort, show, choose nodes
  mode rule|global|direct     Set route mode and restart
  set cliproxy on|off         Terminal proxy state (shell hook)
  set sysproxy on|off         GNOME system proxy
  set proxy on|off            Both terminal and system proxy
  set tun on|off              TUN and restart
  set rules show|update|reset Manage root bypass.list
  status | check [name]       Status / merged config check
  shell-init                  Print Bash integration
  env | noenv                 Print exports / unsets
  db [action]                  Debug menu: ports/process/net/DNS/routes/API/config
  log | doctor                Log / diagnostics
  -h, --help                  Help plus concise configuration

root: $SB_ROOT
profiles: $count  current: ${profile:-<none>}
mode: $(setting mode)  tun: $(setting tun)  cliproxy: $(setting cliproxy)  sysproxy: $(setting sysproxy)
mixed: 127.0.0.1:$SB_PORT  api: 127.0.0.1:$SB_API_PORT
EOF
}

init_layout
case "${1:-status}" in
  add) shift; add_cmd "$@";; start) shift; start_cmd "${1:-}";; stop|end) stop_cmd;; update) update_cmd;;
  -l|--list|list) shift; list_nodes_cmd "${1:-}";; mode) shift; mode_cmd "${1:-}";; set) shift; set_cmd "$@";;
  status) status_cmd;; check) shift; check_cmd "${1:-}";; env) show_env;; noenv) show_noenv;; shell-init) shell_init_cmd;;
  db) shift; db_cmd "$@";;
  log) touch "$SB_LOG"; tail -n 80 -f "$SB_LOG";; doctor) doctor_cmd;; -h|--help|help) help_cmd;;
  *) help_cmd >&2; exit 2;;
esac
