#!/usr/bin/env bash
# ss2022ctl — Xray-API Shadowsocks 2022 WS manager (runtime only; config.json tidak diubah)
# Fitur:
#  add <email> <quota_gb> <expire_days> <reset_days> [max_devices]
#  delete <email>
#  purge <email>
#  renew <email> <add_days> [<new_quota_gb>]
#  list
#  usage <email>
#  online <email>
#  onlineip <email>
#  enforce
#  restore
#  link <email>                 # CETAK URL subscription valid (token dihitung dari container subsrv)
#  devset <email> <max_devices> # set batas device (0=unlimited)
#  devshow <email>              # lihat batas device
#
# Catatan:
# - Semua perubahan user via API (adu/rmu). Persist sendiri di database.json.
# - Expired & reset: input HARI (angka), dikonversi ke detik (countdown).
# - Quota = akumulasi uplink+downlink (StatsService) dengan akumulator delta agar tahan restart Xray.
# - DB: /usr/local/etc/xray/database.json

set -euo pipefail

XRAY="/usr/local/bin/xray"
CFG="/usr/local/etc/xray/config.json"
DB="/usr/local/etc/xray/database.json"
SERVER="127.0.0.1:10085"
TAG="SSWS"                                     # tetap untuk kompatibilitas lama
METHOD="2022-blake3-aes-128-gcm"               # inbound kamu pakai ini

# ====== Multi-inbound support ======
# Override via env jika perlu:
#   export INBOUND_TAGS="SSWS,SSWS-ANTIADS,SSWS-ANTIPORN"
#   export PRIMARY_TAG="SSWS"
INBOUND_TAGS="${INBOUND_TAGS:-SSWS,SSWS-ANTIADS,SSWS-ANTIPORN}"
PRIMARY_TAG="${PRIMARY_TAG:-SSWS}"

# ====== Output / Integrasi ======
DOMAIN_FILE="/root/domain"                             # ambil domain
TELEGRAM_CONF="/etc/gegevps/bin/telegram_config.conf"  # TOKEN & CHAT_ID
SUB_SCHEME="${SUB_SCHEME:-https}"                      # http/https untuk subs page
SS_PORT="${SS_PORT:-443}"                              # port publik akses WS (di balik Nginx)
SS_TLS="${SS_TLS:-yes}"                                # yes→ tambahkan ;tls pada plugin
SS_METHOD="${SS_METHOD:-2022-blake3-aes-128-gcm}"      # metoda SS2022 utk URL ss://
SUBSRV_CONTAINER="${SUBSRV_CONTAINER:-subsrv}"         # nama container subsrv (Flask)

# === Device limit settings ===
DEVICE_COOLDOWN="${DEVICE_COOLDOWN:-300}"   # detik dikunci bila melanggar (default 5 menit)
MAX_DEVICES_DEFAULT="${MAX_DEVICES_DEFAULT:-0}"

need(){ command -v "$1" >/dev/null 2>&1 || { echo "Need: $1"; exit 1; }; }
need jq
need awk
need docker
need openssl
[ -x "$XRAY" ] || { echo "Xray binary not found: $XRAY"; exit 1; }

init_db(){ mkdir -p "$(dirname "$DB")"; [ -f "$DB" ] || echo '{"users":{}}' > "$DB"; }
now_epoch(){ date +%s; }
now_iso(){ date -u +"%Y-%m-%dT%H:%M:%SZ"; }
bytes_from_gb(){ awk -v g="${1:-0}" 'BEGIN{printf "%.0f", g*1024*1024*1024}'; }
days_to_seconds(){ awk -v d="${1:-0}" 'BEGIN{printf "%.0f", d*24*60*60}'; }
ceil_days(){ awk -v s="${1:-0}" 'BEGIN{ d = (s<=0)?0 : int((s + 86399)/86400); print d }'; }
trim(){ sed 's/\r$//' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; }

api_stats_bytes(){ # total uplink+downlink, via JSON statsquery (tahan null)
  local email="$1"
  local out
  out="$(
    $XRAY api statsquery --server="$SERVER" 2>/dev/null \
    | jq -r --arg e "$email" '
        [ (.stat // [])[]
          | select(.name=="user>>>"+$e+">>>traffic>>>uplink"
                   or .name=="user>>>"+$e+">>>traffic>>>downlink")
          | .value
        ] | add // 0
      ' 2>/dev/null \
    || echo 0
  )"
  # pastikan angka
  case "$out" in
    ''|*[!0-9]*) echo 0 ;;
    *)           echo "$out" ;;
  esac
}

# --- total byte mentah dari Xray (uplink+downlink) untuk 1 user
raw_total_bytes(){ # email
  local email="$1"
  api_stats_bytes "$email"
}


# ---- Akumulator usage tahan-restart ----
db_get_last_raw(){ jq -r --arg e "$1" '.users[$e].last_raw_counter // 0' "$DB"; }
db_set_last_raw(){ local e="$1" v="$2"; jq --arg e "$e" --argjson v "$v" '.users[$e].last_raw_counter=$v' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_get_cum_used(){ jq -r --arg e "$1" '.users[$e].cum_used_bytes // 0' "$DB"; }
db_set_cum_used(){ local e="$1" v="$2"; jq --arg e "$e" --argjson v "$v" --arg n "$(now_iso)" '.users[$e].cum_used_bytes=$v | .users[$e].used_bytes=$v | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }

# helper kecil: cek integer
is_int(){ [[ "${1:-}" =~ ^[0-9]+$ ]]; }

update_usage_accumulate(){ # email -> echo used_bytes terbaru (akumulatif)
  local e="$1"
  local raw last cum delta quota

  # Baca counter raw total (uplink+downlink) → pastikan selalu integer
  raw="$(raw_total_bytes "$e" 2>/dev/null || echo 0)"
  is_int "$raw" || raw=0

  # Ambil baseline terakhir & akumulasi dari DB → default 0 kalau kosong/null
  last="$(db_get_last_raw "$e")"; is_int "$last" || last=0
  cum="$(db_get_cum_used "$e")";  is_int "$cum"  || cum=0

  # Jika raw turun (Xray restart/rollover), reset baseline tanpa menambah cum
  if [ "$raw" -lt "$last" ]; then
    db_set_last_raw "$e" "$raw"
    echo "$cum"
    return 0
  fi

  # Tambah hanya jika ada kenaikan
  delta=$(( raw - last ))
  if [ "$delta" -gt 0 ]; then
    cum=$(( cum + delta ))
    db_set_cum_used "$e" "$cum"
    db_set_last_raw "$e" "$raw"
  fi

  # (Opsional) Clamp ke kuota kalau bukan unlimited (biar “Used” tak lewat kuota)
  quota="$(db_get "$e" quota_bytes)"; quota="${quota:-0}"
  if ! is_unlimited "$quota" && is_int "$quota" && [ "$cum" -gt "$quota" ]; then
    cum="$quota"
    db_set_cum_used "$e" "$cum"
  fi

  echo "$cum"
}

# === ONLINE COUNT & IP LIST via access.log (window 10 menit) ===
api_online_count(){ # hitung IP unik 10 menit terakhir dari access.log
  local email="$1"
  local LOG="/var/lib/marzban/assets/access.log"
  local WINDOW=300  # detik (10 menit)

  if [ ! -f "$LOG" ]; then
    echo "Count: 0"; return 0
  fi

  local CUT; CUT="$(date -d "@$(( $(date +%s) - WINDOW ))" "+%Y/%m/%d %H:%M:%S")"

  awk -v email="$email" -v cut="$CUT" '
    function is_private(ip, a, ip_lc) {
      ip_lc = tolower(ip)
      if (substr(ip,1,4)=="127.") return 1
      if (substr(ip,1,3)=="10.") return 1
      if (substr(ip,1,8)=="192.168.") return 1
      if (substr(ip,1,3)=="172") { split(ip,a,"."); if (a[2] >= 16 && a[2] <= 31) return 1 }
      if (ip=="0.0.0.0" || ip=="localhost" || ip=="::1") return 1
      if (substr(ip_lc,1,2)=="fc" || substr(ip_lc,1,2)=="fd") return 1
      if (substr(ip_lc,1,3)=="fe8") return 1
      return 0
    }
    BEGIN { FS = "" }
    {
      line = $0
      if (index(line, "email: " email) == 0) next
      ts = substr(line, 1, 19)                 # "YYYY/MM/DD HH:MM:SS"
      if (ts < cut) next

      pos = index(line, " from "); if (pos == 0) next
      rest = substr(line, pos + 6)
      sp = index(rest, " "); if (sp == 0) next
      ip = substr(rest, 1, sp - 1)

      # buang port di ujung
      p = 0; for (i = length(ip); i >= 1; i--) { if (substr(ip,i,1) == ":") { p = i; break } }
      if (p > 0) ip = substr(ip, 1, p - 1)

      # trailing , ;
      if (substr(ip, length(ip), 1) == "," || substr(ip, length(ip), 1) == ";")
        ip = substr(ip, 1, length(ip)-1)

      if (is_private(ip)) next
      seen[ip] = 1
    }
    END { n = 0; for (k in seen) n++; printf("Count: %d\n", n) }
  ' "$LOG"
}

api_online_iplist(){ # daftar IP unik 10 menit terakhir dari access.log
  local email="$1"
  local LOG="/var/lib/marzban/assets/access.log"
  local WINDOW=300

  if [ ! -f "$LOG" ]; then
    echo "IPs: []"; return 0
  fi

  local CUT; CUT="$(date -d "@$(( $(date +%s) - WINDOW ))" "+%Y/%m/%d %H:%M:%S")"

  awk -v email="$email" -v cut="$CUT" '
    function is_private(ip, a, ip_lc) {
      ip_lc = tolower(ip)
      if (substr(ip,1,4)=="127.") return 1
      if (substr(ip,1,3)=="10.") return 1
      if (substr(ip,1,8)=="192.168.") return 1
      if (substr(ip,1,3)=="172") { split(ip,a,"."); if (a[2] >= 16 && a[2] <= 31) return 1 }
      if (ip=="0.0.0.0" || ip=="localhost" || ip=="::1") return 1
      if (substr(ip_lc,1,2)=="fc" || substr(ip_lc,1,2)=="fd") return 1
      if (substr(ip_lc,1,3)=="fe8") return 1
      return 0
    }
    BEGIN { FS = "" }
    {
      line = $0
      if (index(line, "email: " email) == 0) next
      ts = substr(line, 1, 19); if (ts < cut) next

      pos = index(line, " from "); if (pos == 0) next
      rest = substr(line, pos + 6)
      sp = index(rest, " "); if (sp == 0) next
      ip = substr(rest, 1, sp - 1)

      p = 0; for (i = length(ip); i >= 1; i--) { if (substr(ip,i,1) == ":") { p = i; break } }
      if (p > 0) ip = substr(ip, 1, p - 1)

      if (substr(ip, length(ip), 1) == "," || substr(ip, length(ip), 1) == ";")
        ip = substr(ip, 1, length(ip)-1)

      if (is_private(ip)) next
      last[ip] = 1
    }
    END {
      first = 1
      printf("IPs: [")
      for (k in last) { if (!first) printf(","); printf("\"%s\"", k); first = 0 }
      printf("]\n")
    }
  ' "$LOG"
}

# ---- DB helpers ----
db_exists(){ jq -e --arg e "$1" '.users[$e]' "$DB" >/dev/null; }
db_get(){ jq -r --arg e "$1" --arg k "$2" '.users[$e][$k] // empty' "$DB"; }
db_set_user(){ # email pw_b64 quota_bytes expire_at reset_every_seconds enabled
  local e="$1" pw="$2" q="$3" exp="$4" res="$5" en="$6" now; now="$(now_iso)"
  jq --arg e "$e" --arg p "$pw" --argjson q "$q" --argjson x "$exp" --argjson r "$res" --argjson en "$en" --arg n "$now" '
    .users[$e] = (.users[$e] // {})
    | .users[$e] += {
        "password_b64": $p,
        "quota_bytes": $q,
        "used_bytes": (.users[$e].used_bytes // 0),
        "enabled": $en,
        "created_at": (.users[$e].created_at // $n),
        "last_update": $n,
        "expire_at": $x,
        "reset_every_seconds": $r,
        "last_reset_at": (.users[$e].last_reset_at // 0),
        "max_devices": (.users[$e].max_devices // 0),
        "lock_until": (.users[$e].lock_until // 0)
      }' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"
}
db_update_used(){ local e="$1" u="$2"; jq --arg e "$e" --argjson u "$u" --arg n "$(now_iso)" '.users[$e].used_bytes=$u | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_set_enabled(){ local e="$1" en="$2"; jq --arg e "$e" --argjson en "$en" --arg n "$(now_iso)" '.users[$e].enabled=$en | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_set_expire(){ local e="$1" x="$2"; jq --arg e "$e" --argjson x "$x" --arg n "$(now_iso)" '.users[$e].expire_at=$x | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_set_quota(){  local e="$1" q="$2"; jq --arg e "$e" --argjson q "$q" --arg n "$(now_iso)" '.users[$e].quota_bytes=$q | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_set_last_reset(){ local e="$1" t="$2"; jq --arg e "$e" --argjson t "$t" '.users[$e].last_reset_at=$t' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_remove_user(){ jq --arg e "$1" 'del(.users[$e])' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_set_maxdev(){ local e="$1" m="$2"; jq --arg e "$e" --argjson m "$m" --arg n "$(now_iso)" '.users[$e].max_devices=$m | .users[$e].last_update=$n' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_get_maxdev(){ jq -r --arg e "$1" '.users[$e].max_devices // 0' "$DB"; }
db_set_lock(){ local e="$1" t="$2"; jq --arg e "$e" --argjson t "$t" '.users[$e].lock_until=$t' "$DB" > "${DB}.tmp" && mv "${DB}.tmp" "$DB"; }
db_get_lock(){ jq -r --arg e "$1" '.users[$e].lock_until // 0' "$DB"; }

# ---- Humanize bytes ----
fmt_bytes(){
  awk '
    function human(x, a, i, n) {
      split("B KB MB GB TB PB", a, " ")
      n = length(a); i = 1
      while (x >= 1024 && i < n) { x = x / 1024; i++ }
      printf("%.2f %s\n", x, a[i])
    }
    { human($1) }
  '
}

# ---- Helpers: domain, inbound field, server key, ss-uri, telegram, docker/env ----
# ==== Reset strategy helpers (DB) ====
db_get_reset_strategy(){ # email -> echo strategy
  local e="$1"
  jq -r --arg e "$e" '.users[$e].reset_strategy // empty' "$DB"
}

db_set_reset_strategy(){ # email strategy
  local e="$1" s="$2"
  jq --arg e "$e" --arg s "$s" '.users[$e].reset_strategy = $s' "$DB" > "$DB.tmp" && mv "$DB.tmp" "$DB"
}

db_get_last_reset_key(){ # email -> echo key
  local e="$1"
  jq -r --arg e "$e" '.users[$e].last_reset_key // empty' "$DB"
}

db_set_last_reset_key(){ # email key
  local e="$1" k="$2"
  jq --arg e "$e" --arg k "$k" '.users[$e].last_reset_key = $k' "$DB" > "$DB.tmp" && mv "$DB.tmp" "$DB"
}

# Unlimited quota checker (0 atau <0 dianggap unlimited)
is_unlimited(){ [ "${1:-0}" -le 0 ]; }

# Normalisasi input strategi (kompatibel angka lama)
normalize_strategy(){
  local s="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')"
  case "$s" in
    ""|"no_reset"|"none"|"off"|"0") echo "no_reset" ;;
    "daily"|"day"|"harian"|"1")     echo "daily" ;;
    "weekly"|"week"|"mingguan"|"7") echo "weekly" ;;
    "monthly"|"month"|"bulanan"|"30") echo "monthly" ;;
    "yearly"|"year"|"tahunan"|"365") echo "yearly" ;;
    *) echo "no_reset" ;;
  esac
}

# Kunci periode (WIB) untuk deteksi pergantian periode
period_key_for(){ # strategy epoch -> echo key string
  local strat="$1" ts="${2:-$(date +%s)}"
  case "$strat" in
    daily)   TZ=Asia/Jakarta date -d "@$ts" +%Y-%m-%d ;;
    weekly)  TZ=Asia/Jakarta date -d "@$ts" +%G-%V ;;  # ISO week (Senin)
    monthly) TZ=Asia/Jakarta date -d "@$ts" +%Y-%m ;;
    yearly)  TZ=Asia/Jakarta date -d "@$ts" +%Y ;;
    *)       echo "" ;;
  esac
}

# === Unlimited helpers ===
is_unlimited(){ # quota_bytes -> 0 artinya unlimited
  local q="${1:-0}"; [ "$q" = "0" ]
}

fmt_quota(){ # quota_bytes -> "Unlimited" / human bytes
  local q="${1:-0}"
  if is_unlimited "$q"; then
    echo "Unlimited"
  else
    printf "%s" "$q" | fmt_bytes
  fi
}

fmt_remaining(){ # quota_bytes used_bytes -> "Unlimited" / human bytes
  local q="${1:-0}" u="${2:-0}"
  if is_unlimited "$q"; then
    echo "Unlimited"
  else
    local r=$(( q>u ? q-u : 0 ))
    printf "%s" "$r" | fmt_bytes
  fi
}

fmt_reset_strategy(){
  case "$1" in
    daily) echo "Daily" ;;
    weekly) echo "Weekly" ;;
    monthly) echo "Monthly" ;;
    yearly) echo "Yearly" ;;
    no_reset|"") echo "NoReset" ;;
  esac
}

get_domain(){ [ -f "$DOMAIN_FILE" ] && awk 'NF{print; exit}' "$DOMAIN_FILE" || echo "example.com"; }

get_inbound_field(){ # tag jq_path
  local tag="$1" jpath="$2"
  jq -r --arg tag "$tag" \
    ".inbounds[] | select(.tag==\$tag) | ${jpath} // empty" "$CFG"
}

get_inbound_wspath(){ # tag -> wsSettings.path
  get_inbound_field "$1" '.streamSettings.wsSettings.path' | trim
}

# Server password (SS2022 main key) dari inbound PRIMARY_TAG
get_server_key(){
  jq -r --arg tag "$PRIMARY_TAG" \
    '.inbounds[] | select(.tag==$tag) | .settings.password // empty' "$CFG" | trim
}

b64url(){ base64 -w0 | tr '+/' '-_' | tr -d '='; }

# SS2022 multi-user: password gabungan serverKey:userKey
# Gunakan path WS per-tag dari config; port publik tetap SS_PORT (443) di balik Nginx
make_ss_uri(){ # args: tag user_pw_b64 email
  local tag="$1" user_pw_b64="$2" email="$3" domain path tls plugin base base_b64 srv_pw
  domain="$(get_domain)"
  path="$(get_inbound_wspath "$tag")"; [ -n "$path" ] || path="/ss-ws"
  tls="$SS_TLS"
  srv_pw="$(get_server_key)"

  plugin="v2ray-plugin;mode=websocket;path=${path};host=${domain}"
  [ "$tls" = "yes" ] && plugin="${plugin};tls"
  local plugin_enc; plugin_enc="$(jq -rn --arg s "$plugin" '$s|@uri')"

  base="${SS_METHOD}:${srv_pw}:${user_pw_b64}@${domain}:${SS_PORT}"
  base_b64="$(printf "%s" "$base" | b64url)"
  printf "ss://%s?plugin=%s#%s@%s" "$base_b64" "$plugin_enc" "$email" "$tag"
}

docker_env(){ # container var (auto-trim)
  local cname="${1:-$SUBSRV_CONTAINER}" key="$2"
  docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$cname" 2>/dev/null \
    | awk -F= -v k="$key" '$1==k{ $1=""; sub(/^=/,""); print; exit }' | trim
}

subsrv_token_for_email(){ # email -> token hex (hmac sha256 from SUB_SECRET inside container)
  local email="$1"
  docker exec -i "$SUBSRV_CONTAINER" \
    python -c "import os,hmac,hashlib,sys; e=sys.argv[1]; s=os.environ['SUB_SECRET']; print(hmac.new(s.encode(), e.encode(), hashlib.sha256).hexdigest())" \
    "$email" | trim
}

make_sub_url(){ # email
  local email="$1" domain token base scheme
  domain="$(get_domain)"
  base="$(docker exec subsrv printenv SUB_BASE_HTML 2>/dev/null || echo s22)"
  token="$(
    docker exec -i subsrv python - "$email" <<'PY'
import os,hmac,hashlib,sys
email=sys.argv[1].strip()
secret=os.environ['SUB_SECRET']
print(hmac.new(secret.encode(), email.encode(), hashlib.sha256).hexdigest())
PY
  )"
  scheme="${SUB_SCHEME:-https}"
  printf "%s://%s/%s/%s?token=%s" "$scheme" "$domain" "$base" "$email" "$token"
}

tg_send(){ # text multi-baris
  [ -f "$TELEGRAM_CONF" ] || return 0
  # shellcheck disable=SC1090
  . "$TELEGRAM_CONF" 2>/dev/null || true
  local TOKEN="${TELEGRAM_BOT_TOKEN:-}" CHAT_ID="${TELEGRAM_CHAT_ID:-}"
  [ -n "$TOKEN" ] && [ -n "$CHAT_ID" ] || return 0
  curl -sS -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "parse_mode=HTML" \
    --data-urlencode "text=$1" >/dev/null || true
}

# ---- Xray API payload/wrappers (multi-inbound) ----
make_adu_payload_for_tag(){ # tag email pw_b64
  local tag="$1" email="$2" pw="$3"
  jq --arg tag "$tag" --arg email "$email" --arg pw "$pw" '
    { "inbounds": [ ( .inbounds[] | select(.tag==$tag)
      | .settings.clients = [ { "email": $email, "password": $pw } ] ) ] }' "$CFG"
}

api_adu_for_tag(){ # tag email pw_b64
  local tag="$1" email="$2" pw="$3" tmp
  tmp="$(mktemp)"
  make_adu_payload_for_tag "$tag" "$email" "$pw" > "$tmp"
  $XRAY api adu --server="$SERVER" "$tmp"
  rm -f "$tmp"
}

api_adu_multi(){ # email pw_b64 -> add ke semua INBOUND_TAGS
  local email="$1" pw="$2" IFS=',' tag
  for tag in $INBOUND_TAGS; do
    tag="$(echo "$tag" | xargs)"
    api_adu_for_tag "$tag" "$email" "$pw" >/dev/null 2>&1 || true
  done
}

api_rmu(){ # email -> rmu dari semua INBOUND_TAGS
  local email="$1" IFS=',' tag
  for tag in $INBOUND_TAGS; do
    tag="$(echo "$tag" | xargs)"
    $XRAY api rmu --server="$SERVER" -tag="$tag" "$email" >/dev/null 2>&1 || true
  done
}

# ---- High-level commands ----
cmd_add(){ # email quota_gb expire_days reset_days [max_devices]
  local email="$1" quota_gb="$2" expire_days="$3" reset_days="$4"
  local maxdev="${5:-$MAX_DEVICES_DEFAULT}"
  local pw quota_b now exp_at expire_s reset_s
  pw="$(head -c 16 /dev/urandom | base64 -w0)"
  quota_b="$(bytes_from_gb "$quota_gb")"
  expire_s="$(days_to_seconds "$expire_days")"
  reset_s="$(days_to_seconds "$reset_days")"
  now="$(now_epoch)"; exp_at=$(( now + expire_s ))

  # Tambah user ke SEMUA inbound
  api_adu_multi "$email" "$pw" >/dev/null

  # Persist DB
  db_set_user "$email" "$pw" "$quota_b" "$exp_at" "$reset_s" true
  db_set_maxdev "$email" "$maxdev"

  # Tentukan & simpan reset_strategy + last_reset_key
  local reset_arg="$reset_days"
  local strat; strat="$(normalize_strategy "$reset_arg")"
  db_set_reset_strategy "$email" "$strat"
  # inisialisasi key awal sesuai waktu pembuatan
  local key_now; key_now="$(period_key_for "$strat" "$now")"
  [ -n "$key_now" ] && db_set_last_reset_key "$email" "$key_now"

  # Inisialisasi akumulator tahan-restart
  db_set_last_raw "$email" "$(raw_total_bytes "$email")"
  db_set_cum_used "$email" 0

  local domain sub_url created_wib expire_wib srv_pw
  domain="$(get_domain)"
  sub_url="$(make_sub_url "$email")"
  created_wib="$(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")"
  expire_wib="$(TZ=Asia/Jakarta date -d "@$exp_at" +"%Y-%m-%d %H:%M:%S WIB")"
  srv_pw="$(get_server_key)"

  echo "Pembuatan akun BERHASIL!"
  echo "-=================================-"
  echo "+++++ ShadowSocks Account Created +++++"
  printf "Username : %s\n" "$email"
  printf "Domain   : %s\n" "$domain"
  printf "Password : %s:%s\n" "$srv_pw" "$pw"
  printf "Durasi   : %s hari\n" "$expire_days"
  printf "Protocol : Shadowsocks 2022 (%s) over WebSocket\n" "$SS_METHOD"
  printf "Akun dibuat pada : %s\n" "$created_wib"

  echo "URL (ss://) :"
  local ss_list=""
  IFS=','; for _tag in $INBOUND_TAGS; do
    _tag="$(echo "$_tag" | xargs)"
    local uri; uri="$(make_ss_uri "$_tag" "$pw" "$email")"
    printf " - [%s] %s\n" "$_tag" "$uri"
    ss_list="${ss_list}\n- [${_tag}] ${uri}"
  done; unset IFS

  echo "Subscription :"
  echo "$sub_url"
  printf "Expired : %s\n" "$expire_wib"
  echo
  printf "Quota    : %.2f GB\n" "$quota_gb"
  printf "Reset kuota setiap : %s hari\n" "$reset_days"
  printf "Max Device : %s\n" "$maxdev"

  tg_send "$(cat <<TXT
<b>Pembuatan akun BERHASIL</b>
———————————————
<b>ShadowSocks Account Created</b>
<b>Username</b>: <code>${email}</code>
<b>Domain</b>: <code>${domain}</code>
<b>Password</b>: <code>${srv_pw}:${pw}</code>
<b>Durasi</b>: ${expire_days} hari
<b>Limit Device</b>: ${maxdev} IP
<b>Path WS</b>: <code>/ss-ws</code> atau <code>/ss-ws-antiads</code> atau <code>/ss-ws-antiporn</code>
<b>Protocol</b>: SS 2022 (${SS_METHOD}) over WS
<b>Dibuat</b>: ${created_wib}
<b>Expired</b>: ${expire_wib}
<b>Quota</b>: ${quota_gb} GB (reset tiap ${reset_days} hari)
<b>Subscription</b>
${sub_url}
TXT
  )"

  echo "OK: add $email | quota=${quota_gb}GB | expire_in=${expire_s}s | reset_every=${reset_s}s | max_devices=${maxdev}"
}

cmd_delete(){ # email
  local email="$1"
  api_rmu "$email" >/dev/null || true
  db_set_enabled "$email" false

  tg_send "$(cat <<TXT
<b>Delete Akun (Soft)</b>
————————————
<b>User</b>  : <code>${email}</code>
<b>Status</b>: runtime removed, DB.enabled=false
<b>Waktu</b> : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"

  echo "OK: delete $email (runtime removed, DB.enabled=false)"
}

cmd_purge(){ # email
  local email="$1"
  api_rmu "$email" >/dev/null || true
  db_remove_user "$email"

  tg_send "$(cat <<TXT
<b>Purge Akun (Full Delete)</b>
———————————————
<b>User</b>  : <code>${email}</code>
<b>Status</b>: runtime removed & DB entry deleted
<b>Waktu</b> : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"

  echo "OK: purge $email (runtime removed, DB entry deleted)"
}

cmd_renew(){ # email add_days [new_quota_gb]
  local email="$1" add_days="$2" newq="${3:-}"
  db_exists "$email" || { echo "No such user in DB: $email"; exit 1; }

  local add_s now old_exp new_exp
  add_s="$(days_to_seconds "$add_days")"
  now="$(now_epoch)"
  old_exp="$(db_get "$email" expire_at)"; old_exp="${old_exp:-$now}"
  if [ "$old_exp" -lt "$now" ]; then new_exp=$(( now + add_s )); else new_exp=$(( old_exp + add_s )); fi
  db_set_expire "$email" "$new_exp"

  if [ -n "$newq" ]; then
    db_set_quota "$email" "$(bytes_from_gb "$newq")"
  fi

  local en pw; en="$(db_get "$email" enabled)"; pw="$(db_get "$email" password_b64)"
  if [ "$en" != "true" ]; then
    api_adu_multi "$email" "$pw" >/dev/null
    db_set_enabled "$email" true
  fi

  local exp_wib q_bytes q_gb sub_url
  exp_wib="$(TZ=Asia/Jakarta date -d "@$new_exp" +"%Y-%m-%d %H:%M:%S WIB")"
  q_bytes="$(db_get "$email" quota_bytes)"
  q_gb="$(awk -v b="${q_bytes:-0}" 'BEGIN{printf "%.2f", b/1024/1024/1024}')"
  sub_url="$(make_sub_url "$email")"

  tg_send "$(cat <<TXT
<b>Renew Akun</b>
———————
<b>ShadowSocks-WS Account Extended</b>

<b>User</b>        : <code>${email}</code>
<b>Tambah</b>      : ${add_days} hari
<b>Expired baru</b>: ${exp_wib}
<b>Quota</b>       : ${q_gb} GB$( [ -n "$newq" ] && printf " (set ulang dari argumen)" )
<b>Status</b>      : enabled (runtime dipastikan aktif)
<b>Subscription</b>
${sub_url}
TXT
  )"

  echo "OK: renew $email | +${add_s}s $( [ -n "$newq" ] && printf '| new_quota=%sGB' "$newq" )"
}

cmd_usage(){ # email
  local e="$1" used; used=$(update_usage_accumulate "$e")
  echo "User: $e"; echo "Total used: $used bytes ($(printf "%s" "$used" | fmt_bytes))"
}

cmd_online(){ api_online_count "$1"; }

cmd_onlineip(){
  local e="$1"
  local raw
  raw="$(api_online_iplist "$e")"
  if echo "$raw" | grep -q 'IPs:'; then
    echo "$raw" | sed -n 's/.*IPs: \[\(.*\)\].*/\1/p' | tr -d '"' | tr ',' '\n' | sed '/^\s*$/d'
  else
    echo "$raw"
  fi
}

maybe_reset_quota(){ # email now_epoch
  local e="$1" now="${2:-$(date +%s)}"

  # --- tentukan strategi (migrasi dari reset_every_seconds jika belum ada) ---
  local strat="$(db_get_reset_strategy "$e")"
  if [ -z "$strat" ]; then
    local old="$(db_get "$e" reset_every_seconds)"; old="${old:-0}"
    case "$old" in
      86400)    strat="daily"   ;;
      604800)   strat="weekly"  ;;
      2592000)  strat="monthly" ;;
      31536000) strat="yearly"  ;;
      *)        strat="no_reset";;
    esac
    db_set_reset_strategy "$e" "$strat"
  fi

  # tidak ada reset
  [ "$strat" = "no_reset" ] && return 0

  local key_now key_prev
  key_now="$(period_key_for "$strat" "$now")"
  key_prev="$(db_get_last_reset_key "$e")"

  # inisialisasi awal (jangan reset di siklus pertama)
  if [ -z "$key_prev" ]; then
    db_set_last_reset_key "$e" "$key_now"
    jq --arg e "$e" --argjson now "$now" '.users[$e].last_reset_at = $now' "$DB" > "$DB.tmp" && mv "$DB.tmp" "$DB"
    return 0
  fi

  # kalau periode berganti → lakukan reset
  if [ "$key_now" != "$key_prev" ]; then
    # nilai sebelum reset (untuk notifikasi)
    local before
    before="$(db_get_cum_used "$e")"; before="${before:-0}"
    if [ "$before" -le 0 ]; then
      before="$(db_get "$e" used_bytes)"; before="${before:-0}"
    fi

    # seed counter baru berdasarkan raw meter dari API
    local raw; raw="$(raw_total_bytes "$e")"; raw="${raw:-0}"

    # nolkan akumulator & catat ulang last_raw
    db_set_cum_used "$e" 0
    db_set_last_raw "$e" "$raw"
    jq --arg e "$e" --argjson now "$now" '
      .users[$e].used_bytes   = 0
      | .users[$e].last_reset_at = $now
    ' "$DB" > "$DB.tmp" && mv "$DB.tmp" "$DB"

    # auto re-enable + buka kunci jika sebelumnya nonaktif
    local was_en reenabled="false"
    was_en="$(db_get "$e" enabled)"; was_en="${was_en:-false}"
    if [ "$was_en" != "true" ]; then
      db_set_enabled "$e" true
      reenabled="true"
    fi
    db_set_lock "$e" 0

    # perbarui last_reset_key
    db_set_last_reset_key "$e" "$key_now"

    echo "RESET ($strat): $e"

    # --- kirim notif Telegram (opsional) ---
    local quota; quota="$(db_get "$e" quota_bytes)"; quota="${quota:-0}"
    local before_h quota_h
    before_h="$(printf "%s" "$before" | fmt_bytes)"
    quota_h="$(printf "%s" "$quota"  | fmt_bytes)"
    tg_send "$(cat <<TXT
<b>Reset Kuota</b>
—————————
<b>User</b>       : <code>${e}</code>
<b>Terpakai</b>   : ${before_h}
<b>Kuota</b>      : ${quota_h}
<b>Re-enabled</b> : ${reenabled}
<b>Waktu</b>      : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
    )"
  fi
}

cmd_resetnow(){ # email
  local e="$1" now; now="$(now_epoch)"

  # sebelum reset
  # local before="$(update_usage_accumulate "$e")"  # jika ada akumulator
  local before; before="$(db_get "$e" used_bytes)"; before="${before:-0}"
  local quota; quota="$(db_get "$e" quota_bytes)"; quota="${quota:-0}"

  # reset
  db_update_used "$e" 0
  db_set_last_reset "$e" "$now"

  # auto re-enable jika memenuhi kriteria
  local en exp lock pw reenabled="No"
  en="$(db_get "$e" enabled)"; en="${en:-false}"
  exp="$(db_get "$e" expire_at)"; exp="${exp:-0}"
  lock="$(db_get_lock "$e")"; lock="${lock:-0}"
  if [ "$en" != "true" ] \
     && { [ "$exp" -eq 0 ] || [ "$now" -lt "$exp" ]; } \
     && { [ "$lock" -eq 0 ] || [ "$lock" -le "$now" ]; }
  then
    pw="$(db_get "$e" password_b64)"
    if [ -n "$pw" ]; then
      api_adu_multi "$e" "$pw" >/dev/null 2>&1 || true
      db_set_enabled "$e" true
      [ "$lock" -gt 0 ] && [ "$lock" -le "$now" ] && db_set_lock "$e" 0
      reenabled="Yes"
    fi
  fi

  local before_h quota_h
  before_h="$(printf "%s" "$before" | fmt_bytes)"
  quota_h="$(printf "%s" "$quota"  | fmt_bytes)"
  tg_send "$(cat <<TXT
<b>Reset Kuota (Manual)</b>
——————————————
<b>User</b>       : <code>${e}</code>
<b>Terpakai</b>   : ${before_h}
<b>Kuota</b>      : ${quota_h}
<b>Re-enabled</b> : ${reenabled}
<b>Waktu</b>      : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"

  echo "OK: kuota ${e} di-reset sekarang (Re-enabled: ${reenabled})."
}

cmd_list(){
  init_db
  local now emails; now="$(now_epoch)"
  emails=$(jq -r '.users|keys[]' "$DB" 2>/dev/null || true)
  [ -n "$emails" ] || { echo "No users."; exit 0; }

  printf "%-30s  %-7s  %-12s  %-12s  %-12s  %-10s  %-8s  %-7s  %-8s\n" \
    "Email" "Enabled" "Quota" "Used" "Remaining" "Expire" "Online" "MaxDev" "Reset Quota"
  echo "---------------------------------------------------------------------------------------------------------------------------------"

  local e quota used en oc x exp_str mdev q_h u_h r_h strat strat_h
  for e in $emails; do
    quota="$(db_get "$e" quota_bytes)"; quota="${quota:-0}"
    used="$(update_usage_accumulate "$e")"; used="${used:-0}"
    mdev="$(db_get_maxdev "$e")"; mdev="${mdev:-0}"
    x="$(db_get "$e" expire_at)"; x="${x:-0}"
    en="$(db_get "$e" enabled)"; en="${en:-false}"

    # Expire: 0 => NoExp, selain itu N d
    if [ "$x" -le 0 ]; then
      exp_str="NoExp"
    else
      local exp_in_s exp_in_d
      exp_in_s=$(( x>now ? x-now : 0 ))
      exp_in_d="$(ceil_days "$exp_in_s")"
      exp_str="${exp_in_d}d"
    fi

    # Online count
    oc="$(api_online_count "$e" | awk -F': ' '/Count:/{print $2}' | tr -d '\r' 2>/dev/null)"; oc="${oc:-0}"

    # Format kuota
    q_h="$(fmt_quota "$quota")"
    u_h="$(printf "%s" "$used" | fmt_bytes)"
    r_h="$(fmt_remaining "$quota" "$used")"

    # Reset strategy (pastikan terdefinisi)
    strat="$(db_get_reset_strategy "$e")"; strat="${strat:-}"
    strat_h="$(fmt_reset_strategy "$strat")"

    printf "%-30s  %-7s  %-12s  %-12s  %-12s  %-10s  %-8s  %-7s  %-8s\n" \
      "$e" "$en" "$q_h" "$u_h" "$r_h" "$exp_str" "$oc" "$mdev" "$strat_h"
  done
}

cmd_enforce(){
  init_db
  local now emails; now="$(now_epoch)"
  emails=$(jq -r '.users|keys[]' "$DB" 2>/dev/null || true)
  [ -n "$emails" ] || { echo "No users to enforce."; exit 0; }

  local COOLDOWN="${DEVICE_COOLDOWN:-300}"

  for e in $emails; do
    maybe_reset_quota "$e" "$now"

    # --- metrik dasar ---
    local used quota x en
    used="$(update_usage_accumulate "$e")"; used="${used:-0}"
    quota="$(db_get "$e" quota_bytes)";   quota="${quota:-0}"
    x="$(db_get "$e" expire_at)";         x="${x:-0}"
    en="$(db_get "$e" enabled)";          en="${en:-false}"

    # --- 1) Expired -> disable ---
    if [ "$en" = "true" ]; then
      if [ "$x" -gt 0 ] && [ "$now" -ge "$x" ]; then
        api_rmu "$e" >/dev/null || true
        db_set_enabled "$e" false
        echo "DISABLED (expired): $e"
        continue
      fi
      # --- 2) Quota reached -> disable (skip jika unlimited) ---
      if ! is_unlimited "$quota" && [ "$used" -ge "$quota" ]; then
        api_rmu "$e" >/dev/null || true
        db_set_enabled "$e" false
        echo "DISABLED (quota reached): $e"
        continue
      fi
    fi

    # --- 3) Device limit ---
    local maxd lock oc
    maxd="$(db_get_maxdev "$e")"; maxd="${maxd:-0}"
    lock="$(db_get_lock "$e")";  lock="${lock:-0}"
    oc="$(api_online_count "$e" | awk -F': ' '/Count:/{print $2}' | tr -d '\r' 2>/dev/null)"; oc="${oc:-0}"

    # masih dalam masa lock -> pastikan disabled
    if [ "$lock" -gt "$now" ]; then
      if [ "$en" = "true" ]; then
        api_rmu "$e" >/dev/null || true
        db_set_enabled "$e" false
      fi
      continue
    fi

if [ "$en" = "true" ] && [ "$maxd" -gt 0 ] && [ "$oc" -gt "$maxd" ]; then
  api_rmu "$e" >/dev/null || true
  db_set_enabled "$e" false
  db_set_lock "$e" $(( now + COOLDOWN ))
  echo "DISABLED (device limit ${oc}/${maxd}): $e"

  tg_send "$(cat <<TXT
<b>Device Limit Terlampaui</b>
——————————————
<b>User</b>   : <code>${e}</code>
<b>Online</b> : ${oc}
<b>Max</b>    : ${maxd}
<b>Aksi</b>   : Disable ${COOLDOWN}s
<b>Waktu</b>  : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"
  continue
fi

    # --- AUTO-HEAL: aktifkan kembali user jika aman ---
    # syarat aman:
    #  - tidak expired (expire_at == 0 atau now < expire_at)
    #  - tidak dikunci device (lock_until <= now)
    #  - (unlimited) ATAU (used < quota)
    #  - device limit tidak dilanggar (maxd == 0 atau oc <= maxd)
    local is_unl=false
    [ "${quota:-0}" -le 0 ] && is_unl=true

    local safe_not_expired=false
    if [ "${x:-0}" -eq 0 ] || [ "$now" -lt "$x" ]; then
      safe_not_expired=true
    fi

    local safe_not_locked=true
    [ "${lock:-0}" -gt "$now" ] && safe_not_locked=false

    local safe_quota=false
    if [ "$is_unl" = true ]; then
      safe_quota=true
    else
      [ "${used:-0}" -lt "${quota:-0}" ] && safe_quota=true
    fi

    local safe_device=true
    if [ "${maxd:-0}" -gt 0 ] && [ "${oc:-0}" -gt "${maxd:-0}" ]; then
      safe_device=false
    fi

  if [ "$en" = "false" ] && \
     [ "$safe_not_expired" = true ] && \
     [ "$safe_not_locked" = true ] && \
     [ "$safe_quota" = true ] && \
     [ "$safe_device" = true ]; then
    # re-enable: tambah user ke semua inbound + set enabled=true
    api_adu_multi "$e" "$(db_get "$e" password_b64)" >/dev/null 2>&1 || true
    db_set_enabled "$e" true
    db_set_lock "$e" 0
    echo "ENABLED (auto-heal): $e"

  tg_send "$(cat <<TXT
<b>User Re-Enabled</b>
———————
<b>User</b>   : <code>${e}</code>
<b>Alasan</b> : Device kembali di bawah limit (${oc}/${maxd})
<b>Waktu</b>  : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"
    continue
  fi

    # 4) lock habis → re-enable jika syarat terpenuhi
    if [ "$en" != "true" ] && [ "$lock" -gt 0 ] && [ "$lock" -le "$now" ]; then
      if { [ "$x" -eq 0 ] || [ "$now" -lt "$x" ]; } && { [ "$quota" -eq 0 ] || [ "$used" -lt "$quota" ]; }; then
        local pw; pw="$(db_get "$e" password_b64)"
        if [ -n "$pw" ]; then
          api_adu_multi "$e" "$pw" >/dev/null || true
          db_set_enabled "$e" true
          db_set_lock "$e" 0
          echo "RE-ENABLED (lock expired): $e"
          tg_send "$(cat <<TXT
<b>User Re-Enabled</b>
———————
<b>User</b>  : <code>${e}</code>
<b>Alasan</b>: Lock timeout
<b>Waktu</b> : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
          )"
        fi
      fi
    fi

  done
  echo "Enforce done."
}

cmd_restore(){
  init_db
  local emails; emails=$(jq -r '.users|to_entries|map(select(.value.enabled==true))|.[].key' "$DB" 2>/dev/null || true)
  [ -n "$emails" ] || { echo "No enabled users to restore."; exit 0; }
  local e pw
  for e in $emails; do
    pw="$(db_get "$e" password_b64)"; [ -n "$pw" ] || continue
    api_adu_multi "$e" "$pw" >/dev/null || true
  done
  echo "Restore done."
}

cmd_link(){ # email -> cetak URL subscription valid (token dari container subsrv)
  local email="$1"
  echo "$(make_sub_url "$email")"
}

cmd_devset(){ # email max_devices
  local email="$1" maxd="$2"
  db_exists "$email" || { echo "No such user in DB: $email"; exit 1; }
  [[ "$maxd" =~ ^[0-9]+$ ]] || { echo "max_devices harus angka >=0"; exit 1; }
  db_set_maxdev "$email" "$maxd"
  tg_send "$(cat <<TXT
<b>Set Max Device</b>
———————
<b>User</b>     : <code>${email}</code>
<b>Max Device</b>: ${maxd}
<b>Waktu</b>    : $(TZ=Asia/Jakarta date +"%Y-%m-%d %H:%M:%S WIB")
TXT
  )"
  echo "OK: devset $email -> max_devices=$maxd"
}

cmd_devshow(){ # email
  local email="$1"
  db_exists "$email" || { echo "No such user in DB: $email"; exit 1; }
  local m; m="$(db_get_maxdev "$email")"
  echo "User: $email  max_devices: $m"
}

usage(){
  cat <<EOF
Usage:
  ss2022ctl add <email> <quota_gb> <expire_days> <reset_days> [max_devices]
  ss2022ctl delete <email>
  ss2022ctl purge <email>
  ss2022ctl renew <email> <add_days> [<new_quota_gb>]
  ss2022ctl list
  ss2022ctl usage <email>
  ss2022ctl online <email>
  ss2022ctl onlineip <email>
  ss2022ctl enforce
  ss2022ctl restore
  ss2022ctl link <email>        # cetak Subscription URL valid
  ss2022ctl devset <email> <max_devices>   # 0 = unlimited
  ss2022ctl devshow <email>
  ss2022ctl resetnow <email>

Examples:
  ss2022ctl add user1@example.com 30 30 30        # quota 30GB, expired 30 hari, reset kuota tiap 30 hari
  ss2022ctl add user1@example.com 30 30 30 1      # + batasi 1 device
  ss2022ctl renew user1@example.com 7 50          # tambah 7 hari & set quota 50GB
  ss2022ctl delete user1@example.com              # Soft delete
  ss2022ctl purge user1@example.com               # Hapus User sampai ke database
  ss2022ctl list
  ss2022ctl link user1@example.com
  ss2022ctl enforce
  ss2022ctl restore
  ss2022ctl resetnow user1@example.com
EOF
}

cmd="${1:-}"; shift || true
init_db
case "$cmd" in
  add)      [ $# -ge 4 ] && [ $# -le 5 ] || { usage; exit 1; }; cmd_add "$@" ;;
  delete)   [ $# -eq 1 ] || { usage; exit 1; }; cmd_delete "$@" ;;
  purge)    [ $# -eq 1 ] || { usage; exit 1; }; cmd_purge "$@" ;;
  renew)    [ $# -ge 2 ] && [ $# -le 3 ] || { usage; exit 1; }; cmd_renew "$@" ;;
  list)     cmd_list ;;
  usage)    [ $# -eq 1 ] || { usage; exit 1; }; cmd_usage "$@" ;;
  online)   [ $# -eq 1 ] || { usage; exit 1; }; cmd_online "$@" ;;
  onlineip) [ $# -eq 1 ] || { usage; exit 1; }; cmd_onlineip "$@" ;;
  enforce)  cmd_enforce ;;
  restore)  cmd_restore ;;
  link)     [ $# -eq 1 ] || { usage; exit 1; }; cmd_link "$@" ;;
  devset)   [ $# -eq 2 ] || { usage; exit 1; }; cmd_devset "$@" ;;
  devshow)  [ $# -eq 1 ] || { usage; exit 1; }; cmd_devshow "$@" ;;
  resetnow) [ $# -eq 1 ] || { usage; exit 1; }; cmd_resetnow "$@" ;;
  *)        usage; exit 1 ;;
esac
