#!/usr/bin/env bash
# 3ling - helper CLI untuk x-ui/3x-ui panel via API
# Subcommands:
#   add    <remark/email> <exp_days> <proto> <transport> [quota_gb] [limitip_override] [inbound_id_override]
#   delete <email>        <proto>    <transport>         [inbound_id_override]
#
# Fitur:
# - login via cookie
# - pilih inbound berdasar proto & transport
# - add user (idempotent: cek duplikat terlebih dulu)
# - delete user by email (delClient → fallback update settings)
# - ringkasan output JSON
#
set -euo pipefail

API_HOST_FILE="/etc/x-ui/api_host"
API_PORT_FILE="/etc/x-ui/api_port"
WEBBASE_FILE="/etc/x-ui/webbasepath"
USER_FILE="/etc/x-ui/username"
PASS_FILE="/etc/x-ui/password"
DOMAIN_FILE="/etc/x-ui/domain"
LIMITIP_FILE="/etc/x-ui/limitip"
SS2022_SERVER_PSK_FILE="/etc/x-ui/ss2022psk"

COOKIE=""
trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' EXIT

read_file(){ local p="${1:-}" def="${2:-}"; [[ -r "$p" ]] && tr -d '\r' <"$p" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' || printf '%s' "$def"; }
norm_basepath(){ local s="${1:-/}"; [[ -z "$s" ]]&&s="/"; [[ "$s" != /* ]]&&s="/$s"; [[ "$s" != */ ]]&&s="$s/"; printf '%s' "$s"; }
norm_proto(){ local s="${1,,}"; case "$s" in trojan|vless|vmess|shadowsocks) echo "$s";; *) echo "Unknown proto: $s" >&2; exit 2;; esac; }
norm_transport(){ local s="${1,,}"; case "$s" in ws|httpupgrade|grpc|xhttp|tcp|tcp_tls|reality|none) echo "$s";; *) echo "Unknown transport: $s" >&2; exit 2;; esac; }
urlencode(){ local LC_ALL=C i ch out=""; for((i=0;i<${#1};i++)){ ch="${1:i:1}"; case "$ch" in [a-zA-Z0-9.~_-]) out+="$ch";; *) printf -v out '%s%%%02X' "$out" "'$ch";; esac; }; printf '%s' "$out"; }
now_ms(){ awk 'BEGIN{printf "%.0f", systime()*1000}'; }
rand_letters(){ tr -dc 'A-Za-z' </dev/urandom | head -c 12; }
rand_lc_alnum(){ tr -dc 'a-z0-9' </dev/urandom | head -c 16; }
gen_uuid(){ if command -v uuidgen >/dev/null 2>&1; then uuidgen; else printf "%08x-%04x-%04x-%04x-%012x\n" $RANDOM$RANDOM $RANDOM $RANDOM $RANDOM $RANDOM$RANDOM$RANDOM; fi; }
map_transport(){ case "$1" in ws) echo ws;; httpupgrade) echo httpupgrade;; grpc) echo grpc;; xhttp) echo xhttp;; tcp|tcp_tls|reality|none) echo "$1";; *) echo "$1";; esac; }
ss2022_gen_userpsk() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -base64 16 | tr -d '\n'
  else
    # Fallback tanpa openssl (Python stdlib)
    python3 - <<'PY'
import os, base64, sys
sys.stdout.write(base64.b64encode(os.urandom(16)).decode())
PY
  fi
}

login(){ # login <base> <user> <pass> <cookie>
  local base="$1" user="$2" pass="$3" cookie="$4"
  local user_enc pass_enc
  user_enc=$(urlencode "$user"); pass_enc=$(urlencode "$pass")
  curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/x-www-form-urlencoded' \
       -X POST --data "username=$user_enc&password=$pass_enc&twoFactorCode=" \
       "${base}login" >/dev/null
  curl -sS -c "$cookie" -b "$cookie" "${base}panel/api/status" >/dev/null
  grep -Eq '(3x-ui|session)' "$cookie" || { echo "Login failed: no session cookie set" >&2; exit 1; }
}

# return: objek client (JSON) dari inbound.settings.clients yang email-nya persis $2
find_client_in_inbound_by_email() {
  # $1 = inbound JSON (1 objek dari array inbounds/list)
  # $2 = email
  jq -c --arg e "$2" '
    def mj: if type=="string" then (try fromjson catch {}) else . end;
    (.settings|mj).clients[] | select(.email==$e)
  ' <<<"$1" | head -n1
}

# echo expiryTime baru (ms)
calc_extended_expiry_ms() {
  # $1 = current_expiry_ms (angka, boleh 0)
  # $2 = days_to_add (angka hari)
  local now_ms base_ms days_ms
  now_ms="$(awk 'BEGIN{printf "%.0f", systime()*1000}')" || now_ms=0
  days_ms=$(( $2 * 86400000 ))
  if [[ ${1:-0} -gt "$now_ms" ]]; then
    base_ms="$1"
  else
    base_ms="$now_ms"
  fi
  echo $(( base_ms + days_ms ))
}

select_inbound(){ # <inbounds_json> <proto> <transport>
  local inbounds_json="$1" proto="$2" transport="$3" want_net picked
  want_net="$(map_transport "$transport")"
  picked="$(
    jq -c --arg p "$proto" --arg n "$want_net" '
      def mj: if type=="string" then (try fromjson catch {}) else . end;
      .[] | select((.protocol|ascii_downcase)==$p)
      | (.streamSettings|mj) as $ss
      | (.stream|mj)         as $st
      | {id, remark, protocol:(.protocol|ascii_downcase),
         net: ($ss.network // $st.network // ""),
         sec: ($ss.security // $st.security // "")}
      | select((.net|ascii_downcase)==$n)
    ' <<<"$inbounds_json" | head -n1
  )"
  [[ -z "$picked" ]] && { echo "Tidak menemukan inbound: proto=$proto transport=$transport" >&2; exit 3; }
  echo "$picked"
}

# build_settings <proto> <remark> <exp_days> <limitip> <transport> [quota_gb]
build_settings(){
  local proto="$1" remark="$2" exp_days="$3" limit_ip="$4" transport="$5" quota_gb="${6:-}"
  local expiry_ms total_bytes subid client_obj

  # waktu & kuota
  expiry_ms="$(now_ms)"; expiry_ms=$(( expiry_ms + exp_days*24*60*60*1000 ))
  total_bytes=0
  [[ -n "${quota_gb}" && "${quota_gb}" != "-" ]] && total_bytes=$(( quota_gb * 1024 * 1024 * 1024 ))
  subid="$(rand_lc_alnum)"

  # normalisasi transport ke lowercase
  local _transport_lc; _transport_lc="$(printf '%s' "$transport" | tr 'A-Z' 'a-z')"

  case "$proto" in
    trojan)
      local pw; pw="$(rand_letters)"
      client_obj="$(
        jq -nc \
          --arg password "$pw" \
          --arg email "$remark" \
          --argjson exp   "$expiry_ms" \
          --argjson total "$total_bytes" \
          --argjson lim   "$limit_ip" \
          '{
            password:  $password,
            email:     $email,
            limitIp:   $lim,
            totalGB:   $total,
            expiryTime:$exp,
            enable:    true,
            tgId:      "",
            subId:     "'"$subid"'",
            comment:   "",
            reset:     0
          }'
      )"
      ;;

    vless)
      local uuid; uuid="$(gen_uuid)"
      # isi flow xtls-rprx-vision hanya utk tcp/reality
      local flow_val=""
      case "$_transport_lc" in
        tcp|reality) flow_val="xtls-rprx-vision" ;;
      esac
      client_obj="$(
        jq -nc \
          --arg id    "$uuid" \
          --arg email "$remark" \
          --arg flow  "$flow_val" \
          --argjson exp   "$expiry_ms" \
          --argjson total "$total_bytes" \
          --argjson lim   "$limit_ip" \
          '{
            id:         $id,
            security:   "",
            password:   "",
            flow:       $flow,
            email:      $email,
            limitIp:    $lim,
            totalGB:    $total,
            expiryTime: $exp,
            enable:     true,
            tgId:       0,
            subId:      "'"$subid"'",
            comment:    "",
            reset:      0,
            created_at: (now|floor*1000),
            updated_at: (now|floor*1000)
          }'
      )"
      ;;

    vmess)
      local uuid; uuid="$(gen_uuid)"
      client_obj="$(
        jq -nc \
          --arg id    "$uuid" \
          --arg email "$remark" \
          --argjson exp   "$expiry_ms" \
          --argjson total "$total_bytes" \
          --argjson lim   "$limit_ip" \
          '{
            id:         $id,
            security:   "auto",
            password:   "",
            flow:       "",
            email:      $email,
            limitIp:    $lim,
            totalGB:    $total,
            expiryTime: $exp,
            enable:     true,
            tgId:       0,
            subId:      "'"$subid"'",
            comment:    "",
            reset:      0,
            created_at: (now|floor*1000),
            updated_at: (now|floor*1000)
          }'
      )"
      ;;

    shadowsocks|shadowsocks2022)
      # sesuaikan kalau kamu pakai skema server_psk/user_psk
      local cli_key; cli_key="$(ss2022_gen_userpsk)"
      client_obj="$(
        jq -nc \
          --arg email "$remark" \
          --arg method "" \
          --arg password "$cli_key" \
          --argjson exp   "$expiry_ms" \
          --argjson total "$total_bytes" \
          --argjson lim   "$limit_ip" \
          '{
            id:         "",
            security:   "",
            email:      $email,
            method:     $method,
            password:   $password,
            limitIp:    $lim,
            totalGB:    $total,
            expiryTime: $exp,
            enable:     true,
            tgId:       0,
            subId:      "'"$subid"'",
            comment:    "",
            reset:      0,
            created_at: (now|floor*1000),
            updated_at: (now|floor*1000)
          }'
      )"
      ;;
  esac

  jq -c --argjson c "$client_obj" -n '{clients:[$c]}'
}


# --- LIST CLIENTS ----------------------------------------------------------
cmd_list() {
  local as_json=false
  if [[ "${1:-}" == "--json" ]]; then as_json=true; shift; fi

  # ==== config ====
  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" "127.0.0.1")"
  api_port="$(read_file "$API_PORT_FILE" "2053")"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" "/")")"
  user="$(read_file "$USER_FILE")"
  pass="$(read_file "$PASS_FILE")"
  [[ -z "$user" || -z "$pass" ]] && { echo "Auth not set (username/password)"; return 1; }

  local BASE="http://$api_host:$api_port$webbase"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"
  trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN

  # ==== login ====
  login "$BASE" "$user" "$pass" "$COOKIE"

  # ==== fetch inbounds ====
  local raw_inbounds
  raw_inbounds="$(
    curl -sS -b "$COOKIE" -H 'Accept: application/json' \
      "${BASE}panel/api/inbounds/list" | jq -c '.obj // .'
  )"
  [[ -z "$raw_inbounds" || "$raw_inbounds" == "null" ]] && {
    echo "Gagal mengambil data inbounds"; return 2;
  }

  local now_ms; now_ms="$(awk 'BEGIN{printf "%.0f", systime()*1000}')"

  # ==== build JSON_FINAL (filtered: skip TLS/nTLS & email mengandung "admin") ====
  local JSON_FINAL
  JSON_FINAL="$(
    jq -c --argjson now "$now_ms" '
      def mj: if type=="string" then (try fromjson catch {}) else . end;
      def dleft(now; ms):
        if (ms|type)!="number" or ms==0 then null
        else (((ms - now)/86400000|floor) | if .<0 then 0 else . end)
        end;

      . as $inb_all
      | [
          $inb_all[]
          | select((.remark // "") as $r | ($r != "TLS" and $r != "nTLS"))
          | . as $inb
          | ($inb.settings|mj).clients[]? as $c
          | select((($c.email // "")|test("admin"; "i")|not))
          | ($inb.clientStats // []) | map(select(.email==($c.email // ""))) | first as $s
          | {
              inbound_id:   $inb.id,
              remark:       ($inb.remark // ""),
              protocol:     ($inb.protocol // ""),
              email:        ($c.email // ""),
              id:           ($c.id // ""),
	      enable:
  			    ( if ($c.enable|type)=="boolean" then $c.enable
    			     elif ($s.enable|type)=="boolean" then $s.enable
    			     else true end
			     ),
	      expiry_ms:    ($c.expiryTime // 0),
              days_left:    dleft($now; ($c.expiryTime // 0)),
              limitIp:      ($c.limitIp // 0),
              totalGB:      ($c.totalGB // 0),
              subId:        ($c.subId // ""),
              lastOnline:   ($s.lastOnline // 0),
              up:           ($s.up // 0),
              down:         ($s.down // 0),
              allTime:      ($s.allTime // 0)
            }
	      | . as $x
	      | ($x.up + $x.down) as $used
	      | ( if ($x.totalGB|type)=="number" then
      		( if $x.totalGB >= 1073741824
        	then $x.totalGB
        	else ($x.totalGB * 1073741824)
        	end )
    		else 0 end ) as $quota_bytes
	      | ($quota_bytes - $used) as $remain
              | . + {
 	        used: $used,
	        quota_bytes: (if $quota_bytes < 0 then 0 else $quota_bytes end),
	        remain_bytes: (if ($remain|type)=="number" and $remain>=0 then $remain else 0 end),
	        used_pct: (if $quota_bytes>0 then (($used*100.0)/$quota_bytes) else null end)
	  }
        ] as $clients
      | {
          ok: true,
          filters: { email:"", proto:"", soon_days:0, skip_admin:true, skip_tls_ntls:true },
          matched_clients: ($clients|length),
          now_ms: $now,
          clients: $clients
        }
    ' <<<"$raw_inbounds"
  )"

  if $as_json; then
    echo "$JSON_FINAL"
    return
  fi

  # ==== pretty table ====
  echo "$JSON_FINAL" | jq -r '
    # human bytes, base 1024
    def hbytes(n):
      if (n|type)!="number" then "-" else
      if n < 1024 then "\(n)B"
      elif n < (1024*1024) then "\((n/1024)|floor)KB"
      elif n < (1024*1024*1024) then "\((n/(1024*1024))|floor)MB"
      elif n < (1024*1024*1024*1024) then "\((n/(1024*1024*1024))|floor)GB"
      else "\((n/(1024*1024*1024*1024))|floor)TB" end end;

    def ago(now_ms; t_ms):
      if (t_ms|type)!="number" or t_ms==0 then "-"
      else
        ((now_ms - t_ms)/1000 | floor) as $s |
        if   $s < 60 then "\($s)s"
        elif $s < 3600 then "\(($s/60)|floor)m"
        elif $s < 86400 then "\(($s/3600)|floor)h"
        else "\(($s/86400)|floor)d" end
      end;

    def date_ymd(ms):
      if (ms|type)!="number" or ms==0 then "∞"
      else ((ms/1000|floor) | strftime("%Y-%m-%d")) end;

    .now_ms as $now
    | ["INB","REMARK","PROTO","EMAIL","EN","LIM","ALLTIME","EXPIRY","LEFT","LAST","UP","DOWN","USED","QUOTA"]
    , ( .clients[]
        | [
            (.inbound_id|tostring),
            .remark,
            .protocol,
            .email,
            (if .enable then "✓" else "✗" end),
            (.limitIp // 0 | tostring),
            hbytes(.allTime),
            (date_ymd(.expiry_ms)),
            (if (.days_left == null) then "-" else (.days_left|tostring) end),
            (ago($now; .lastOnline)),
            hbytes(.up),
            hbytes(.down),
            hbytes(.used),
            (if (.quota_bytes // 0) == 0 then "-" else hbytes(.quota_bytes) end)
          ]
      )
    | @tsv
  ' | column -t -s $'\t'
}

cmd_add(){ # add <remark> <exp_days> <proto> <transport> [quota_gb] [limitip_override] [inbound_id_override]
  local remark="$1" exp_days="$2" proto_raw="$3" trans_raw="$4"; shift 4 || true
  local quota_gb="${1:-}" limit_ip_opt="${2:-}" inbound_id_override="${3:-}"
  local proto trans; proto="$(norm_proto "$proto_raw")"; trans="$(norm_transport "$trans_raw")"

  local api_host api_port webbase user pass public_domain limitip_default ss2022_serverpsk
  api_host="$(read_file "$API_HOST_FILE" "127.0.0.1")"
  api_port="$(read_file "$API_PORT_FILE" "2053")"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" "/")")"
  user="$(read_file "$USER_FILE")"; pass="$(read_file "$PASS_FILE")"
  public_domain="$(read_file "$DOMAIN_FILE" "")"
  limitip_default="$(read_file "$LIMITIP_FILE" "0")"
  ss2022_serverpsk="$(read_file "$SS2022_SERVER_PSK_FILE" "")"

  [[ -n "$user" ]] || { echo "username kosong" >&2; exit 2; }
  [[ -n "$pass" ]] || { echo "password kosong" >&2; exit 2; }
  [[ "$exp_days" =~ ^[0-9]+$ ]] || { echo "exp_days harus angka" >&2; exit 2; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="/tmp/3ling_cookie.$$"
  login "$BASE" "$user" "$pass" "$COOKIE"

  local inbounds; inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"

  local inbound_id inbound_remark picked
  if [[ -n "${inbound_id_override:-}" ]]; then
    inbound_id="$inbound_id_override"
    inbound_remark="$(jq -r --argjson id "$inbound_id" '.[]|select(.id==$id)|.remark' <<<"$inbounds")"
    [[ -z "$inbound_remark" ]] && { echo "Inbound id=$inbound_id tidak ditemukan" >&2; exit 3; }
  else
    picked="$(select_inbound "$inbounds" "$proto" "$trans")"
    inbound_id="$(jq -r '.id' <<<"$picked")"
    inbound_remark="$(jq -r '.remark // ""' <<<"$picked")"
  fi

  local limit_ip; limit_ip="$(read_file "$LIMITIP_FILE" "0")"
  [[ -n "${limit_ip_opt:-}" ]] && limit_ip="$limit_ip_opt"

  if jq -e --arg email "$remark" --argjson id "$inbound_id" '
      .[] | select(.id==$id)
      | (.settings|try fromjson catch {}) as $s
      | ($s.clients // [])[] | select((.email//"")==$email)
    ' <<<"$inbounds" >/dev/null; then
    local existing_client; existing_client="$(
      jq -c --arg email "$remark" --argjson id "$inbound_id" '
        .[] | select(.id==$id)
        | (.settings|try fromjson catch {}) as $s
        | ($s.clients // [])[] | select((.email//"")==$email)
      ' <<<"$inbounds"
    )"
    local ss_combined=""; [[ "$proto" == "shadowsocks" ]] && ss_combined="$(read_file "$SS2022_SERVER_PSK_FILE" "")"
    jq -nc --argjson ok true --arg api_base "$BASE" --arg public_domain "$public_domain" \
      --arg proto "$proto" --arg trans "$trans" --argjson inbound_id "$inbound_id" \
      --arg inbound_remark "$inbound_remark" --argjson client "$existing_client" \
      --arg ss_combined "$ss_combined" '
      def base:{ok:$ok,api_base:$api_base,public_domain:$public_domain,inbound_id:$inbound_id,
                inbound_remark:$inbound_remark,protocol:$proto,transport:$trans,client:$client};
      base + (if ($proto=="shadowsocks") and ($ss_combined|length>0) then {ss2022_combined_psk:$ss_combined} else {} end)
    '
    return 0
  fi

  local settings_json; settings_json="$(build_settings "$proto" "$remark" "$exp_days" "$limit_ip" "$trans" "${quota_gb:-}")"
  local resp; resp="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' -F "id=${inbound_id}" -F "settings=${settings_json}" "${BASE}panel/api/inbounds/addClient")"
  if ! jq -e '.success' <<<"$resp" >/dev/null; then
    resp="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' --data-urlencode "id=${inbound_id}" --data-urlencode "settings=${settings_json}" "${BASE}panel/api/inbounds/addClient")"
  fi
  local ok; ok="$(jq -r '.success // false' <<<"$resp")"
  local ss_combined=""; [[ "$proto" == "shadowsocks" || "$proto" == "shadowsocks2022" ]] && ss_combined="$(read_file "$SS2022_SERVER_PSK_FILE" "")"
  jq -nc --argjson ok "$ok" --arg api_base "$BASE" --arg public_domain "$public_domain" \
    --arg proto "$proto" --arg trans "$trans" --argjson inbound_id "$inbound_id" \
    --arg inbound_remark "$inbound_remark" --argjson client "$settings_json" \
    --arg ss_combined "$ss_combined" '
    def base:{ok:$ok,api_base:$api_base,public_domain:$public_domain,inbound_id:$inbound_id,
              inbound_remark:$inbound_remark,protocol:$proto,transport:$trans,client:$client};
    base + (if ($proto=="shadowsocks") and ($ss_combined|length>0) then {ss2022_combined_psk:$ss_combined} else {} end)
  ' | jq -c .
}

cmd_delete(){ # delete <email> [inbound_id_override]
  local email="$1"; shift || true
  local inbound_id_override="${1:-}"

  # config & login
  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" "127.0.0.1")"
  api_port="$(read_file "$API_PORT_FILE" "2053")"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" "/")")"
  user="$(read_file "$USER_FILE")"; pass="$(read_file "$PASS_FILE")"
  [[ -n "$user" ]] || { echo "username kosong" >&2; exit 2; }
  [[ -n "$pass" ]] || { echo "password kosong" >&2; exit 2; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="/tmp/3ling_cookie.$$"
  login "$BASE" "$user" "$pass" "$COOKIE"

  # ambil semua inbounds
  local inbounds; inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' \
                    "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"

  # tentukan target inbound IDs
  local target_ids
  if [[ -n "${inbound_id_override:-}" ]]; then
    # pakai 1 inbound (validasi ada)
    local okid
    okid="$(jq -r --argjson id "$inbound_id_override" \
            'any(.[]; .id==$id)' <<<"$inbounds")"
    [[ "$okid" != "true" ]] && { echo "Inbound id=${inbound_id_override} tidak ditemukan" >&2; exit 3; }
    target_ids="$inbound_id_override"
  else
    # cari semua inbound yang mengandung email tsb
    target_ids="$(jq -r --arg email "$email" '
      .[] as $i
      | ($i.settings|try fromjson catch {}) as $s
      | ($s.clients // []) | map(.email // "") | index($email)
      | if . != null then $i.id else empty end
    ' <<<"$inbounds" | paste -sd ' ' -)"
  fi

  if [[ -z "${target_ids:-}" ]]; then
    jq -nc --arg email "$email" '{ok:true, deleted:false, status:"not_found_any_inbound", email:$email, affected:[]}'
    return 0
  fi

  # call endpoint by-email untuk tiap inbound
  local results='[]' id enc_email
  enc_email="$(urlencode "$email")"
  for id in $target_ids; do
    # POST /panel/api/inbounds/{id}/delClientByEmail/{email}
    local url resp ok msg
    url="${BASE}panel/api/inbounds/${id}/delClientByEmail/${enc_email}"
    resp="$(curl -sS -b "$COOKIE" -X POST -H 'Accept: application/json' "$url" || true)"
    if jq -e '.success' <<<"$resp" >/dev/null 2>&1; then
      ok="$(jq -r '.success|tostring' <<<"$resp")"
      msg="$(jq -r '.msg // ""' <<<"$resp")"
    else
      ok="false"
      msg="no_success_field_or_http_err"
    fi
    results="$(jq -c --argjson id "$id" --arg ok "$ok" --arg msg "$msg" \
      '. + [{inbound_id:$id, ok:($ok=="true"), msg:$msg}]' <<<"$results")"
  done

  # rangkum
  local ok_count
  ok_count="$(jq '[.[]|select(.ok==true)]|length' <<<"$results")"
  jq -nc --arg email "$email" --argjson details "$results" --argjson okc "$ok_count" '
    {ok: ($okc>0), email:$email, deleted_count:$okc, details:$details}
  '
}

cmd_extend() {
  local email="$1" add_days="$2"
  [[ -z "${email:-}" ]] && { echo "email kosong" >&2; return 2; }
  [[ -z "${add_days:-}" || ! "$add_days" =~ ^[0-9]+$ ]] && { echo "hari harus angka" >&2; return 2; }

  # --- load config & login
  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" "127.0.0.1" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  api_port="$(read_file "$API_PORT_FILE" "2053"       | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" "/" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')")"
  user="$(read_file "$USER_FILE"   | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  pass="$(read_file "$PASS_FILE"   | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  [[ -z "$user" || -z "$pass" ]] && { echo "username/password kosong" >&2; return 2; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"
  login "$BASE" "$user" "$pass" "$COOKIE"

  # --- ambil daftar inbound
  local inbounds
  inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
  [[ -z "${inbounds:-}" || "${inbounds:-}" == "null" ]] && {
    jq -nc --arg email "$email" --argjson days "$add_days" \
      '{ok:false,email:$email,extended_days:$days,matched_inbounds:0,updated:0,skipped:0}'
    return 0
  }

  # --- waktu
  local now_ms days_ms; now_ms="$(awk 'BEGIN{printf "%.0f", systime()*1000}')" || now_ms=0
  days_ms=$(( add_days * 24 * 60 * 60 * 1000 ))

  # --- counters
  local matched=0 updated=0 skipped=0

  # --- loop inbound
  while IFS= read -r inb; do
    # cari client dengan email target di inbound ini
    local cli
    cli="$(jq -c --arg e "$email" '
      def mj: if type=="string" then (try fromjson catch {}) else . end;
      (.settings|mj).clients[]? | select(.email==$e)
    ' <<<"$inb" | head -n1)"

    [[ -z "$cli" ]] && continue   # nggak ada di inbound ini

    matched=$((matched+1))

    local inb_id client_id cur_exp base_ms new_exp
    inb_id="$(jq -r '.id' <<<"$inb")"
    client_id="$(jq -r '.id // empty' <<<"$cli")"  # beberapa protokol tidak ada .id

    cur_exp="$(jq -r '.expiryTime // 0' <<<"$cli")"
    [[ "$cur_exp" == "null" || -z "$cur_exp" ]] && cur_exp=0

    if (( cur_exp > now_ms )); then base_ms="$cur_exp"; else base_ms="$now_ms"; fi
    new_exp=$(( base_ms + days_ms ))

    if [[ -z "$client_id" ]]; then
      # tidak bisa pakai updateClient/{clientId}
      skipped=$((skipped+1))
      continue
    fi

    # rakit payload: settings harus string JSON dg field "clients" hanya berisi client yg diupdate
    local settings_json payload resp
    settings_json="$(jq -nc --argjson c "$cli" --argjson exp "$new_exp" '
      ($c|.expiryTime=$exp) | {clients:[.]}
    ')"
    payload="$(jq -nc --argjson id "$inb_id" --arg settings "$settings_json" '{id:$id, settings:$settings}')"

    resp="$(curl -sS -b "$COOKIE" \
      -H 'Accept: application/json' -H 'Content-Type: application/json' \
      -X POST "${BASE}panel/api/inbounds/updateClient/${client_id}" \
      --data "$payload")"

    if jq -e '.success==true' >/dev/null 2>&1 <<<"$resp"; then
      updated=$((updated+1))
    else
      skipped=$((skipped+1))
    fi
  done < <(jq -c '.[]' <<<"$inbounds")

  # --- hasil ringkas
  local ok=false
  if (( matched>0 && updated>0 )); then ok=true; fi

  jq -nc --arg email "$email" --argjson days "$add_days" \
        --argjson matched "$matched" --argjson updated "$updated" --argjson skipped "$skipped" \
        --arg okstr "$ok" \
        '{ok:($okstr=="true"), email:$email, extended_days:$days, matched_inbounds:$matched, updated:$updated, skipped:$skipped}'
}

cmd_show() {
  local email="${1:-}"; [[ -z "$email" ]] && { usage; exit 2; }

  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" 127.0.0.1)"
  api_port="$(read_file "$API_PORT_FILE" 2053)"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" /)")"
  user="$(read_file "$USER_FILE")"
  pass="$(read_file "$PASS_FILE")"
  [[ -z "$user" || -z "$pass" ]] && { echo "login credential not set"; exit 1; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"; trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN
  login "$BASE" "$user" "$pass" "$COOKIE"
  curl -sS -c "$COOKIE" -b "$COOKIE" "${BASE%/}/panel/api/status" >/dev/null
  grep -Eq '(3x-ui|session)' "$COOKIE" || { echo "login failed"; exit 1; }

  local raw_inbounds
  raw_inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE%/}/panel/api/inbounds/list" | jq -c '.obj // .')"
  [[ -z "$raw_inbounds" || "$raw_inbounds" = "null" ]] && { echo "no inbounds"; exit 1; }

  local now_ms; now_ms="$(awk 'BEGIN{printf "%.0f", systime()*1000}')"

  local out
  out="$(
    jq -r --arg email "$email" --argjson now "$now_ms" '
      def mj: if type=="string" then (try fromjson catch {}) else . end;

      def hbytes(n):
        if (n|type)!="number" then "-" else
        if n < 1024 then "\(n)B"
        elif n < (1024*1024) then "\((n/1024)|floor)KB"
        elif n < (1024*1024*1024) then "\((n/(1024*1024))|floor)MB"
        elif n < (1024*1024*1024*1024) then "\((n/(1024*1024*1024))|floor)GB"
        else "\((n/(1024*1024*1024*1024))|floor)TB" end end;

      def date_ymd(ms):
        if (ms|type)!="number" or ms==0 then "∞"
        else ((ms/1000|floor) | strftime("%Y-%m-%d")) end;

      def dleft(now; ms):
        if (ms|type)!="number" or ms==0 then null
        else (((ms - now)/86400000|floor) | if .<0 then 0 else . end)
        end;

      def ago(now_ms; t_ms):
        if (t_ms|type)!="number" or t_ms==0 then "-"
        else
          ((now_ms - t_ms)/1000 | floor) as $s |
          if   $s < 60 then "\($s)s"
          elif $s < 3600 then "\(($s/60)|floor)m"
          elif $s < 86400 then "\(($s/3600)|floor)h"
          else "\(($s/86400)|floor)d" end
        end;

      . as $inb_all
      | (
          $inb_all[]
          | . as $inb
          | ($inb.settings|mj).clients[]? as $c
          | select(($c.email // "") == $email)
          | ($inb.clientStats // []) | map(select(.email==($c.email // ""))) | first as $s
          | {
              inb:        ($inb.id),
              remark:     ($inb.remark // ""),
              proto:      ($inb.protocol // ""),
              email:      ($c.email // ""),
              enable:     ($c.enable // true),
              limitIp:    ($c.limitIp // 0),
              totalGB:    ($c.totalGB // 0),
              subId:      ($c.subId // ""),
              uuid:       ($c.id // ($s.uuid // "")),
              password:   ($c.password // ""),         # << ambil password kalau ada (trojan/ss)
              expiry_ms:  ($c.expiryTime // 0),
              days_left:  dleft($now; ($c.expiryTime // 0)),
              lastOnline: ($s.lastOnline // 0),
              up:         ($s.up // 0),
              down:       ($s.down // 0),
              allTime:    ($s.allTime // 0)
            }
        ) as $x
      | if ($x|type)=="null"
        then "NOTFOUND"
        else
          ($x.totalGB // 0) as $tg
          | ( if ($tg|type)=="number" then ( if $tg >= 1073741824 then $tg else ($tg*1073741824) end ) else 0 end ) as $quota_bytes
          | [
              ["EMAIL",       $x.email],
              ["EN",          (if $x.enable then "✓" else "✗" end)],
              ["INBOUND",     ("\($x.inb)  \($x.remark) (\($x.proto))")],
              # UUID vs Password tergantung protokol
              [
                (if ($x.proto=="trojan" or $x.proto=="shadowsocks") then "Password" else "UUID" end),
                (if ($x.proto=="trojan" or $x.proto=="shadowsocks")
                   then (if ($x.password//"")=="" then "-" else $x.password end)
                   else (if ($x.uuid//"")=="" then "-" else $x.uuid end)
                 end)
              ],
              ["SubID",       (if ($x.subId//"") == "" then "-" else $x.subId end)],
              ["Limit IP",    ($x.limitIp|tostring)],
              ["TotalGB",     (if $quota_bytes==0 then "-" else hbytes($quota_bytes) end)],
              ["Expiry",      (if $x.expiry_ms==0 then "∞" else (date_ymd($x.expiry_ms)+"  ("+ ( ($x.days_left//"-")|tostring)+"d)") end)],
              ["Last Online", (ago($now; $x.lastOnline))],
              ["Traffic",     ("Up "+hbytes($x.up)+"  Down "+hbytes($x.down)+"  All "+hbytes($x.allTime))]
            ]
            | (.[]
               | @tsv)
        end
    ' <<<"$raw_inbounds"
  )"

  if [[ "$out" == "NOTFOUND" || -z "$out" ]]; then
    echo "client not found: $email" >&2
    exit 3
  fi

  echo "$out" | column -t -s $'\t'
}

# --- toggle enable/disable client by email ------------------------------------

cmd_enable()  { _toggle_client_enabled "$1" true;  }
cmd_disable() { _toggle_client_enabled "$1" false; }

_toggle_client_enabled() {
  set -euo pipefail
  local email="${1:-}"; local state="${2:-true}"
  [[ -z "$email" ]] && { echo "Usage: $0 ${state:true?enable:disable} <email>"; exit 2; }

  # read config (reuse your helpers)
  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" 127.0.0.1)"
  api_port="$(read_file "$API_PORT_FILE" 2053)"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" /)")"
  user="$(read_file "$USER_FILE")"
  pass="$(read_file "$PASS_FILE")"
  [[ -z "${user}" || -z "${pass}" ]] && { echo "missing panel credentials"; exit 1; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"
  trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN

  login "$BASE" "$user" "$pass" "$COOKIE" >/dev/null

  # pull all inbounds
  local inbounds
  inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
  [[ -z "$inbounds" || "$inbounds" = "null" ]] && { echo "failed to fetch inbounds"; exit 1; }

  # build payload untuk inbound yang berisi email yang dicari + toggle flag
  local upd
  upd="$(jq -c --arg email "$email" --argjson state "$state" '
    # helper parse settings JSON
    def mj: if type=="string" then (try fromjson catch {}) else . end;

    # pilih inbound yang punya client dg email tepat
    (.[] | select((.settings|mj).clients[]? | select((.email // "") == $email))) as $inb
    | if ($inb|type)=="null" then empty else
        ($inb.settings|mj) as $set
        | ($set.clients // []) as $cs
        | ($cs | map( if (.email // "") == $email then (.enable = $state) else . end)) as $cs2
        | ($set + {clients:$cs2}) as $set2
        | {
            id: $inb.id,
            up: ($inb.up // 0),
            down: ($inb.down // 0),
            total: ($inb.total // 0),
            remark: ($inb.remark // ""),
            enable: ($inb.enable // true),
            expiryTime: ($inb.expiryTime // 0),
            listen: ($inb.listen // ""),
            port: ($inb.port),
            protocol: ($inb.protocol // ""),
            settings: ($set2 | tojson),
            streamSettings: ($inb.streamSettings // ""),
            sniffing: ($inb.sniffing // ""),
            tag: ($inb.tag // "")
          }
      end
  ' <<<"$inbounds")"

  [[ -z "$upd" ]] && { echo "email not found: $email"; exit 4; }

  # ambil id untuk URL
  local inb_id; inb_id="$(jq -r '.id' <<<"$upd")"

  # update ke panel
  local resp
  resp="$(curl -sS -b "$COOKIE" -H 'Content-Type: application/json' \
          -X POST "${BASE}panel/api/inbounds/update/$inb_id" \
          --data "$upd")"

  # cek hasil
  local ok; ok="$(jq -r '.success // .ok // false' <<<"$resp")"
  if [[ "$ok" == "true" ]]; then
    local mark; mark=$([[ "$state" == "true" ]] && echo "enabled" || echo "disabled")
    # tampilkan ringkas + versi JSON untuk keperluan scripting
    echo "{\"ok\":true,\"email\":\"$email\",\"state\":\"$mark\",\"inbound_id\":$inb_id}"
    printf "%-8s %s\n" "EMAIL"   "$email"
    printf "%-8s %s\n" "STATE"   "$mark"
    printf "%-8s %s\n" "INBOUND" "$inb_id"
  else
    echo "update failed"; echo "$resp"
    exit 1
  fi
}
# --- ceklogin: list all or by email ------------------------------------------
cmd_ceklogin() {
  set -euo pipefail
  local sub="${1:-list}"
  local email="${2:-}"
  local api_host api_port webbase user pass

  api_host="$(read_file "$API_HOST_FILE" 127.0.0.1)"
  api_port="$(read_file "$API_PORT_FILE" 2053)"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE")")"
  user="$(read_file "$USER_FILE")"
  pass="$(read_file "$PASS_FILE")"
  [[ -z "${user}" || -z "${pass}" ]] && { echo "missing panel credentials"; exit 1; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"
  trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN
  login "$BASE" "$user" "$pass" "$COOKIE" >/dev/null

  # Pull inbounds
  local raw_inbounds
  raw_inbounds="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
  [[ -z "$raw_inbounds" || "$raw_inbounds" = "null" ]] && { echo "failed to fetch inbounds"; exit 1; }

  # Build candidates: skip TLS/nTLS & email admin*
  local clients
  clients="$(
    jq -c '
      def mj: if type=="string" then (try fromjson catch {}) else . end;
      . as $inb_all
      | [
          $inb_all[]
          | select((.remark // "") as $r | ($r != "TLS" and $r != "nTLS"))
          | . as $inb
          | ($inb.settings|mj).clients[]? as $c
          | select((($c.email // "") | test("^admin"; "i") | not))
          | ($inb.clientStats // []) | map(select(.email==($c.email // ""))) | first as $s
          | {
              inbound_id: ($inb.id),
              remark:     ($inb.remark // ""),
              protocol:   ($inb.protocol // ""),
              email:      ($c.email // ""),
              enable:     ($c.enable // true),
              lastOnline: ($s.lastOnline // 0)
            }
        ]' <<<"$raw_inbounds"
  )"

  # Filter by email when requested
  if [[ "$sub" != "list" ]]; then
    email="$sub"
    clients="$(jq -c --arg e "$email" '[ .[] | select(.email==$e) ]' <<<"$clients")"
  fi

  # Nothing to show?
  if [[ "$(jq 'length' <<<"$clients")" -eq 0 ]]; then
    if [[ "$sub" = "list" ]]; then
      echo "Tidak ada client."
    else
      echo "Email tidak ditemukan: $email"
    fi
    return 0
  fi

  # Pretty print each client
  local count=0
  while read -r row; do
    count=$((count+1))
    local cem cpr cproto cinb lms
    cem="$(jq -r '.email' <<<"$row")"
    cpr="$(jq -r '.remark' <<<"$row")"
    cproto="$(jq -r '.protocol' <<<"$row")"
    cinb="$(jq -r '.inbound_id' <<<"$row")"
    lms="$(jq -r '.lastOnline' <<<"$row")"

    # format lastOnline in WIB
    local last_str="-"
    if [[ "$lms" != "0" && "$lms" != "null" ]]; then
      last_str="$(TZ=Asia/Jakarta date -d "@$((lms/1000))" +"%Y-%m-%d %H:%M:%S" 2>/dev/null || true)"
    fi

    echo "${count}. User: ${cem} (Protocol: ${cproto})"
    printf "   Data login terakhir (WIB): %s\n" "${last_str:-"-"}"

    # --- fetch connected IPs for this email ---
    local ip_resp ips_json
    ip_resp="$(curl -sS -b "$COOKIE" -X POST "${BASE}panel/api/inbounds/clientIps/$cem" || true)"

    # --- Normalisasi ke array string (tahan format aneh dari API) ---
    ips_json="$(
      printf '%s' "$ip_resp" | jq -c '
        ( .ips // .obj // .data // [] ) as $x
        | if   ($x|type) == "string" then
            (try ($x|fromjson)
             catch ($x|split(",")|map(.|gsub("^\\s+|\\s+$";""))|map(select(length>0))))
          elif ($x|type) == "array" then
            $x
          else
            []
          end
        | map(tostring)
      ' 2>/dev/null || echo "[]"
    )"

    echo "   Connected IPs:"
    # Ambil ke array bash
    local ips=()
    # filter: hanya string non-kosong, IPv4/IPv6 sederhana
    while IFS= read -r _ip; do
      ips+=("$_ip")
    done < <(
      jq -r '
        map(select((type=="string") and (length>0)))
        | map(select(test("^([0-9]{1,3}\\.){3}[0-9]{1,3}$") or test(":")))
        | .[]
      ' <<<"$ips_json"
    )

    if [[ ${#ips[@]} -eq 0 ]]; then
      echo "      (none)"
    else
      local i=0
      for ip in "${ips[@]}"; do
        i=$((i+1))
        # ASN/ISP lookup (best-effort; jangan gagalkan output)
        local asninfo
        asninfo="$(_asn_lookup_ip "$ip" 2>/dev/null || true)"
        printf "      %d. %s\n" "$i" "$ip"
        printf "         ASN/ISP: %s\n" "${asninfo:-"-"}"
      done
    fi
  done < <(jq -c '.[]' <<<"$clients")
}

# --- helper: ASN/ISP lookup (best-effort, optional internet) ------------------
_asn_lookup_ip() {
  local ip="${1:-}"; [[ -z "$ip" ]] && { echo "-"; return 0; }

  # Prefer Team Cymru whois (structured)
  if command -v whois >/dev/null 2>&1; then
    local line
    line="$(whois -h whois.cymru.com " -v $ip" 2>/dev/null | awk 'NR==2{print}')"
    if [[ -n "${line:-}" ]]; then
      # Columns: AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name
      local asn asname
      asn="$(awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$1); print $1}' <<<"$line")"
      asname="$(awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$7); print $7}' <<<"$line")"
      if [[ -n "$asn" && -n "$asname" ]]; then
        echo "AS${asn} ${asname}"
        return 0
      fi
    fi
    # Fallback generic whois
    line="$(whois "$ip" 2>/dev/null | grep -Ei 'origin:|org-name:|OrgName:|descr:' | head -1 | sed 's/^[[:space:]]*//')"
    if [[ -n "${line:-}" ]]; then
      echo "${line#*: }"
      return 0
    fi
  fi

  # Last resort: ipinfo.org (jika internet ada)
  if command -v curl >/dev/null 2>&1; then
    local org
    org="$(curl -m 2 -s "https://ipinfo.io/$ip/org" || true)"
    if [[ -n "${org:-}" ]]; then
      echo "$org"
      return 0
    fi
  fi

  echo "-"
  return 0
}

cmd_purge() {
  set -euo pipefail
  local inbound_id="${1:-}"   # optional

  # baca config panel
  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" 127.0.0.1)"
  api_port="$(read_file "$API_PORT_FILE" 2053)"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE")")"
  user="$(read_file "$USER_FILE")"
  pass="$(read_file "$PASS_FILE")"
  [[ -z "${user}" || -z "${pass}" ]] && { echo "missing panel credentials"; exit 1; }

  local BASE="http://${api_host}:${api_port}${webbase}"

  # login (pakai cookie temp)
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"
  trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN
  login "$BASE" "$user" "$pass" "$COOKIE" >/dev/null

  # siapkan URL: dengan atau tanpa inbound_id
  local url="${BASE}panel/api/inbounds/delDepletedClients"
  if [[ -n "${inbound_id}" && "${inbound_id}" != "all" ]]; then
    # optional sanity check numeric
    if ! [[ "$inbound_id" =~ ^[0-9]+$ ]]; then
      echo "invalid inbound_id: $inbound_id"; exit 2
    fi
    url="${url}/${inbound_id}"
  fi

  # eksekusi
  local resp success msg
  resp="$(curl -sS -b "$COOKIE" -X POST -H 'Accept: application/json' "$url" || true)"
  success="$(jq -r '.success // false' <<<"$resp" 2>/dev/null || echo "false")"
  msg="$(jq -r '.msg // ""' <<<"$resp" 2>/dev/null || echo "")"

  if [[ "$success" == "true" ]]; then
    if [[ -n "${inbound_id:-}" && "${inbound_id}" != "all" ]]; then
      printf "OK: depleted clients pada inbound %s berhasil dihapus." "$inbound_id"; echo
    else
      printf "OK: depleted clients pada SEMUA inbound berhasil dihapus."; echo
    fi
    [[ -n "$msg" ]] && echo "$msg"
  else
    echo "Gagal menghapus depleted clients."
    [[ -n "$msg" ]] && echo "Reason: $msg"
    # tampilkan payload mentah untuk debug
    [[ -n "$resp" ]] && echo "$resp"
    exit 1
  fi
}
# --- Helpers khusus edit ------------------------------------------------------
# Bungkus JSON client jadi bentuk settings yang diharapkan API
# Generate email sementara yang pasti unik (berbasis timestamp+rand)
_tmp_email() {
  printf "tmp-%s-%04x@%s\n" "$(date +%s%3N)" "$((RANDOM&0xffff))" "migr.local"
}
_api_delete_client_by_clientId() { # <clientId> <inbound_id>
  local clientId="$1" inb_id="$2"
  local url="${BASE}panel/api/inbounds/${inb_id}/delClient/${clientId}"

  local resp=""

  # 1) POST tanpa body (sesuai spec baru)
  resp="$(curl -sS -X POST -b "$COOKIE" -H 'Accept: application/json' "$url" || true)"

  # 2) fallback x-www-form-urlencoded (sebagian fork masih minta id di body)
  if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$resp"; then
    resp="$(curl -sS -X POST -b "$COOKIE" -H 'Accept: application/json' \
             --data-urlencode "id=${inb_id}" "$url" || true)"
  fi

  # 3) fallback multipart
  if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$resp"; then
    resp="$(curl -sS -X POST -b "$COOKIE" -H 'Accept: application/json' \
             -F "id=${inb_id}" "$url" || true)"
  fi

  # 4) kalau masih kosong, kembalikan JSON error konsisten
  [[ -z "$resp" ]] && resp='{"success":false,"msg":"Empty response from delClient","obj":null}'

  printf '%s' "$resp"
}
_settings_wrap() {
  # stdin: client_json  (atau lewat arg1)
  # stdout: {"clients":[{...}]}
  local c_json="${1:-}"
  [[ -z "$c_json" ]] && c_json="$(cat)"
  jq -c --argjson c "$c_json" -n '{clients:[$c]}'
}

# Tambah client ke inbound tertentu
# usage: _api_add_client <inbound_id> <client_json>
_api_add_client() {
  local id="$1" client_json="$2"
  local settings
  settings="$(_settings_wrap "$client_json")"

  # coba kirim pakai multipart (-F), beberapa versi 3x-ui lebih suka ini
  local resp
  resp="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' \
            -F "id=${id}" -F "settings=${settings}" \
            "${BASE}panel/api/inbounds/addClient" || true)"

  # fallback ke x-www-form-urlencoded kalau multipart gagal / kosong
  if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$resp"; then
    resp="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' \
              --data-urlencode "id=${id}" \
              --data-urlencode "settings=${settings}" \
              "${BASE}panel/api/inbounds/addClient" || true)"
  fi

  printf '%s' "$resp"
}

# Hapus client berdasarkan clientId (mapping:
#   VMESS/VLESS → client.id, TROJAN → client.password, SS → client.email)
# API: POST /panel/api/inbounds/delClient/:clientId  body: { id: <inbound_id> }
# usage: _api_delete_client_by_clientId <clientId> <inbound_id>
_is_present_by_cid_in_inb() { # <inb_json> <inb_id> <clientId>
  local inb_json="$1" inb_id="$2" cid="$3"
  jq -e --argjson id "$inb_id" --arg cid "$cid" '
    def mj: if type=="string" then (try fromjson catch {}) else . end;
    any(.[]; .id==$id and (
      (.settings|mj|.clients // [])[]? as $c |
      ($c.id // $c.password // "") == $cid
    ))
  ' >/dev/null 2>&1 <<<"$inb_json"
}
_parse_exp_ms(){ # <val> -> stdout: milliseconds; return!=0 kalau invalid
  local v="$1"
  if [[ "$v" == "-" || "$v" == "0" ]]; then
    echo 0; return 0
  elif [[ "$v" =~ ^[0-9]+$ ]]; then
    # input dalam hari
    local now_s days
    now_s="$(date +%s)"
    days="$v"
    echo $(( (now_s + days*86400) * 1000 )); return 0
  else
    # coba parse absolut (YYYY-MM-DD[,THH:MM])
    local s
    if ! s="$(date -d "$v" +%s 2>/dev/null)"; then
      return 1
    fi
    echo $(( s * 1000 )); return 0
  fi
}

# Temukan client + inboundnya
_find_client() { # out: JSON {inb, client, protocol, transport}
  local inb_json="$1" email="$2"
  jq -c --arg e "$email" '
    def mj: if type=="string" then (try fromjson catch {}) else . end;
    [ .[] as $inb
      | ($inb.settings|mj) as $s
      | ($s.clients // [])[]? as $c
      | select((($c.email // "") == $e))
      | {
          inb: { id: $inb.id, remark: ($inb.remark // ""), protocol: ($inb.protocol // ""),
                 stream: ($inb.streamSettings // $inb.ss // null) },
          client: $c,
          protocol: ($inb.protocol // ""),
          transport: (
            try ( ($inb.streamSettings // $inb.ss // {} | .network) )
            catch null
          )
        }
    ] | .[0] // {}' <<<"$inb_json"
}
# Kembalikan semua record dengan email tsb.
# out: JSON array [{inb:{id,remark,protocol,stream}, client:{...}, protocol, transport, clientId, cred}]
# Kembalikan SEMUA record dengan email tsb.
_find_clients_all() { # <inb_json> <email>
  local inb_json="$1" email="$2"
  jq -c --arg e "$email" '
    def mj: if type=="string" then (try fromjson catch {}) else . end;
    [ .[] as $inb
      | ($inb.settings|mj) as $s
      | ($s.clients // [])[]? as $c
      | select((($c.email // "") == $e))
      | {
          inb: { id: $inb.id, remark: ($inb.remark // ""), protocol: ($inb.protocol // ""),
                 stream: ($inb.streamSettings // $inb.ss // null) },
          client: $c,
          protocol: ($inb.protocol // ""),
          transport: (try ( ($inb.streamSettings // $inb.ss // {} | .network) ) catch null),
	clientId: (
  	if      ($inb.protocol // "") == "vless" or ($inb.protocol // "") == "vmess"
  		then $c.id // ""
  		elif    ($inb.protocol // "") == "trojan"
  		then $c.password // ""
  		else
       		$c.email // ""
  	end
	),
	cred: (
  		if      ($inb.protocol // "") == "vless" or ($inb.protocol // "") == "vmess"
  		then $c.id // ""
  		elif    ($inb.protocol // "") == "trojan"
  		then $c.password // ""
  		else
       		$c.password // ""
  		end
	   )
        }
    ]' <<<"$inb_json"
}
# clientId mapping API: VMESS/VLESS=client.id, TROJAN=client.password, SS2022=client.email
_client_id_for() { # stdin = JSON hasil _find_client; arg1 = proto
  local p="${1:-}"
  case "$p" in
    vless|vmess)                 jq -r '.client.id // ""' ;;
    trojan)                      jq -r '.client.password // ""' ;;
    shadowsocks|shadowsocks2022) jq -r '.client.email // ""' ;;
    *)                           echo "" ;;
  esac
}
# Coba delete lalu verifikasi benar-benar hilang; ulangi sekali kalau perlu.
_delete_and_verify() { # <clientId> <inb_id>
  local cid="$1" iid="$2" r inb_raw

  r="$(_api_delete_client_by_clientId "$cid" "$iid")"
  if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$r"; then
    echo "$r"; return 1
  fi

  # verifikasi (re-fetch), retry sekali jika masih ada
  sleep 0.25
  inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
  if _is_present_by_cid_in_inb "$inb_raw" "$iid" "$cid"; then
    sleep 0.35
    r="$(_api_delete_client_by_clientId "$cid" "$iid")"
    if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$r"; then
      echo "$r"; return 1
    fi
    sleep 0.25
    inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
    if _is_present_by_cid_in_inb "$inb_raw" "$iid" "$cid"; then
      echo '{"success":false,"msg":"Still present after retry","obj":null}'; return 1
    fi
  fi

  echo '{"success":true}'
}
_api_update_client_by_clientId() { # <clientId> <inbound_id> <client_json>
  local cid="$1" id="$2" client_json="$3"
  local settings body
  settings="$(jq -c --argjson c "$client_json" -n '{clients:[$c]}')"
  body="$(jq -cn --arg s "$settings" --argjson i "$id" '{id:$i,settings:$s}')"

  curl -sS -b "$COOKIE" \
       -H 'Accept: application/json' \
       -H 'Content-Type: application/json' \
       --data "$body" \
       "${BASE}panel/api/inbounds/updateClient/${cid}" || true
}
_norm_flow_for() { # <proto> <transport>
  local p="$1" t="$2"
  if [[ "$p" == "vless" && "$t" == "tcp" ]]; then
    echo "xtls-rprx-vision"; return
  fi
  echo ""
}
_build_client_like() { # <proto> <email> <exp_ms> <total_bytes> <limitip> <subid> [<uuid_or_pass> [<flow>]]
  local proto="$1" email="$2" exp="$3" total="$4" lim="$5" sub="$6"
  local cred="${7:-}" flow="${8:-}"

  case "$proto" in
    vless)
      [[ -z "$cred" ]] && cred="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
      jq -nc --arg id "$cred" --arg email "$email" --arg flow "$flow" \
        --argjson lim "$lim" --argjson total "$total" --argjson exp "$exp" --arg sub "$sub" \
        '{id:$id,security:"",password:"",flow:$flow,email:$email,limitIp:$lim,totalGB:$total,expiryTime:$exp,enable:true,tgId:0,subId:$sub,comment:"",reset:0,updated_at:(now|floor*1000)}'
      ;;

    vmess)
      [[ -z "$cred" ]] && cred="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
      jq -nc --arg id "$cred" --arg email "$email" \
        --argjson lim "$lim" --argjson total "$total" --argjson exp "$exp" --arg sub "$sub" \
        '{id:$id,security:"auto",password:"",flow:"",email:$email,limitIp:$lim,totalGB:$total,expiryTime:$exp,enable:true,tgId:0,subId:$sub,comment:"",reset:0,updated_at:(now|floor*1000)}'
      ;;

    trojan)
      [[ -z "$cred" ]] && cred="$(tr -dc A-Za-z </dev/urandom | head -c 12)"
      jq -nc --arg password "$cred" --arg email "$email" \
        --argjson lim "$lim" --argjson total "$total" --argjson exp "$exp" --arg sub "$sub" \
        '{password:$password,email:$email,limitIp:$lim,totalGB:$total,expiryTime:$exp,enable:true,tgId:"",subId:$sub,comment:"",reset:0,updated_at:(now|floor*1000)}'
      ;;

    shadowsocks|shadowsocks2022)
      [[ -z "$cred" ]] && cred="$(openssl rand -base64 9 2>/dev/null || tr -dc A-Za-z0-9 </dev/urandom | head -c 12)"
      jq -nc --arg email "$email" --arg method "" --arg password "$cred" \
        --argjson lim "$lim" --argjson total "$total" --argjson exp "$exp" --arg sub "$sub" \
        '{id:"",security:"",email:$email,method:$method,password:$password,limitIp:$lim,totalGB:$total,expiryTime:$exp,enable:true,tgId:0,subId:$sub,comment:"",reset:0,updated_at:(now|floor*1000)}'
      ;;
  esac
}

usage_edit() {
  cat <<'U'
edit regen <email>
edit quota <email> <GB|->           # "-" = unlimited
edit limitip <email> <N>
edit transport <email> <transport>  # protocol tetap, pindah transport (antar-inbound)
edit move <email> <transport> [inbound_id]  # alias transport, proto harus sama
edit set-exp <email> <hari> → contoh: 30 (hari dari sekarang)
edit set-exp <email> - atau 0 → unlimited (expiryTime=0)
edit set-exp <email> <tanggal> → YYYY-MM-DD atau YYYY-MM-DDTHH:MM
U
}
cmd_edit() {
  local sub="${1:-}"; shift || true
  [[ -z "$sub" ]] && { usage_edit; return 2; }

  local api_host api_port webbase user pass
  api_host="$(read_file "$API_HOST_FILE" "127.0.0.1")"
  api_port="$(read_file "$API_PORT_FILE" "2053")"
  webbase="$(norm_basepath "$(read_file "$WEBBASE_FILE" "/")")"
  user="$(read_file "$USER_FILE")"; pass="$(read_file "$PASS_FILE")"
  [[ -z "$user" || -z "$pass" ]] && { echo "username/password kosong"; return 2; }

  local BASE="http://${api_host}:${api_port}${webbase}"
  COOKIE="$(mktemp /tmp/3ling_cookie.XXXXXX)"; trap '[[ -n "${COOKIE:-}" ]] && rm -f "$COOKIE" || true' RETURN
  login "$BASE" "$user" "$pass" "$COOKIE"

  case "$sub" in
    regen)
      local email="${1:-}"; [[ -z "$email" ]] && { usage_edit; return 2; }

      local inb_raw rec
      inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      rec="$(_find_client "$inb_raw" "$email")"
      [[ -z "$rec" || "$rec" = "{}" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      local inb_id proto transport cur exp total lim sub flow clientId
      inb_id="$(jq -r '.inb.id' <<<"$rec")"
      proto="$(jq -r '.protocol' <<<"$rec")"
      transport="$(jq -r '.transport // ""' <<<"$rec")"
      cur="$(jq -c '.client' <<<"$rec")"
      exp="$(jq -r '.expiryTime // 0' <<<"$cur")"
      total="$(jq -r '.totalGB // 0' <<<"$cur")"
      lim="$(jq -r '.limitIp // 0' <<<"$cur")"
      sub="$(jq -r '.subId // ""' <<<"$cur")"
      flow="$(_norm_flow_for "$proto" "$transport")"
      clientId="$(_client_id_for "$proto" <<<"$rec")"
      [[ -z "$clientId" || "$clientId" == "null" ]] && { echo "clientId tidak ditemukan untuk proto=$proto"; return 2; }

      local new_client
      if [[ "$proto" == "vless" ]]; then
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "" "$flow")"
      else
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub")"
      fi

      local r; r="$(_api_update_client_by_clientId "$clientId" "$inb_id" "$new_client")"
      if jq -e '.success==true' >/dev/null 2>&1 <<<"$r"; then
        echo "Credential baru tersimpan (updateClient/${clientId})."
        return 0
      fi
      echo "Gagal update: $r"; return 1
      ;;
    quota)
      local email="${1:-}" q="${2:-}"; [[ -z "$email" || -z "$q" ]] && { usage_edit; return 2; }

      local inb_raw rec; inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      rec="$(_find_client "$inb_raw" "$email")"; [[ -z "$rec" || "$rec" = "{}" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      local inb_id proto transport cur exp total lim sub flow clientId
      inb_id="$(jq -r '.inb.id' <<<"$rec")"
      proto="$(jq -r '.protocol' <<<"$rec")"
      transport="$(jq -r '.transport // ""' <<<"$rec")"
      cur="$(jq -c '.client' <<<"$rec")"
      exp="$(jq -r '.expiryTime // 0' <<<"$cur")"
      lim="$(jq -r '.limitIp // 0' <<<"$cur")"
      sub="$(jq -r '.subId // ""' <<<"$cur")"
      flow="$(_norm_flow_for "$proto" "$transport")"
      clientId="$(_client_id_for "$proto" <<<"$rec")"

      if [[ "$q" == "-" || "$q" == "0" ]]; then
        total=0
      else
        [[ "$q" =~ ^[0-9]+$ ]] || { echo "Quota harus angka GB atau '-'"; return 2; }
        total=$(( q * 1024 * 1024 * 1024 ))
      fi

      local new_client
      if [[ "$proto" == "vless" ]]; then
        # pertahankan cred lama
        local cred; cred="$(jq -r '.client.id // .client.password // ""' <<<"$rec")"
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred" "$flow")"
      elif [[ "$proto" == "vmess" ]]; then
        local cred; cred="$(jq -r '.client.id' <<<"$rec")"
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred")"
      elif [[ "$proto" == "trojan" ]]; then
        local cred; cred="$(jq -r '.client.password' <<<"$rec")"
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred")"
      else
        local cred; cred="$(jq -r '.client.password' <<<"$rec")"
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred")"
      fi

      local r; r="$(_api_update_client_by_clientId "$clientId" "$inb_id" "$new_client")"
      jq -e '.success==true' >/dev/null 2>&1 <<<"$r" && { echo "Quota diperbarui."; return 0; }
      echo "Gagal update: $r"; return 1
      ;;
    transport)
      # 3ling edit transport <email> <transport>  (protocol tetap, pindah antar-inbound)
      local email="${1:-}" new_trans_raw="${2:-}"
      [[ -z "$email" || -z "$new_trans_raw" ]] && { echo "Usage: 3ling edit transport <email> <transport>"; return 2; }

      local new_trans; new_trans="$(norm_transport "$new_trans_raw")"

      # ambil semua inbound
      local inb_raw; inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"

      # locate client sekarang
      local rec; rec="$(_find_client "$inb_raw" "$email")"
      [[ -z "$rec" || "$rec" = "{}" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      local cur_inb_id proto cur_trans
      cur_inb_id="$(jq -r '.inb.id' <<<"$rec")"
      proto="$(jq -r '.protocol' <<<"$rec")"
      cur_trans="$(jq -r '.transport // ""' <<<"$rec")"

      [[ "$cur_trans" == "$new_trans" ]] && { echo "Transport sudah $new_trans (tidak ada perubahan)."; return 0; }

      # pilih inbound tujuan (protocol sama, transport beda)
      local picked; picked="$(select_inbound "$inb_raw" "$proto" "$new_trans")"
      [[ -z "$picked" || "$picked" = "null" ]] && { echo "Inbound untuk $proto/$new_trans tidak ditemukan."; return 3; }
      local dst_inb_id dst_remark
      dst_inb_id="$(jq -r '.id' <<<"$picked")"
      dst_remark="$(jq -r '.remark // ""' <<<"$picked")"

      # data client lama
      local cur total lim sub exp flow clientId cred
      cur="$(jq -c '.client' <<<"$rec")"
      total="$(jq -r '.totalGB // 0' <<<"$cur")"
      lim="$(jq -r '.limitIp // 0' <<<"$cur")"
      sub="$(jq -r '.subId // ""' <<<"$cur")"
      exp="$(jq -r '.expiryTime // 0' <<<"$cur")"

      # tentukan identifier & credential lama biar sama persis
      clientId="$(_client_id_for "$proto" <<<"$rec")"
      case "$proto" in
        vless|vmess) cred="$(jq -r '.client.id' <<<"$rec")" ;;
        trojan)      cred="$(jq -r '.client.password' <<<"$rec")" ;;
        shadowsocks|shadowsocks2022) cred="$(jq -r '.client.password' <<<"$rec")" ;;
        *) echo "Protocol tidak didukung: $proto"; return 2 ;;
      esac

      # flow baru (khusus vless tcp → vision)
      flow="$(_norm_flow_for "$proto" "$new_trans")"

      # ---- Anti-duplikat: rename dulu di inbound asal -> tmp-... (agar email global unik)
      local tmpmail cur_flow cur_client_json rename_r
      tmpmail="$(_tmp_email)"
      cur_flow="$(_norm_flow_for "$proto" "$cur_trans")"
      if [[ "$proto" == "vless" ]]; then
        cur_client_json="$(_build_client_like "$proto" "$tmpmail" "$exp" "$total" "$lim" "$sub" "$cred" "$cur_flow")"
      else
        cur_client_json="$(_build_client_like "$proto" "$tmpmail" "$exp" "$total" "$lim" "$sub" "$cred")"
      fi
      rename_r="$(_api_update_client_by_clientId "$clientId" "$cur_inb_id" "$cur_client_json")"
      if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$rename_r"; then
        echo "Gagal rename sementara di inbound asal: $rename_r"
        return 1
      fi

      # rakit payload client baru (email asli) untuk inbound tujuan
      local new_client
      if [[ "$proto" == "vless" ]]; then
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred" "$flow")"
      else
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred")"
      fi

      # add ke inbound tujuan (email asli)
      local add_r
      add_r="$(_api_add_client "$dst_inb_id" "$new_client")"
      if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$add_r"; then
        # rollback rename → balikin email ke asal
        local rollback_json
        if [[ "$proto" == "vless" ]]; then
          rollback_json="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred" "$cur_flow")"
        else
          rollback_json="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim" "$sub" "$cred")"
        fi
        _api_update_client_by_clientId "$clientId" "$cur_inb_id" "$rollback_json" >/dev/null 2>&1 || true
        echo "Gagal menambah ke inbound tujuan ($dst_inb_id/$dst_remark): $add_r"
        return 1
      fi

      # hapus entry lama yang tmp (delete+verify, retry)
      local try=0 del_ok=0 del_r
      while (( try < 3 )); do
        del_r="$(_api_delete_client_by_clientId "$clientId" "$cur_inb_id")"
        if jq -e '.success==true' >/dev/null 2>&1 <<<"$del_r"; then
          sleep 0.3
          inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
          if ! _is_present_by_cid_in_inb "$inb_raw" "$cur_inb_id" "$clientId"; then
            del_ok=1; break
          fi
        fi
        sleep 0.35; try=$((try+1))
      done
      if (( del_ok == 0 )); then
        # fallback: mungkin CID berubah setelah rename; cari by email tmp
        inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list")"
        local alt_cid
        alt_cid="$(jq -r --argjson iid "$cur_inb_id" --arg em "$tmpmail" '
          def mj: if type=="string" then (try fromjson catch {}) else . end;
          (.obj // .) as $A
          | ($A[] | select(.id==$iid) | (.settings|mj).clients[]? | select((.email//"")==$em) | (.id//.password//"")) // ""
        ' <<<"$inb_raw")"
        [[ -n "$alt_cid" ]] && _api_delete_client_by_clientId "$alt_cid" "$cur_inb_id" >/dev/null 2>&1 || true
        echo "Peringatan: berhasil pindah ke $dst_remark, tapi gagal hapus dari inbound lama id=$cur_inb_id: ${del_r:-{}}"
        echo "Silakan hapus manual entry tmp ($tmpmail) di inbound lama."
        return 0
      fi

      echo "Berhasil pindah transport: $proto $email  ${cur_trans} → ${new_trans}  (inbound ${cur_inb_id} → ${dst_inb_id})"
      return 0
      ;;

    move)
      # 3ling edit move <email> <proto> <transport>  (ganti PROTOCOL + TRANSPORT)
      local email="${1:-}" proto_raw="${2:-}" trans_raw="${3:-}"
      [[ -z "$email" || -z "$proto_raw" || -z "$trans_raw" ]] && { echo "Usage: 3ling edit move <email> <proto> <transport>"; return 2; }

      local new_proto new_trans
      new_proto="$(norm_proto "$proto_raw")"
      new_trans="$(norm_transport "$trans_raw")"

      # ambil semua inbound & semua entri client dengan email tsb
      local inb_raw; inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      local arr; arr="$(_find_clients_all "$inb_raw" "$email")"
      [[ -z "$arr" || "$arr" = "[]" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      # inbound tujuan (proto/transport baru)
      local picked; picked="$(select_inbound "$inb_raw" "$new_proto" "$new_trans")"
      [[ -z "$picked" || "$picked" = "null" ]] && { echo "Inbound target tidak ditemukan untuk $new_proto/$new_trans"; return 3; }
      local dst_inb_id dst_remark
      dst_inb_id="$(jq -r '.id' <<<"$picked")"
      dst_remark="$(jq -r '.remark // ""' <<<"$picked")"

      # properti umum (quota/limit/expiry/sub) ambil dari entri pertama
      local c0 exp total lim sub
      c0="$(jq -c '.[0].client' <<<"$arr")"
      exp="$(jq -r '.expiryTime // 0' <<<"$c0")"
      total="$(jq -r '.totalGB // 0'   <<<"$c0")"
      lim="$(jq -r '.limitIp // 0'     <<<"$c0")"
      sub="$(jq -r '.subId // ""'      <<<"$c0")"

      # 1) mass-rename semua entri email lama -> tmp-...
      local renames_json; renames_json='[]'
      while IFS= read -r row; do
        local old_proto old_trans cur_inb_id clientId cred tmpmail flow cur_client_json rename_r
        old_proto="$(jq -r '.protocol' <<<"$row")"
        old_trans="$(jq -r '.transport // ""' <<<"$row")"
        cur_inb_id="$(jq -r '.inb.id'   <<<"$row")"
        clientId="$(jq -r '.clientId'   <<<"$row")"
        cred="$(jq -r '.cred'           <<<"$row")"
        tmpmail="$(_tmp_email)"
        flow="$(_norm_flow_for "$old_proto" "$old_trans")"

        if [[ "$old_proto" == "vless" ]]; then
          cur_client_json="$(_build_client_like "$old_proto" "$tmpmail" "$exp" "$total" "$lim" "$sub" "$cred" "$flow")"
        else
          cur_client_json="$(_build_client_like "$old_proto" "$tmpmail" "$exp" "$total" "$lim" "$sub" "$cred")"
        fi

        rename_r="$(_api_update_client_by_clientId "$clientId" "$cur_inb_id" "$cur_client_json")"
        if ! jq -e '.success==true' >/dev/null 2>&1 <<<"$rename_r"; then
          echo "Gagal rename sementara (inb $cur_inb_id): $rename_r"
          return 1
        fi

        renames_json="$(jq -c --argjson iid "$cur_inb_id" --arg cid "$clientId" --arg tmp "$tmpmail" '. + [{inb:$iid, clientId:$cid, tmp:$tmp}]' <<<"$renames_json")"
      done < <(jq -c '.[]' <<<"$arr")

      sleep 0.2

      # 2) add di inbound tujuan (ganti PROTO => regen cred)
      local new_flow new_client add_r
      new_flow="$(_norm_flow_for "$new_proto" "$new_trans")"
      if [[ "$new_proto" == "vless" ]]; then
        new_client="$(_build_client_like "$new_proto" "$email" "$exp" "$total" "$lim" "$sub" "" "$new_flow")"
      else
        new_client="$(_build_client_like "$new_proto" "$email" "$exp" "$total" "$lim" "$sub")"
      fi
      add_r="$(_api_add_client "$dst_inb_id" "$new_client")"
      jq -e '.success==true' >/dev/null 2>&1 <<<"$add_r" || { echo "Add client baru gagal: $add_r"; return 1; }

      sleep 0.2

      # 3) mass-delete semua tmp hasil rename (retry + verifikasi)
      local del_warn=0
      while IFS= read -r it; do
        local iid cid tmp try=0 del_ok=0 del_r
        iid="$(jq -r '.inb' <<<"$it")"
        cid="$(jq -r '.clientId' <<<"$it")"
        tmp="$(jq -r '.tmp' <<<"$it")"

        while (( try < 3 )); do
          del_r="$(_api_delete_client_by_clientId "$cid" "$iid")"
          if jq -e '.success==true' >/dev/null 2>&1 <<<"$del_r"; then
            sleep 0.3
            inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
            if ! _is_present_by_cid_in_inb "$inb_raw" "$iid" "$cid"; then
              del_ok=1; break
            fi
          fi
          sleep 0.35; try=$((try+1))
        done

        if (( del_ok == 0 )); then
          # fallback: cari berdasarkan email tmp (kalau CID berubah)
          inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list")"
          local alt_cid
          alt_cid="$(jq -r --argjson iid "$iid" --arg em "$tmp" '
            def mj: if type=="string" then (try fromjson catch {}) else . end;
            (.obj // .) as $A
            | ($A[] | select(.id==$iid) | (.settings|mj).clients[]? | select((.email//"")==$em) | (.id//.password//"")) // ""
          ' <<<"$inb_raw")"
          [[ -n "$alt_cid" ]] && _api_delete_client_by_clientId "$alt_cid" "$iid" >/dev/null 2>&1 || true
          del_warn=1
          echo "WARNING: gagal hapus tmp di inbound $iid (cid=$cid)."
        fi
      done < <(jq -c '.[]' <<<"$renames_json")

      echo "Client dipindahkan: $email  →  ${new_proto}/${new_trans} (inbound ${dst_inb_id}, dest=${dst_remark})."
      (( del_warn == 1 )) && echo "Catatan: beberapa tmp mungkin tertinggal; jalankan: 3ling cleanup-email $email"
      return 0
      ;;
    cleanup-email)
      local email="${1:-}"; [[ -z "$email" ]] && { echo "Usage: 3ling cleanup-email <email>"; return 2; }
      local inb_raw; inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      # ambil semua entry yg email diawali tmp- ATAU email persis (kalau masih ada duplikat)
      local rows; rows="$(jq -c '
        def mj: if type=="string" then (try fromjson catch {}) else . end;
        [ .[] as $inb
          | ($inb.settings|mj) as $s
          | ($s.clients // [])[]? as $c
          | select( ((($c.email // "")|startswith("tmp-")) or (($c.email // "")=="'"$email"'")) )
          | {inb:$inb.id, cid:($c.id // $c.password // ""), email:($c.email // "")}
        ]' <<<"$inb_raw")"
      [[ -z "$rows" || "$rows" = "[]" ]] && { echo "Tidak ada entri tmp-… / duplikat untuk $email"; return 0; }
      local ok=1
      while IFS= read -r r; do
        local iid cid em; iid="$(jq -r '.inb' <<<"$r")"; cid="$(jq -r '.cid' <<<"$r")"; em="$(jq -r '.email' <<<"$r")"
        # Jangan hapus yang email persis $email bila itu satu-satunya & sudah pindah ⇒ cek inbound tujuan sudah ada
        if [[ "$em" == "$email" ]]; then
          continue
        fi
        local d; d="$(_delete_and_verify "$cid" "$iid")"
        jq -e '.success==true' >/dev/null 2>&1 <<<"$d" || { ok=0; echo "WARNING: gagal hapus tmp di inbound $iid: $d"; }
      done < <(jq -c '.[]' <<<"$rows")
      [[ $ok -eq 1 ]] && echo "Cleanup selesai." || echo "Cleanup selesai dengan peringatan."
      ;;
    limitip)
      local email="${1:-}" lim_new="${2:-}"; [[ -z "$email" || -z "$lim_new" ]] && { usage_edit; return 2; }
      [[ "$lim_new" =~ ^[0-9]+$ ]] || { echo "limitip harus angka"; return 2; }

      local inb_raw rec; inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      rec="$(_find_client "$inb_raw" "$email")"; [[ -z "$rec" || "$rec" = "{}" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      local inb_id proto transport cur exp total sub flow clientId cred
      inb_id="$(jq -r '.inb.id' <<<"$rec")"
      proto="$(jq -r '.protocol' <<<"$rec")"
      transport="$(jq -r '.transport // ""' <<<"$rec")"
      cur="$(jq -c '.client' <<<"$rec")"
      exp="$(jq -r '.expiryTime // 0' <<<"$cur")"
      total="$(jq -r '.totalGB // 0' <<<"$cur")"
      sub="$(jq -r '.subId // ""' <<<"$cur")"
      flow="$(_norm_flow_for "$proto" "$transport")"
      clientId="$(_client_id_for "$proto" <<<"$rec")"

      case "$proto" in
        vless|vmess) cred="$(jq -r '.client.id' <<<"$rec")" ;;
        trojan)      cred="$(jq -r '.client.password' <<<"$rec")" ;;
        shadowsocks|shadowsocks2022) cred="$(jq -r '.client.password' <<<"$rec")" ;;
      esac

      local new_client
      if [[ "$proto" == "vless" ]]; then
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim_new" "$sub" "$cred" "$flow")"
      else
        new_client="$(_build_client_like "$proto" "$email" "$exp" "$total" "$lim_new" "$sub" "$cred")"
      fi

      local r; r="$(_api_update_client_by_clientId "$clientId" "$inb_id" "$new_client")"
      jq -e '.success==true' >/dev/null 2>&1 <<<"$r" && { echo "Limit IP diperbarui."; return 0; }
      echo "Gagal update: $r"; return 1
      ;;
    set-exp)
      local email="${1:-}" val="${2:-}"
      [[ -z "$email" || -z "$val" ]] && { echo "Usage: 3ling edit set-exp <email> <hari|YYYY-MM-DD|YYYY-MM-DDTHH:MM|->"; return 2; }

      local exp_ms
      if ! exp_ms="$(_parse_exp_ms "$val")"; then
        echo "Format expire tidak valid. Pakai angka hari, '-' untuk unlimited, atau tanggal YYYY-MM-DD[THH:MM]."
        return 2
      fi

      # ambil client & inbound
      local inb_raw rec
      inb_raw="$(curl -sS -b "$COOKIE" -H 'Accept: application/json' "${BASE}panel/api/inbounds/list" | jq -c '.obj // .')"
      rec="$(_find_client "$inb_raw" "$email")"
      [[ -z "$rec" || "$rec" = "{}" ]] && { echo "Email tidak ditemukan: $email"; return 1; }

      local inb_id proto transport cur total lim sub flow clientId cred
      inb_id="$(jq -r '.inb.id' <<<"$rec")"
      proto="$(jq -r '.protocol' <<<"$rec")"
      transport="$(jq -r '.transport // ""' <<<"$rec")"
      cur="$(jq -c '.client' <<<"$rec")"
      total="$(jq -r '.totalGB // 0' <<<"$cur")"
      lim="$(jq -r '.limitIp // 0' <<<"$cur")"
      sub="$(jq -r '.subId // ""' <<<"$cur")"
      flow="$(_norm_flow_for "$proto" "$transport")"
      clientId="$(_client_id_for "$proto" <<<"$rec")"
      [[ -z "$clientId" || "$clientId" == "null" ]] && { echo "clientId tidak ditemukan untuk proto=$proto"; return 2; }

      case "$proto" in
        vless|vmess) cred="$(jq -r '.client.id' <<<"$rec")" ;;
        trojan)      cred="$(jq -r '.client.password' <<<"$rec")" ;;
        shadowsocks|shadowsocks2022) cred="$(jq -r '.client.password' <<<"$rec")" ;;
      esac

      local new_client
      if [[ "$proto" == "vless" ]]; then
        new_client="$(_build_client_like "$proto" "$email" "$exp_ms" "$total" "$lim" "$sub" "$cred" "$flow")"
      else
        new_client="$(_build_client_like "$proto" "$email" "$exp_ms" "$total" "$lim" "$sub" "$cred")"
      fi

      local r; r="$(_api_update_client_by_clientId "$clientId" "$inb_id" "$new_client")"
      if jq -e '.success==true' >/dev/null 2>&1 <<<"$r"; then
        if [[ "$exp_ms" -eq 0 ]]; then
          echo "Expire di-set ke unlimited (0)."
        else
          local human; human="$(date -d @"$((exp_ms/1000))" "+%F %T")"
          echo "Expire diperbarui: ${human}"
        fi
        return 0
      fi
      echo "Gagal update expire: $r"; return 1
      ;;
    *)
      usage_edit; return 2;;
  esac
}

usage(){
  cat <<'USAGE'
3ling - CLI helper x-ui

Pemakaian:
1. List:
Semua client:
  3ling list

Filter email (glob):
  3ling list --email '<email>*'

Filter protocol:
  3ling list --proto <proto>

Yang akan expired ≤ x hari:
  3ling list --soon <days>

Kombinasi:
  3ling list --proto <proto> --email '<email>*' --soon <days>

2. User Management:
  3ling add    <remark/email> <exp_days> <proto> <transport> [quota_gb] [limitip] [inbound_id_override]
  3ling delete <email> [inbound_id]
  3ling extend <email> <days>

Contoh:
  3ling list
  3ling add tester 30 trojan ws
  3ling add tester 30 trojan ws 1024
  3ling add tester 30 trojan ws 1024 10
  3ling delete tester
  3ling extend tester 30

3. Show
  3ling show <email>

4. Enable/Disable user
  3ling enable <email>
  3ling disable <email>
USAGE
}

main() {
  local cmd="${1:-}"
  [[ -z "$cmd" ]] && { usage; exit 1; }
  shift || true

  case "$cmd" in
    add)       [[ $# -lt 4 ]] && { usage; exit 2; }; cmd_add "$@";;
    delete)    [[ $# -lt 1 ]] && { usage; exit 2; }; cmd_delete "$@";;
    extend)    [[ $# -lt 2 ]] && { usage; exit 2; }; cmd_extend "$@";;
    list)      cmd_list "$@";;
    show)      [[ $# -lt 1 ]] && { usage; exit 2; }; cmd_show "$@";;
    enable)    [[ $# -lt 1 ]] && { usage; exit 2; }; cmd_enable "$@";;
    disable)   [[ $# -lt 1 ]] && { usage; exit 2; }; cmd_disable "$@";;
    ceklogin)  cmd_ceklogin "$@";;
    purge)     cmd_purge "$@";;
    edit)      cmd_edit "$@";;
    *)         usage; exit 1;;
  esac
}

main "$@"