# -*- coding: utf-8 -*-
import os
import sys
import subprocess
import json
import sqlite3
import random
import secrets
import string
import time
import re
import threading
import html
import traceback
from datetime import datetime, timezone

import telebot
from telebot import types
import requests

# ==================== تنظیمات اصلی ====================
TOKEN = (os.environ.get("BOT_TOKEN") or "").strip()
if not TOKEN:
    # برای Pydroid می‌توان local_config.example.py را به local_config.py تغییر نام داد
    # و توکن تازه را فقط روی خود گوشی در آن فایل محلی قرار داد.
    try:
        from local_config import BOT_TOKEN as _LOCAL_BOT_TOKEN
        TOKEN = (_LOCAL_BOT_TOKEN or "").strip()
    except (ImportError, AttributeError):
        pass
_LOCAL_OWNER = None
try:
    from local_config import OWNER_ID as _LOCAL_OWNER
except (ImportError, AttributeError):
    pass
OWNER_ID = int(os.environ.get("OWNER_ID") or _LOCAL_OWNER or "8018407998")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_FILE = os.environ.get("DB_FILE", "vpn_bot.db")
if not os.path.isabs(DB_FILE):
    DB_FILE = os.path.join(BASE_DIR, DB_FILE)
BOT_START_TS = time.time()
BACKUP_INTERVAL_SEC = 24 * 3600

DEFAULT_CUSTOM_EMOJI_ID = ""
# یوزرنیم ربات و لینک پیش‌فرض مینی‌اپ (اگر از پنل مدیریت لینک دیگری ثبت نشود، این استفاده می‌شود)
DEFAULT_BOT_USERNAME = "Panellsazbot"
DEFAULT_MINIAPP_URL = os.environ.get("MINIAPP_URL") or "https://admin22.technologybest.ir"
BUTTON_STYLES = {"primary": "primary", "success": "success", "danger": "danger"}


def dbx(sql, args=(), mode="none"):
    """اجرای کوئری روی دیتابیس. mode: none | one | all | id"""
    conn = sqlite3.connect(DB_FILE, timeout=30)
    try:
        cur = conn.execute(sql, args)
        if mode == "one":
            res = cur.fetchone()
        elif mode == "all":
            res = cur.fetchall()
        elif mode == "id":
            res = cur.lastrowid
        else:
            res = cur.rowcount
        conn.commit()
        return res
    finally:
        conn.close()


def get_setting(key: str) -> str:
    row = dbx("SELECT value FROM settings WHERE key = ?", (key,), "one")
    return row[0] if row and row[0] is not None else ""


def set_setting(key: str, value: str):
    dbx("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))


def colors_on() -> bool:
    return get_setting("colors_enabled") == "1"


def get_button_style_from_db(btn_id: str):
    if not colors_on():
        return None
    saved_style = get_setting(f"style_{btn_id}")
    if saved_style == "none":
        return None
    if saved_style in BUTTON_STYLES.values():
        return saved_style
    if str(btn_id).startswith(("adm_", "pg_", "usr_", "set_", "fj_")):
        return None
    key = str(btn_id).lower()
    if any(w in key for w in ("delete", "del_", "remove", "block", "cancel", "disable", "confirm_del")):
        return BUTTON_STYLES["danger"]
    if any(w in key for w in ("save", "confirm", "unblock", "enable", "add_")):
        return BUTTON_STYLES["success"]
    return BUTTON_STYLES["primary"]


def strip_button_emoji(value):
    if not isinstance(value, str):
        return value
    return re.sub(
        r"[\U0001F000-\U0001FAFF\u2600-\u27BF\u2300-\u23FF\uFE0E\uFE0F\u200D\u20E3\U0001F3FB-\U0001F3FF]",
        "", value).strip()


def _button_icon_key(text, callback_data=None, url=None):
    return str(callback_data or url or strip_button_emoji(text) or "")


def make_inline_button(*, text, callback_data=None, url=None, btn_id=None, style=None, keep_emoji=False, **kwargs):
    key = btn_id or _button_icon_key(text, callback_data, url)
    icon_id = get_setting(f"emoji_{key}") or DEFAULT_CUSTOM_EMOJI_ID
    if not icon_id:
        _pid, _ptxt = pem_button_icon(text)
        if _pid:
            icon_id, text, keep_emoji = _pid, _ptxt, True
    clean_text = text if (keep_emoji or not icon_id) else strip_button_emoji(text)
    if not clean_text:
        clean_text = str(text) or "—"
    if style == "none":
        final_style = None
    else:
        final_style = style if style is not None else get_button_style_from_db(key)
        final_style = final_style or None
    if not colors_on():
        final_style = None
    extra = dict(kwargs)
    try:
        return types.InlineKeyboardButton(
            text=clean_text, callback_data=callback_data, url=url,
            icon_custom_emoji_id=icon_id or None, style=final_style, **extra)
    except TypeError:
        button = types.InlineKeyboardButton(text=clean_text, callback_data=callback_data, url=url, **extra)
        try:
            button.icon_custom_emoji_id = icon_id or None
            button.style = final_style
        except Exception:
            pass
        return button


def make_keyboard_button(text, btn_id=None, **kwargs):
    key = btn_id or strip_button_emoji(text) or str(text)
    icon_id = get_setting(f"emoji_{key}") or DEFAULT_CUSTOM_EMOJI_ID
    _pid, _ptxt = (None, text) if icon_id else pem_button_icon(text)
    if _pid:
        icon_id = _pid
        clean_text = _ptxt
    else:
        clean_text = (strip_button_emoji(text) or str(text)) if icon_id else str(text)
    style = get_button_style_from_db(key)
    try:
        return types.KeyboardButton(text=clean_text, icon_custom_emoji_id=icon_id or None, style=style, **kwargs)
    except TypeError:
        button = types.KeyboardButton(text=clean_text, **kwargs)
        try:
            button.icon_custom_emoji_id = icon_id or None
            button.style = style
        except Exception:
            pass
        return button



# threaded=False: در حالت وبهوک هندلرها را خودمان در ThreadPool (passenger_wsgi.py) اجرا می‌کنیم
bot = telebot.TeleBot(TOKEN or "0:placeholder", threaded=False)


# ==================== وضعیت ماندگار (SQLite) ====================
# روی cPanel (Passenger) ممکن است چند پروسه بالا بیاید و پروسه‌ها هر از گاهی ری‌استارت شوند؛
# پس وضعیت مراحل کاربر (user_states / user_temp_data / ...) در دیتابیس نگه‌داری می‌شود نه در رم.
import pickle as _pickle

_kv_ready = False
_kv_lock = threading.Lock()


def _kv_init():
    global _kv_ready
    if _kv_ready:
        return
    with _kv_lock:
        if _kv_ready:
            return
        dbx("CREATE TABLE IF NOT EXISTS kv_state (name TEXT NOT NULL, k TEXT NOT NULL, "
            "v BLOB, ts INTEGER, PRIMARY KEY (name, k))")
        _kv_ready = True


class _PSub:
    """مقدار dict/list ماندگار؛ هر تغییر دوباره در دیتابیس ذخیره می‌شود."""
    _MUT = ("__setitem__", "__delitem__", "update", "pop", "popitem", "clear", "setdefault",
            "append", "extend", "insert", "remove", "sort", "reverse", "__iadd__")


def _make_sub(base, owner, key, val):
    ns = {}
    for m in _PSub._MUT:
        if hasattr(base, m):
            def _wrap(name):
                orig = getattr(base, name)

                def f(self, *a, **kw):
                    r = orig(self, *a, **kw)
                    owner._save(key, base(self) if base is list else dict(self))
                    return r
                return f
            ns[m] = _wrap(m)
    cls = type("P" + base.__name__.capitalize(), (base,), ns)
    return cls(val)


class PersistentMap:
    def __init__(self, name):
        self.name = name

    def _load(self, k):
        _kv_init()
        row = dbx("SELECT v FROM kv_state WHERE name = ? AND k = ?", (self.name, str(k)), "one")
        if not row:
            return False, None
        try:
            return True, _pickle.loads(row[0])
        except Exception:
            return False, None

    def _save(self, k, v):
        _kv_init()
        if isinstance(v, dict):
            v = dict(v)
        elif isinstance(v, list):
            v = list(v)
        dbx("INSERT OR REPLACE INTO kv_state (name, k, v, ts) VALUES (?, ?, ?, ?)",
            (self.name, str(k), _pickle.dumps(v, protocol=4), int(time.time())))

    def _wrap(self, k, v):
        if isinstance(v, dict):
            return _make_sub(dict, self, k, v)
        if isinstance(v, list):
            return _make_sub(list, self, k, v)
        return v

    def get(self, k, default=None):
        ok, v = self._load(k)
        return self._wrap(k, v) if ok else default

    def __getitem__(self, k):
        ok, v = self._load(k)
        if not ok:
            raise KeyError(k)
        return self._wrap(k, v)

    def __setitem__(self, k, v):
        self._save(k, v)

    def __contains__(self, k):
        return self._load(k)[0]

    def __delitem__(self, k):
        _kv_init()
        dbx("DELETE FROM kv_state WHERE name = ? AND k = ?", (self.name, str(k)))

    def pop(self, k, *default):
        ok, v = self._load(k)
        if ok:
            del self[k]
            return v
        if default:
            return default[0]
        raise KeyError(k)

    def setdefault(self, k, default=None):
        ok, v = self._load(k)
        if not ok:
            self._save(k, default)
            v = default
        return self._wrap(k, v)


def kv_cleanup(max_age=24 * 3600):
    _kv_init()
    dbx("DELETE FROM kv_state WHERE ts < ?", (int(time.time()) - max_age,))


user_states = PersistentMap("user_states")
user_temp_data = PersistentMap("user_temp_data")


# ==================== ایموجی پرمیوم سراسری (همه‌جای ربات) ====================
# هر ایموجی معمولی که در این لیست ثبت شود، در تمام پیام‌ها، کپشن‌ها و دکمه‌های ربات
# به صورت خودکار با نسخه پرمیومش جایگزین می‌شود.
import inspect as _pem_inspect

_PEM_CACHE = {"t": 0, "map": {}, "on": False, "rx": None}


def pem_norm(e):
    return (e or "").replace("\ufe0f", "").strip()


def pem_get_map():
    try:
        data = json.loads(get_setting("pem_map") or "{}")
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def pem_save_map(m):
    set_setting("pem_map", json.dumps(m, ensure_ascii=False))
    _PEM_CACHE["t"] = 0


def pem_is_on():
    return get_setting("pem_on") == "1"


def _pem_state():
    now = time.time()
    if now - _PEM_CACHE["t"] > 5:
        try:
            m = pem_get_map()
            on = pem_is_on()
        except Exception:
            m, on = {}, False
        rx = None
        if m:
            keys = sorted(m.keys(), key=len, reverse=True)
            parts = ["\ufe0f?".join(re.escape(ch) for ch in k) + "\ufe0f?" for k in keys if k]
            if parts:
                rx = re.compile("|".join(parts))
        _PEM_CACHE.update(t=now, map=m, on=on, rx=rx)
    return _PEM_CACHE


def pem_active():
    s = _pem_state()
    return bool(s["on"] and s["rx"])


def _pem_find(text):
    s = _pem_state()
    if not (s["on"] and s["rx"] and text):
        return []
    return [(mt.start(), mt.end(), s["map"].get(pem_norm(mt.group(0)))) for mt in s["rx"].finditer(text)
            if s["map"].get(pem_norm(mt.group(0)))]


def pem_html(text):
    """جایگزینی ایموجی‌ها در متن HTML (بیرون از تگ‌ها، code/pre و tg-emoji موجود)."""
    if not pem_active() or not text:
        return text
    out, skip = [], 0
    for seg in re.split(r"(<[^>]+>)", text):
        if seg.startswith("<") and seg.endswith(">"):
            tag = seg[1:].strip().lower()
            if re.match(r"(tg-emoji|code|pre)\b", tag):
                skip += 1
            elif re.match(r"/(tg-emoji|code|pre)\b", tag):
                skip = max(0, skip - 1)
            out.append(seg)
            continue
        if skip or not seg:
            out.append(seg)
            continue
        last, buf = 0, []
        for a, b, cid in _pem_find(seg):
            buf.append(seg[last:a])
            buf.append(f'<tg-emoji emoji-id="{html.escape(str(cid))}">{seg[a:b]}</tg-emoji>')
            last = b
        buf.append(seg[last:])
        out.append("".join(buf))
    return "".join(out)


def pem_entities(text, entities=None):
    """اضافه کردن entity ایموجی پرمیوم برای متن‌های بدون HTML."""
    ents = list(entities or [])
    found = _pem_find(text)
    if not found:
        return entities
    busy = []
    for e in ents:
        if getattr(e, "type", None) in ("custom_emoji", "code", "pre"):
            busy.append((e.offset, e.offset + e.length))
    for a, b, cid in found:
        off = utf16_len(text[:a])
        ln = utf16_len(text[a:b])
        if any(off < y and off + ln > x for x, y in busy):
            continue
        ents.append(types.MessageEntity(type="custom_emoji", offset=off, length=ln, custom_emoji_id=str(cid)))
    ents.sort(key=lambda e: e.offset)
    return ents


def pem_button_icon(text):
    """اولین ایموجی ثبت‌شده در متن دکمه را برمی‌گرداند: (id, متن بدون آن ایموجی)."""
    if not isinstance(text, str):
        return None, text
    found = _pem_find(text)
    if not found:
        return None, text
    a, b, cid = found[0]
    rest = (text[:a] + text[b:]).strip()
    return str(cid), (rest or text)


def _pem_convert(bound, text_key, ent_key):
    a = bound.arguments
    txt = a.get(text_key)
    if not isinstance(txt, str) or not txt:
        return False
    pm = a.get("parse_mode")
    if pm and str(pm).upper() == "HTML":
        new = pem_html(txt)
        if new != txt:
            a[text_key] = new
            return True
        return False
    if pm:  # Markdown → دست نمی‌زنیم
        return False
    new_ents = pem_entities(txt, a.get(ent_key))
    if new_ents is not a.get(ent_key) and new_ents:
        a[ent_key] = new_ents
        return True
    return False


def _pem_wrap(method_name, text_key, ent_key):
    orig = getattr(bot, method_name, None)
    if not orig:
        return
    try:
        sig = _pem_inspect.signature(orig)
    except Exception:
        return

    def wrapper(*args, **kwargs):
        if not pem_active():
            return orig(*args, **kwargs)
        try:
            bound = sig.bind(*args, **kwargs)
            changed = _pem_convert(bound, text_key, ent_key)
        except Exception:
            return orig(*args, **kwargs)
        if not changed:
            return orig(*args, **kwargs)
        try:
            return orig(*bound.args, **bound.kwargs)
        except telebot.apihelper.ApiTelegramException as e:
            if getattr(e, "error_code", 0) == 400 and "not modified" not in str(e).lower():
                return orig(*args, **kwargs)  # اگر تلگرام قبول نکرد، نسخه عادی ارسال شود
            raise

    setattr(bot, method_name, wrapper)


for _m, _t, _e in (("send_message", "text", "entities"), ("edit_message_text", "text", "entities"),
                   ("send_photo", "caption", "caption_entities"), ("send_document", "caption", "caption_entities"),
                   ("send_video", "caption", "caption_entities"), ("send_animation", "caption", "caption_entities"),
                   ("send_audio", "caption", "caption_entities"), ("send_voice", "caption", "caption_entities"),
                   ("edit_message_caption", "caption", "caption_entities"),
                   ("copy_message", "caption", "caption_entities")):
    _pem_wrap(_m, _t, _e)


def show_pem_menu(chat_id, msg_id):
    m = pem_get_map()
    on = pem_is_on()
    lines = ["✨ <b>مدیریت ایموجی پرمیوم</b>", "",
             f"وضعیت قابلیت: {'🟢 روشن' if on else '🔴 خاموش'}",
             f"تعداد ایموجی‌های ثبت‌شده: <b>{len(m)}</b>", ""]
    if m:
        preview = " ".join(f'<tg-emoji emoji-id="{html.escape(str(v))}">{html.escape(k)}</tg-emoji>'
                           for k, v in list(m.items())[:40])
        lines.append(f"ایموجی‌های فعال: {preview}")
    else:
        lines.append("هنوز ایموجویی ثبت نشده.")
    lines += ["", "<i>وقتی روشن باشد، هر جای ربات (پیام‌ها، کپشن‌ها و دکمه‌ها) که این ایموجی‌ها "
                  "استفاده شوند، نسخه پرمیوم نمایش داده می‌شود.</i>"]
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="افزودن ایموجی پرمیوم", callback_data="adm_pem_add", style="success"))
    kb.add(make_inline_button(text="📋 لیست ایموجی‌ها", callback_data="adm_pem_list", style="primary",
                              keep_emoji=True))
    kb.add(make_inline_button(text="خاموش کردن قابلیت" if on else "روشن کردن قابلیت",
                              callback_data="adm_pem_toggle", style="danger" if on else "success"))
    kb.add(make_inline_button(text="بازگشت", callback_data="adm_cat:3", style="danger"))
    update_message_safe(chat_id, msg_id, "\n".join(lines), reply_markup=kb)


def pem_callback(call, uid, chat_id, msg_id, data):
    td = user_temp_data.setdefault(uid, {})
    if data == "adm_pem_menu":
        user_states.pop(uid, None)
        show_pem_menu(chat_id, msg_id)
    elif data == "adm_pem_toggle":
        on = not pem_is_on()
        set_setting("pem_on", "1" if on else "0")
        _PEM_CACHE["t"] = 0
        safe_answer(call, "قابلیت روشن شد ✅" if on else "قابلیت خاموش شد")
        show_pem_menu(chat_id, msg_id)
        return
    elif data == "adm_pem_add":
        user_states[uid] = "pem_step_base"
        td["last_msg_id"] = msg_id
        td.pop("pem_base", None)
        update_message_safe(chat_id, msg_id,
                            "➕ <b>افزودن ایموجی پرمیوم</b>\n\n<b>مرحله ۱ از ۲</b>\n"
                            "ایموجی پایه (معمولی) را بفرستید؛ مثلاً: 🛒\n\n"
                            "💡 <i>میانبر: اگر مستقیم یک یا چند ایموجی پرمیوم بفرستید، همه با هم و "
                            "به صورت خودکار ثبت می‌شوند.</i>",
                            reply_markup=cancel_kb("adm_pem_menu"))
    elif data == "adm_pem_list":
        m = pem_get_map()
        if not m:
            safe_answer(call, "هنوز ایموجویی ثبت نشده.", True)
            return
        rows = [f"{html.escape(k)} ⬅️ <tg-emoji emoji-id=\"{html.escape(str(v))}\">{html.escape(k)}</tg-emoji>"
                f"  <code>{html.escape(str(v))}</code>" for k, v in m.items()]
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text="حذف ایموجی", callback_data="adm_pem_delmenu", style="danger"))
        kb.add(make_inline_button(text="بازگشت", callback_data="adm_pem_menu", style="danger"))
        update_message_safe(chat_id, msg_id, "📋 <b>لیست ایموجی‌های پرمیوم</b>\n\n" + "\n".join(rows[:80]),
                            reply_markup=kb)
    elif data == "adm_pem_delmenu":
        m = pem_get_map()
        if not m:
            safe_answer(call, "ایموجویی برای حذف وجود ندارد.", True)
            return
        kb = types.InlineKeyboardMarkup()
        btns = [make_inline_button(text=f"🗑 {k}", callback_data=f"adm_pem_del:{i}", style="danger",
                                   keep_emoji=True) for i, k in enumerate(m.keys())]
        for i in range(0, len(btns), 4):
            kb.row(*btns[i:i + 4])
        kb.add(make_inline_button(text="🧹 حذف همه", callback_data="adm_pem_delall", style="danger",
                                  keep_emoji=True))
        kb.add(make_inline_button(text="بازگشت", callback_data="adm_pem_list"))
        update_message_safe(chat_id, msg_id, "ایموجی مورد نظر برای حذف را انتخاب کنید:", reply_markup=kb)
    elif data.startswith("adm_pem_del:"):
        m = pem_get_map()
        keys = list(m.keys())
        idx = parse_int(data.split(":", 1)[1], -1)
        if not (0 <= idx < len(keys)):
            pem_callback(call, uid, chat_id, msg_id, "adm_pem_delmenu" if m else "adm_pem_menu")
            return
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="✅ بله، حذف شود", callback_data=f"adm_pem_delok:{idx}", style="danger",
                                  keep_emoji=True),
               make_inline_button(text="❌ نه، انصراف", callback_data="adm_pem_delmenu", style="success",
                                  keep_emoji=True))
        update_message_safe(chat_id, msg_id,
                            f"آیا مطمئنید می‌خواهید ایموجی {html.escape(keys[idx])} حذف شود؟", reply_markup=kb)
        safe_answer(call)
        return
    elif data.startswith("adm_pem_delok:"):
        m = pem_get_map()
        keys = list(m.keys())
        idx = parse_int(data.split(":", 1)[1], -1)
        if 0 <= idx < len(keys):
            m.pop(keys[idx], None)
            pem_save_map(m)
            safe_answer(call, "حذف شد ✅")
        pem_callback(call, uid, chat_id, msg_id, "adm_pem_delmenu" if m else "adm_pem_menu")
        return
    elif data == "adm_pem_delall":
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="✅ بله، همه حذف شوند", callback_data="adm_pem_delallok", style="danger",
                                  keep_emoji=True),
               make_inline_button(text="❌ نه، انصراف", callback_data="adm_pem_delmenu", style="success",
                                  keep_emoji=True))
        update_message_safe(chat_id, msg_id, "⚠️ آیا مطمئنید می‌خواهید <b>همهٔ</b> ایموجی‌های پرمیوم حذف شوند؟",
                            reply_markup=kb)
        safe_answer(call)
        return
    elif data == "adm_pem_delallok":
        pem_save_map({})
        safe_answer(call, "همه ایموجی‌ها حذف شدند.")
        show_pem_menu(chat_id, msg_id)
        return
    safe_answer(call)


def _pem_done_kb():
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="افزودن ایموجی جدید", callback_data="adm_pem_add", style="success"))
    kb.add(make_inline_button(text="مدیریت ایموجی", callback_data="adm_pem_menu", style="primary"))
    return kb


def pem_handle_state(uid, chat_id, state, message, td):
    if state not in ("pem_step_base", "pem_step_premium"):
        return False
    text = message.text or message.caption or ""
    ents = message.entities or message.caption_entities or []
    customs = [e for e in ents if getattr(e, "type", None) == "custom_emoji" and getattr(e, "custom_emoji_id", None)]
    m = pem_get_map()
    if state == "pem_step_base":
        if customs:  # ثبت گروهی مستقیم
            added = []
            for e in customs:
                glyph = pem_norm(utf16_slice(text, e.offset, e.length))
                if glyph:
                    m[glyph] = str(e.custom_emoji_id)
                    added.append(f'<tg-emoji emoji-id="{e.custom_emoji_id}">{html.escape(glyph)}</tg-emoji>')
            pem_save_map(m)
            user_states.pop(uid, None)
            step_reply(uid, chat_id, f"✅ {len(added)} ایموجی پرمیوم ثبت شد:\n{' '.join(added)}", _pem_done_kb())
            return True
        base = pem_norm(text)
        if not base or len(base) > 8 or re.search(r"[\w\s]", base):
            step_reply(uid, chat_id, "❌ لطفاً فقط یک ایموجی معمولی بفرستید (مثلاً 🛒):",
                       cancel_kb("adm_pem_menu"))
            return True
        td["pem_base"] = base
        user_states[uid] = "pem_step_premium"
        step_reply(uid, chat_id, f"<b>مرحله ۲ از ۲</b>\nایموجی پایه: {html.escape(base)}\n\n"
                                 "حالا خود ایموجی پرمیوم را مستقیماً از تلگرام ارسال کنید "
                                 "(یا آیدی عددی آن را بفرستید).", cancel_kb("adm_pem_menu"))
        return True
    # pem_step_premium
    cid = str(customs[0].custom_emoji_id) if customs else (to_en_digits(text.strip()) if
                                                           re.fullmatch(r"\d{10,25}", to_en_digits(text.strip())) else "")
    if not cid:
        step_reply(uid, chat_id, "❌ ایموجی پرمیوم تشخیص داده نشد.\nدوباره ایموجی پرمیوم را بفرستید:",
                   cancel_kb("adm_pem_menu"))
        return True
    base = td.pop("pem_base", "")
    m[base] = cid
    pem_save_map(m)
    user_states.pop(uid, None)
    step_reply(uid, chat_id, f'✅ ایموجی پرمیوم ثبت شد.\n{html.escape(base)} ⬅️ '
                             f'<tg-emoji emoji-id="{cid}">{html.escape(base)}</tg-emoji>', _pem_done_kb())
    return True


def utf16_slice(text, offset, length):
    raw = text.encode("utf-16-le")
    return raw[offset * 2:(offset + length) * 2].decode("utf-16-le", errors="ignore")



# ==================== ارتباط با API پاسارگاد ====================
def get_server_panel_by_id(pid):
    return dbx("SELECT id, name, url, username, password, group_name FROM server_panels WHERE id = ?", (pid,), "one")


def get_all_server_panels():
    return dbx("SELECT id, name, url, username, password, group_name FROM server_panels", (), "all")


def get_default_or_first_panel():
    panels = get_all_server_panels()
    return panels[0] if panels else None


def _panel_info_dict(panel_id=None):
    row = get_server_panel_by_id(panel_id) if panel_id else None
    if not row:
        row = get_default_or_first_panel()
    if not row:
        return None
    return {"id": row[0], "name": row[1], "url": row[2], "username": row[3],
            "password": row[4], "group_name": row[5]}


def clean_panel_url(text: str) -> str:
    u = (text or "").strip().rstrip("/")
    for suffix in ("/dashboard", "/dashboard/login", "/login"):
        if u.lower().endswith(suffix):
            u = u[: -len(suffix)]
    return u.rstrip("/")


_token_cache = {}


def get_pasarguard_token(panel_info=None):
    panel_info = panel_info or _panel_info_dict()
    if not panel_info:
        return None
    url_base = (panel_info.get("url") or "").rstrip("/")
    u, p = panel_info.get("username"), panel_info.get("password")
    if not url_base or not u or not p:
        return None
    if str(p).startswith("pg_key_"):
        return str(p)
    ck = (url_base, u, p)
    cached = _token_cache.get(ck)
    if cached and cached[1] > time.time():
        return cached[0]
    try:
        res = requests.post(f"{url_base}/api/admin/token", data={"username": u, "password": p},
                            headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=12)
        if res.status_code == 200:
            tok = res.json().get("access_token")
            if tok:
                _token_cache[ck] = (tok, time.time() + 300)
                return tok
    except Exception:
        pass
    return None


def get_admin_sudo_status(url_base, token):
    headers = {"Authorization": f"Bearer {token}"}
    for ep in ("/api/admin", "/api/admin/current"):
        try:
            r = requests.get(f"{url_base}{ep}", headers=headers, timeout=8)
            if r.status_code == 200:
                d = r.json()
                if isinstance(d, dict) and "is_sudo" in d:
                    return bool(d["is_sudo"])
        except Exception:
            pass
    return None


def test_pasarguard_connection(panel_info=None):
    panel_info = panel_info or _panel_info_dict()
    if not panel_info:
        return False, "هیچ پنلی در ربات ثبت نشده است."
    url_base = (panel_info.get("url") or "").rstrip("/")
    u, p = panel_info.get("username"), panel_info.get("password")
    if not url_base:
        return False, "آدرس پنل هنوز وارد نشده است."
    if not u or not p:
        return False, "نام کاربری یا رمز عبور ادمین ثبت نشده است."
    if str(p).startswith("pg_key_"):
        try:
            r = requests.get(f"{url_base}/api/users", headers={"Authorization": f"Bearer {p}"},
                             params={"limit": 1}, timeout=12)
            if r.status_code == 200:
                sudo = get_admin_sudo_status(url_base, str(p))
                sudo_txt = {True: "کلید سودو است ✅", False: "کلید سودو نیست ⚠️", None: "وضعیت سودو بودن مشخص نشد"}[sudo]
                return True, f"اتصال با API key موفق بود.\n{sudo_txt}"
            if r.status_code in (401, 403):
                return False, f"API key نامعتبر است یا دسترسی ندارد (کد {r.status_code})."
            return False, f"خطای پنل با API key (کد {r.status_code}): {r.text[:120]}"
        except Exception as e:
            return False, f"خطای شبکه: {str(e)}"
    try:
        res = requests.post(f"{url_base}/api/admin/token", data={"username": u, "password": p},
                            headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=12)
        if res.status_code == 200:
            tok = res.json().get("access_token")
            sudo = get_admin_sudo_status(url_base, tok) if tok else None
            sudo_txt = {True: "ادمین سودو است ✅", False: "ادمین سودو نیست ⚠️ (فقط حالت «اشتراک» کار می‌کند)",
                        None: "وضعیت سودو بودن مشخص نشد"}[sudo]
            return True, f"ارتباط موفقیت‌آمیز بود و توکن دریافت شد.\n{sudo_txt}"
        if res.status_code in (400, 401, 422):
            return False, f"نام کاربری یا رمز عبور اشتباه است (کد {res.status_code})."
        if res.status_code == 404:
            return False, "آدرس اشتباه است (کد 404). آدرس باید فقط دامنه و پورت باشد، بدون /dashboard."
        return False, f"خطای سرور پنل (کد {res.status_code}): {res.text[:120]}"
    except requests.exceptions.SSLError:
        return False, "خطای SSL. اگر پنل https ندارد از http:// استفاده کنید."
    except requests.exceptions.ConnectionError:
        return False, "اتصال برقرار نشد. آدرس و پورت و https/http را بررسی کنید."
    except requests.exceptions.Timeout:
        return False, "زمان پاسخ‌دهی پنل به پایان رسید (Timeout)."
    except Exception as e:
        return False, f"خطای شبکه: {str(e)}"


def _fallback_group_records(url_base, headers):
    records = {}
    misses = 0
    for i in range(1, 41):
        try:
            r = requests.get(f"{url_base}/api/group/{i}", headers=headers, timeout=5)
        except Exception:
            break
        if r.status_code in (401, 403, 405):
            break
        if r.status_code == 200:
            misses = 0
            try:
                name = r.json().get("name")
            except Exception:
                name = None
            records[i] = {"id": i, "name": str(name).strip() if name else f"گروه {i}"}
        else:
            misses += 1
            if (records and misses >= 3) or misses >= 4:
                break
    if records:
        return sorted(records.values(), key=lambda x: x["id"])
    try:
        r = requests.get(f"{url_base}/api/users", headers=headers, params={"limit": 100}, timeout=10)
        if r.status_code == 200:
            d = r.json()
            users = d.get("users", []) if isinstance(d, dict) else d
            for usr in users or []:
                for g in (usr.get("group_ids") or []):
                    if isinstance(g, int):
                        records[g] = {"id": g, "name": f"گروه {g}"}
    except Exception:
        pass
    return sorted(records.values(), key=lambda x: x["id"])


def fetch_panel_group_records(panel_info=None):
    panel_info = panel_info or _panel_info_dict()
    if not panel_info:
        return [], "هیچ پنلی متصل نیست."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not url_base or not token:
        return [], "عدم اتصال به پنل یا توکن نامعتبر"
    headers = {"Authorization": f"Bearer {token}"}
    errors = []
    forbidden = False
    for ep in ("/api/groups", "/api/inbounds"):
        try:
            res = requests.get(f"{url_base}{ep}", headers=headers, timeout=10)
            if res.status_code == 403:
                forbidden = True
            if res.status_code != 200:
                errors.append(f"{ep} -> HTTP {res.status_code}")
                continue
            data = res.json()
            if isinstance(data, dict):
                for key in ("groups", "items", "results", "data", "obj", "inbounds"):
                    if key in data and isinstance(data[key], (list, dict)):
                        data = data[key]
                        break
            if isinstance(data, dict):
                data = list(data.values())
            if not isinstance(data, list):
                errors.append(f"{ep} -> فرمت داده نامعتبر")
                continue
            records = []
            for item in data:
                if not isinstance(item, dict):
                    continue
                name = item.get("name") or item.get("group_name") or item.get("tag")
                gid = item.get("id")
                if gid is None:
                    gid = item.get("group_id")
                if gid is None:
                    gid = item.get("tag")
                if name and gid is not None:
                    records.append({"id": gid, "name": str(name).strip()})
            if records:
                unique = {r["name"]: r for r in records}
                return sorted(unique.values(), key=lambda x: x["name"].lower()), None
            errors.append(f"{ep} -> لیست خالی")
        except Exception:
            errors.append(f"{ep} -> خطای ارتباط")
    fb = _fallback_group_records(url_base, headers)
    if fb:
        return fb, None
    msg = " | ".join(errors)
    if forbidden:
        msg += " | ادمین پنل سودو نیست."
    return [], msg


def discover_all_group_ids(panel_info=None):
    panel_info = panel_info or _panel_info_dict()
    if not panel_info:
        return []
    ids = set()
    records, _ = fetch_panel_group_records(panel_info)
    for r in records:
        try:
            ids.add(int(r["id"]))
        except (ValueError, TypeError):
            pass
    return sorted(ids)


def resolve_group_ids(saved_grp: str, panel_info=None):
    saved_grp = (saved_grp or "").strip()
    if not saved_grp or saved_grp == "0":
        return [], None
    tokens = [g.strip() for g in saved_grp.split(",") if g.strip()]
    numeric_ids = [int(t) for t in tokens if t.isdigit()]
    name_tokens = [t for t in tokens if not t.isdigit() and t != "ALL"]
    need_api = (saved_grp == "ALL") or bool(name_tokens)
    records = []
    if need_api:
        records, _ = fetch_panel_group_records(panel_info)
    name_to_id = {g["name"]: g["id"] for g in records}
    lower = {k.lower(): v for k, v in name_to_id.items()}
    if saved_grp == "ALL":
        ids = list(name_to_id.values())
    else:
        ids = list(numeric_ids)
        for n in name_tokens:
            gid = name_to_id.get(n, lower.get(n.lower()))
            if gid is not None:
                ids.append(gid)
    clean, seen = [], set()
    for i in ids:
        try:
            i = int(i)
        except (ValueError, TypeError):
            continue
        if i not in seen:
            seen.add(i)
            clean.append(i)
    return clean, None


def _full_link(url_base, data):
    s = data.get("subscription_url")
    if s:
        s = str(s)
        return url_base + s if s.startswith("/") else s
    return f"{url_base}/sub/{data.get('subscription_token')}"


def user_exists_in_panel(username: str, panel_info=None) -> bool:
    panel_info = panel_info or _panel_info_dict()
    if not panel_info:
        return False
    token = get_pasarguard_token(panel_info)
    if not token:
        return False
    try:
        res = requests.get(f"{panel_info['url'].rstrip('/')}/api/user/{username}",
                           headers={"Authorization": f"Bearer {token}"}, timeout=6)
        return res.status_code == 200
    except Exception:
        return False


def create_pasarguard_user(username: str, data_limit_mb: float, expire_hours: float, panel_id=None):
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "هیچ پنلی به ربات متصل نیست."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    saved_grp = panel_info.get("group_name") or "ALL"
    if not url_base or not token:
        return False, f"اتصال به پنل «{panel_info['name']}» برقرار نشد. آدرس/یوزر/پسورد را بررسی کنید."
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    final_uname = username
    db_exist = dbx("SELECT 1 FROM user_panels WHERE username = ?", (final_uname,), "one")
    if db_exist or user_exists_in_panel(final_uname, panel_info):
        final_uname = f"{username}_{random.randint(100, 999)}"

    data_limit_bytes = int(data_limit_mb * 1024 * 1024) if data_limit_mb > 0 else 0
    expire_timestamp = int(time.time() + (expire_hours * 3600)) if expire_hours > 0 else 0
    payload = {"username": final_uname, "data_limit": data_limit_bytes, "expire": expire_timestamp,
               "status": "active", "proxy_settings": {}, "proxies": {}, "inbounds": {}}

    if saved_grp != "0":
        grp_ids, _ = resolve_group_ids(saved_grp, panel_info)
        if not grp_ids:
            return False, ("هیچ گروهی از پنل «" + str(panel_info["name"]) + "» دریافت نشد "
                           "(ادمین سودو نیست). در پنل مدیریت ← تنظیمات پنل پاسارگاد ← این پنل ← «انتخاب گروه پنل» ← "
                           "«تایپ دستی ID گروه» عدد گروه را وارد کنید (مثلاً 1).")
        payload["group_ids"] = grp_ids

    try:
        res = requests.post(f"{url_base}/api/user", json=payload, headers=headers, timeout=20)
        if res.status_code not in (200, 201) and "group" in res.text.lower() and "not found" in res.text.lower():
            payload.pop("group_ids", None)
            res = requests.post(f"{url_base}/api/user", json=payload, headers=headers, timeout=20)
        if res.status_code in (200, 201):
            return True, (final_uname, _full_link(url_base, res.json()), panel_info["id"])
        try:
            detail = res.json().get("detail", res.text[:150])
        except Exception:
            detail = res.text[:150]
        return False, f"خطای پنل (کد {res.status_code}): {detail}"
    except Exception as e:
        return False, str(e)


def delete_pasarguard_user(username: str, panel_id=None):
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False
    token = get_pasarguard_token(panel_info)
    if not token:
        return False
    try:
        res = requests.delete(f"{panel_info['url'].rstrip('/')}/api/user/{username}",
                              headers={"Authorization": f"Bearer {token}"}, timeout=10)
        return res.status_code in (200, 204, 404)
    except Exception:
        return False


def revoke_pasarguard_sub(username: str, panel_id=None):
    """ساخت لینک اشتراک جدید؛ لینک قبلی بلافاصله از کار می‌افتد. خروجی: (ok, لینک_جدید | پیام خطا)"""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل یافت نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token or not url_base:
        return False, "اتصال به پنل برقرار نشد."
    try:
        res = requests.post(f"{url_base}/api/user/{username}/revoke_sub",
                            headers={"Authorization": f"Bearer {token}"}, timeout=15)
        if res.status_code in (200, 201):
            return True, _full_link(url_base, res.json())
        if res.status_code == 404:
            return False, "این سرویس در پنل پیدا نشد (ممکن است حذف شده باشد)."
        try:
            detail = res.json().get("detail", res.text[:120])
        except Exception:
            detail = res.text[:120]
        return False, f"خطای پنل (کد {res.status_code}): {detail}"
    except Exception as e:
        return False, str(e)


def _modify_pasarguard_admin(username: str, fields: dict, panel_id=None):
    """ویرایش ادمین در پاسارگاد (PUT). خروجی: (ok, پیام_خطا | None)"""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل سرور یافت نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token or not url_base:
        return False, "اتصال به پنل سرور برقرار نشد."
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    attempts = [dict(fields)]
    try:
        r = requests.get(f"{url_base}/api/admin/{username}", headers=headers, timeout=10)
        if r.status_code == 200:
            cur = r.json()
            if isinstance(cur, dict):
                merged = {k: cur[k] for k in ("is_sudo", "role_id") if cur.get(k) is not None}
                merged.update(fields)
                attempts.append(merged)
    except Exception:
        pass
    last = ""
    for payload in attempts:
        try:
            res = requests.put(f"{url_base}/api/admin/{username}", json=payload, headers=headers, timeout=15)
        except Exception as e:
            return False, str(e)
        if res.status_code in (200, 201):
            return True, None
        if res.status_code in (401, 403):
            return False, "ادمین پنل سودو نیست و اجازه ویرایش ادمین‌ها را ندارد."
        if res.status_code == 404:
            return False, "این پنل در سرور پیدا نشد (ممکن است حذف شده باشد)."
        try:
            last = res.json().get("detail", res.text[:150])
        except Exception:
            last = res.text[:150]
    return False, f"خطای پنل: {last}"


def set_pasarguard_user_status(username: str, active: bool, panel_id=None):
    """فعال/غیرفعال کردن سرویس اشتراکی. خروجی: (ok, پیام_خطا | None)"""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل سرور یافت نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token or not url_base:
        return False, "اتصال به پنل سرور برقرار نشد."
    try:
        res = requests.put(f"{url_base}/api/user/{username}",
                           json={"status": "active" if active else "disabled"},
                           headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
                           timeout=15)
        if res.status_code in (200, 201):
            return True, None
        if res.status_code == 404:
            return False, "این سرویس در پنل پیدا نشد (ممکن است حذف شده باشد)."
        try:
            detail = res.json().get("detail", res.text[:150])
        except Exception:
            detail = res.text[:150]
        return False, f"خطای پنل (کد {res.status_code}): {detail}"
    except Exception as e:
        return False, str(e)


def _panel_timestamp(value):
    if value is None or str(value).strip() in ("", "0", "None"):
        return 0
    try:
        stamp = int(float(value))
        return int(stamp / 1000) if stamp > 100000000000 else stamp
    except (ValueError, TypeError):
        try:
            return int(datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp())
        except Exception:
            return 0


def _fetch_panel_admin(url_base, headers, username):
    """اطلاعات یک ادمین پنل. در پاسارگاد GET /api/admin/{username} وجود ندارد (خطای 405)،
    پس اگر جواب نداد از لیست ادمین‌ها می‌خوانیم. خروجی: (dict | None, توضیح خطا)"""
    notes = []
    try:
        r = requests.get(f"{url_base}/api/admin/{username}", headers=headers, timeout=10)
        if r.status_code == 200:
            data = r.json()
            if isinstance(data, dict) and data:
                return data, ""
        else:
            notes.append(f"/api/admin/{{username}}: {r.status_code}")
    except Exception as e:
        notes.append(str(e)[:80])
    want = str(username).strip().lower()
    for base_params in ({"username": username, "limit": 100}, {"limit": 100}):
        offset = 0
        for _ in range(30):
            params = dict(base_params)
            params["offset"] = offset
            try:
                r = requests.get(f"{url_base}/api/admins", headers=headers, params=params, timeout=10)
            except Exception as e:
                notes.append(f"/api/admins: {str(e)[:80]}")
                break
            if r.status_code != 200:
                notes.append(f"/api/admins: {r.status_code}")
                break
            try:
                items = [x for x in _as_list(r.json()) if isinstance(x, dict)]
            except Exception:
                break
            for item in items:
                if str(item.get("username", "")).strip().lower() == want:
                    return item, ""
            if len(items) < params["limit"]:
                break
            offset += params["limit"]
    return None, ", ".join(dict.fromkeys(notes))


def renew_pasarguard_account(username, panel_id, kind, traffic_gb, days):
    """Add the purchased quota/time to an existing account on the configured panel."""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل سرور پیدا نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token:
        return False, "اتصال به پنل برقرار نشد."
    endpoint = "admin" if kind == "admin" else "user"
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    try:
        if kind == "admin":
            current, why = _fetch_panel_admin(url_base, headers, username)
            if current is None:
                return False, f"سرویس در پنل پیدا نشد یا دسترسی خواندن ادمین‌ها وجود ندارد ({why or 'ادمین در لیست نبود'})."
        else:
            current_response = requests.get(f"{url_base}/api/{endpoint}/{username}",
                                            headers=headers, timeout=12)
            if current_response.status_code != 200:
                return False, f"سرویس در پنل پیدا نشد یا دسترسی ویرایش وجود ندارد (کد {current_response.status_code})."
            current = current_response.json()
        current_limit = int(current.get("data_limit") or current.get("admin_limit") or
                            current.get("traffic_limit") or 0)
        current_expire = _panel_timestamp(current.get("expire", current.get("expire_ts")))
        if kind == "admin" and current_expire <= 0:
            local_expiry = dbx("SELECT expire_ts FROM user_panels WHERE username = ? AND panel_id = ?",
                               (username, panel_id), "one")
            if local_expiry and local_expiry[0]:
                current_expire = int(local_expiry[0])
        if int(traffic_gb or 0) <= 0:
            new_limit = 0
        elif current_limit <= 0:
            new_limit = 0
        else:
            new_limit = current_limit + int(traffic_gb) * (1024 ** 3)
        now = int(time.time())
        if int(days or 0) <= 0:
            new_expire = 0
        elif current_expire <= 0:
            new_expire = 0
        else:
            new_expire = max(now, current_expire) + int(days) * 86400
        payload = {"data_limit": int(new_limit), "expire": int(new_expire)}
        if kind == "admin":
            admin_payloads = [
                {"data_limit": int(new_limit), "expire": int(new_expire), "is_disabled": False},
                {"admin_limit": int(new_limit), "expire": int(new_expire), "is_disabled": False},
                {"data_limit": int(new_limit), "is_disabled": False},
                {"admin_limit": int(new_limit), "is_disabled": False},
            ]
            response = None
            for admin_payload in admin_payloads:
                response = requests.put(f"{url_base}/api/admin/{username}", json=admin_payload,
                                        headers=headers, timeout=15)
                if response.status_code in (200, 201, 204):
                    break
        else:
            payload["status"] = "active"
            response = requests.put(f"{url_base}/api/user/{username}", json=payload,
                                    headers=headers, timeout=15)
        if response.status_code not in (200, 201, 204):
            try:
                reason = response.json().get("detail", response.text[:200])
            except Exception:
                reason = response.text[:200]
            return False, f"پنل تمدید را نپذیرفت (کد {response.status_code}): {reason}"
        if kind == "admin":
            lk = dbx("SELECT id, password, locked_pw FROM user_panels WHERE username = ? AND panel_id = ?",
                     (username, panel_id), "all")
            for _lid, _lpw, _llock in lk:
                if _llock and _lpw:
                    unlock_admin_panel(username, panel_id, _lpw, _lid)
        dbx("UPDATE user_panels SET expire_ts = ?, is_disabled = 0, expire_warned_at = 0, "
            "delete_after_ts = 0 WHERE username = ? AND panel_id = ?",
            (new_expire, username, panel_id))
        return True, {"traffic_gb": int(traffic_gb or 0), "days": int(days or 0),
                      "expire_ts": new_expire}
    except Exception as e:
        return False, str(e)


def adjust_pasarguard_service(username, panel_id, kind, adjustment, unit):
    """Adjust an existing service's traffic limit (GB) or expiry (days)."""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل سرور پیدا نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token:
        return False, "اتصال به پنل برقرار نشد."
    endpoint = "admin" if kind == "admin" else "user"
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    try:
        if kind == "admin":
            current, why = _fetch_panel_admin(url_base, headers, username)
            if current is None:
                return False, f"سرویس در پنل پیدا نشد یا دسترسی خواندن ادمین‌ها وجود ندارد ({why or 'ادمین در لیست نبود'})."
        else:
            response = requests.get(f"{url_base}/api/{endpoint}/{username}", headers=headers, timeout=12)
            if response.status_code != 200:
                return False, f"سرویس در پنل پیدا نشد یا دسترسی ویرایش وجود ندارد (کد {response.status_code})."
            current = response.json()
        if not isinstance(current, dict):
            return False, "پاسخ پنل قابل‌خواندن نیست."

        if unit == "days":
            current_expire = _panel_timestamp(current.get("expire", current.get("expire_ts")))
            if current_expire <= 0:
                return False, "زمان سرویس نامحدود است؛ ابتدا باید در پنل برای آن زمان پایان تعیین شود."
            new_expire = current_expire + int(adjustment) * 86400
            if new_expire <= int(time.time()):
                return False, "با این مقدار، زمان سرویس در گذشته قرار می‌گیرد."
            fields = {"expire": int(new_expire)}
        elif unit == "traffic":
            current_limit = int(current.get("data_limit") or current.get("admin_limit") or
                                current.get("traffic_limit") or 0)
            if current_limit <= 0:
                return False, "حجم سرویس نامحدود است و قابل‌کاهش یا افزایش عددی نیست."
            used = int(current.get("used_traffic") or 0)
            new_limit = current_limit + int(round(float(adjustment) * (1024 ** 3)))
            if new_limit <= 0 or new_limit < used:
                return False, "حجم نهایی باید بیشتر از حجم مصرف‌شده و بزرگ‌تر از صفر باشد."
            limit_key = "admin_limit" if kind == "admin" and "admin_limit" in current and "data_limit" not in current else "data_limit"
            fields = {limit_key: int(new_limit)}
        else:
            return False, "نوع تغییر سرویس معتبر نیست."

        payloads = [fields]
        if kind == "admin" and "data_limit" in fields:
            payloads.append({"admin_limit": fields["data_limit"]})
        result = None
        for payload in payloads:
            result = requests.put(f"{url_base}/api/{endpoint}/{username}", json=payload,
                                  headers=headers, timeout=15)
            if result.status_code in (200, 201, 204):
                break
        if result is None or result.status_code not in (200, 201, 204):
            try:
                reason = result.json().get("detail", result.text[:200]) if result else "پاسخ نامعتبر"
            except Exception:
                reason = result.text[:200] if result else "پاسخ نامعتبر"
            return False, f"پنل تغییر را نپذیرفت: {reason}"

        if unit == "days":
            dbx("UPDATE user_panels SET expire_ts = ?, expire_warned_at = 0, delete_after_ts = 0 "
                "WHERE username = ? AND panel_id = ?", (new_expire, username, panel_id))
        return True, None
    except Exception as e:
        return False, str(e)


def _as_list(d):
    if isinstance(d, dict):
        for key in ("roles", "admins", "items", "results", "data", "obj"):
            if isinstance(d.get(key), list):
                return d[key]
        return []
    return d if isinstance(d, list) else []


def get_admin_role_id(url_base, headers):
    saved = get_setting("admin_role_id")
    if saved.isdigit():
        return int(saved), "تنظیم دستی"
    notes = []
    endpoints = ["/api/roles", "/api/role", "/api/admin/roles", "/api/admin_roles", "/api/admins/roles"]
    try:
        r = requests.get(f"{url_base}/openapi.json", timeout=8)
        if r.status_code == 200:
            for path, methods in (r.json().get("paths") or {}).items():
                if "role" in path.lower() and "{" not in path and "get" in methods and path not in endpoints:
                    endpoints.append(path)
    except Exception:
        pass
    for ep in endpoints:
        try:
            r = requests.get(f"{url_base}{ep}", headers=headers, timeout=8)
        except Exception:
            continue
        if r.status_code != 200:
            if r.status_code != 404:
                notes.append(f"{ep}: {r.status_code}")
            continue
        try:
            items = [x for x in _as_list(r.json()) if isinstance(x, dict) and x.get("id") is not None]
        except Exception:
            continue
        non_sudo = [x for x in items if not x.get("is_sudo")]
        pick = (non_sudo or items or [None])[0]
        if pick:
            try:
                return int(pick["id"]), f"از {ep}"
            except (ValueError, TypeError):
                pass
    for ep in ("/api/admins",):
        try:
            r = requests.get(f"{url_base}{ep}", headers=headers, params={"limit": 100}, timeout=8)
        except Exception:
            continue
        if r.status_code != 200:
            notes.append(f"{ep}: {r.status_code}")
            continue
        try:
            for adm in _as_list(r.json()):
                if not isinstance(adm, dict) or adm.get("is_sudo"):
                    continue
                rid = adm.get("role_id")
                if rid is None and isinstance(adm.get("role"), dict):
                    rid = adm["role"].get("id")
                if rid is not None:
                    return int(rid), "از نقش ادمین‌های موجود"
        except Exception:
            continue
    return None, ("پاسخ‌ها: " + ", ".join(notes)) if notes else "هیچ مسیر نقشی در پنل پیدا نشد"


def _admin_limit_fields(url_base, data_limit_bytes, expire_ts=0):
    fields = {}
    if data_limit_bytes and data_limit_bytes > 0:
        fields["data_limit"] = int(data_limit_bytes)
    return fields, bool(fields)


def create_pasarguard_admin(username: str, password: str, panel_id=None, data_limit_bytes=0, expire_ts=0):
    """ساخت ادمین در پنل پاسارگاد بدون فیلد زمان و با اعمال دقیق سقف حجم"""
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "هیچ پنلی به ربات متصل نیست."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not url_base or not token:
        return False, f"اتصال به پنل «{panel_info['name']}» برقرار نشد."

    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    final = username
    if dbx("SELECT 1 FROM user_panels WHERE username = ? AND kind = 'admin'", (final,), "one"):
        final = f"{username}_{random.randint(100, 999)}"

    role_id, role_notes = get_admin_role_id(url_base, headers)

    limit_variations = []
    if data_limit_bytes > 0:
        limit_variations = [
            {"data_limit": int(data_limit_bytes)},
            {"admin_limit": int(data_limit_bytes)},
            {"traffic_limit": int(data_limit_bytes)}
        ]
    else:
        limit_variations = [{}]

    last_error = ""
    for extra in limit_variations:
        payload = {
            "username": final,
            "password": password,
            "is_sudo": False
        }
        payload.update(extra)
        if role_id is not None:
            payload["role_id"] = role_id

        try:
            res = requests.post(f"{url_base}/api/admin", json=payload, headers=headers, timeout=20)
            if res.status_code in (200, 201):
                return True, (final, url_base + "/dashboard", panel_info["id"])

            if res.status_code == 409:
                final = f"{username}_{random.randint(100, 999)}"
                payload["username"] = final
                res = requests.post(f"{url_base}/api/admin", json=payload, headers=headers, timeout=20)
                if res.status_code in (200, 201):
                    return True, (final, url_base + "/dashboard", panel_info["id"])

            if res.status_code in (401, 403):
                return False, ("ادمین پنل سودو نیست و اجازه ساخت ادمین ندارد. یا یک ادمین سودو در ربات ثبت کنید، "
                               "یا از پنل مدیریت «حالت ساخت اکانت» را روی «اشتراک» بگذارید.")

            try:
                last_error = res.json().get("detail", res.text[:200])
            except Exception:
                last_error = res.text[:200]

        except Exception as e:
            return False, str(e)

    # فال‌بک
    base_payload = {"username": final, "password": password, "is_sudo": False}
    if role_id is not None:
        base_payload["role_id"] = role_id
    try:
        res = requests.post(f"{url_base}/api/admin", json=base_payload, headers=headers, timeout=20)
        if res.status_code in (200, 201):
            if data_limit_bytes > 0:
                for fld in ["data_limit", "admin_limit"]:
                    try:
                        r_put = requests.put(f"{url_base}/api/admin/{final}", json={fld: int(data_limit_bytes)},
                                             headers=headers, timeout=10)
                        if r_put.status_code in (200, 201):
                            break
                    except Exception:
                        pass
            return True, (final, url_base + "/dashboard", panel_info["id"])
    except Exception:
        pass

    return False, f"خطای پنل: {last_error}"


def delete_pasarguard_admin(username: str, panel_id=None):
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False
    token = get_pasarguard_token(panel_info)
    if not token:
        return False
    try:
        res = requests.delete(f"{panel_info['url'].rstrip('/')}/api/admin/{username}",
                              headers={"Authorization": f"Bearer {token}"}, timeout=10)
        return res.status_code in (200, 204, 404)
    except Exception:
        return False


def admin_expiry_text(expire_ts) -> str:
    if not expire_ts or expire_ts <= 0:
        return "فعال (نامحدود)"
    left = expire_ts - time.time()
    if left <= 0:
        return "منقضی شده"
    days = int(left // 86400)
    if days == 0:
        return f"فعال (کمتر از ۲۴ ساعت: {max(0, int(left // 3600))} ساعت)"
    return f"فعال ({days} روز مانده - تا {datetime.fromtimestamp(expire_ts).strftime('%Y-%m-%d')})"


def format_bytes(size_bytes) -> str:
    if not size_bytes or size_bytes <= 0:
        return "0 MB"
    gb = size_bytes / (1024 ** 3)
    if gb >= 1:
        return f"{gb:.2f} GB"
    return f"{(size_bytes / (1024 ** 2)):.1f} MB"


def get_pasarguard_user_info(username: str, panel_id=None):
    panel_info = _panel_info_dict(panel_id)
    if not panel_info:
        return False, "پنل یافت نشد."
    url_base = panel_info["url"].rstrip("/")
    token = get_pasarguard_token(panel_info)
    if not token or not url_base:
        return False, "عدم اتصال به پنل سرور"
    try:
        res = requests.get(f"{url_base}/api/user/{username}",
                           headers={"Authorization": f"Bearer {token}"}, timeout=10)
        if res.status_code == 200:
            data = res.json()
            used = int(data.get("used_traffic") or 0)
            limit = int(data.get("data_limit") or 0)
            expire_raw = data.get("expire")
            status_raw = str(data.get("status", "active")).lower()
            expire_ts = None
            if expire_raw is not None and str(expire_raw).strip() != "":
                try:
                    expire_ts = int(float(expire_raw))
                except (ValueError, TypeError):
                    try:
                        expire_ts = int(datetime.fromisoformat(str(expire_raw).replace("Z", "+00:00")).timestamp())
                    except Exception:
                        expire_ts = None
            remaining_str = format_bytes(max(0, limit - used)) if limit > 0 else "نامحدود"
            limit_str = format_bytes(limit) if limit > 0 else "نامحدود"
            is_expired = False
            if expire_ts and expire_ts > 0:
                exp_dt = datetime.fromtimestamp(expire_ts)
                days_left = (exp_dt - datetime.now()).days
                if days_left < 0:
                    expire_str = "منقضی شده"
                    is_expired = True
                elif days_left == 0:
                    hours_left = max(0, int((exp_dt - datetime.now()).total_seconds() // 3600))
                    expire_str = f"کمتر از ۲۴ ساعت ({hours_left} ساعت)"
                else:
                    expire_str = f"{days_left} روز ({exp_dt.strftime('%Y-%m-%d')})"
            else:
                expire_str = "نامحدود"
            is_traffic_ended = (limit > 0 and used >= limit)
            if status_raw == "active" and not is_expired and not is_traffic_ended:
                status_display = "روشن (فعال)"
            elif is_traffic_ended:
                status_display = "خاموش (اتمام حجم)"
            elif is_expired:
                status_display = "خاموش (منقضی شده)"
            else:
                status_display = "خاموش (غیرفعال)"
            return True, {"status": status_display, "used": format_bytes(used), "remaining": remaining_str,
                          "total": limit_str, "expire": expire_str,
                          "is_ended": (is_expired or is_traffic_ended or status_raw in ("limited", "expired"))}
        if res.status_code == 404:
            return False, "deleted"
        return False, "خطا در دریافت اطلاعات"
    except Exception as e:
        return False, str(e)


def background_check_once():
    try:
        panels = dbx("SELECT id, user_id, username, name, panel_id, kind, expire_ts, delete_after_ts "
                     "FROM user_panels", (), "all")
        now = int(time.time())
        for pid, uid, uname, pname, server_pid, kind, expire_ts, delete_after_ts in panels:
            try:
                is_ended = bool(expire_ts and int(expire_ts) > 0 and now >= int(expire_ts))
                if kind != "admin":
                    ok, info = get_pasarguard_user_info(uname, server_pid)
                    if ok and info.get("is_ended"):
                        is_ended = True
                    elif not ok and info == "deleted":
                        is_ended = True
                if delete_after_ts and now >= int(delete_after_ts):
                    deleted = (delete_pasarguard_admin(uname, server_pid) if kind == "admin"
                               else delete_pasarguard_user(uname, server_pid))
                    if deleted:
                        dbx("DELETE FROM user_panels WHERE id = ?", (pid,))
                        send_message_safe(
                            uid, f"<b>اطلاعیه پایان سرویس</b>\n\nسرویس <b>{html.escape(str(pname))}</b> "
                                 "پس از پایان مهلت اخطار از پنل حذف شد.")
                        user_row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
                        send_report(
                            f"🗑 <b>سرویس منقضی حذف شد</b>\n\n"
                            f"👤 <b>کاربر:</b> <a href=\"tg://user?id={uid}\">{uid}</a>\n"
                            f"📦 <b>سرویس:</b> {html.escape(str(pname))}",
                            user_id=uid, username=(user_row[0] if user_row else ""), topic="expiry")
                    else:
                        dbx("UPDATE user_panels SET delete_after_ts = ? WHERE id = ?",
                            (now + 180, pid))
                    continue
                if is_ended and not delete_after_ts:
                    mins = parse_int(get_setting("expiry_cleanup_minutes"), default=10) or 10
                    mins = max(1, min(mins, 1440))
                    delete_at = now + mins * 60
                    dbx("UPDATE user_panels SET expire_warned_at = ?, delete_after_ts = ? "
                        "WHERE id = ? AND delete_after_ts = 0", (now, delete_at, pid))
                    if kind == "admin":
                        lock_admin_panel(uname, server_pid, pid)
                    else:
                        set_pasarguard_user_status(uname, False, server_pid)
                    deadline = datetime.fromtimestamp(delete_at).strftime("%H:%M")
                    send_message_safe(
                        uid, f"⚠️ <b>اخطار پایان سرویس</b>\n\nسرویس <b>{html.escape(str(pname))}</b> "
                             f"منقضی شده و تا <b>{deadline}</b> از پنل حذف می‌شود. "
                             "اگر قصد ادامه دارید، قبل از حذف از بخش «سرویس‌های من» آن را تمدید کنید.")
                    user_row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
                    send_report(
                        f"⚠️ <b>مهلت پایان سرویس آغاز شد</b>\n\n"
                        f"👤 <b>کاربر:</b> <a href=\"tg://user?id={uid}\">{uid}</a>\n"
                        f"📦 <b>سرویس:</b> {html.escape(str(pname))}\n"
                        f"⏳ <b>حذف پس از:</b> {mins} دقیقه",
                        user_id=uid, username=(user_row[0] if user_row else ""), topic="expiry")
            except Exception:
                traceback.print_exc()
    except Exception:
        traceback.print_exc()


def background_checker_loop():
    while True:
        time.sleep(180)
        background_check_once()


# ==================== پایگاه داده ====================
def init_db():
    conn = sqlite3.connect(DB_FILE, timeout=30)
    c = conn.cursor()
    c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
    c.execute("SELECT 1 FROM settings WHERE key = 'start_text'")
    _had_db = c.fetchone() is not None
    default_positions = {"btn_buy": "top_single", "btn_my_panels": "mid_right", "btn_test": "mid_left",
                         "btn_wallet": "bot_right", "btn_support": "bot_single", "btn_channel": "bot_single",
                         "btn_miniapp": "bot_single", "btn_agency": "bot_left"}
    defaults = {
        "btn_agency": "🤝 نمایندگی", "cmd_btn_agency": "",
        "agency_user_text": "🤝 <b>اتصال نمایندگی</b>\n\nنام کاربری پنل خود را بفرستید:",
        "agency_pass_text": "🔑 حالا رمز عبور پنل را بفرستید (پیام شما بعد از بررسی پاک می‌شود):",
        "btn_buy": "🛒 خرید", "btn_my_panels": "📂 پنل‌های من", "btn_test": "🎁 تست پنل",
        "btn_support": "💬 پشتیبانی", "btn_channel": "📢 چنل ما", "btn_admin_panel": "⚙️ پنل مدیریت",
        "btn_wallet": "💰 کیف پول", "cmd_btn_wallet": "",
        "btn_miniapp": "🧩 مینی‌اپ", "cmd_btn_miniapp": "", "miniapp_url": "",
        "wallet_text": "👛 کیف پول شما", "wallet_entities": "[]",
        "btn_custom_volume": "✏️ حجم دلخواه", "btn_back_groups": "⬅️ بازگشت به گروه‌ها",
        "shop_btn_price": "0", "notify_user_actions": "1",
        "btn_back_main": "🔙 بازگشت به منوی اصلی", "btn_cancel": "❌ انصراف",
        "btn_adm_layout": "چینش دکمه‌ها", "btn_adm_texts": "تغییر متن دکمه‌ها",
        "btn_adm_emoji": "تنظیم ایموجی دکمه‌ها", "btn_adm_links": "تنظیم پشتیبانی و کانال",
        "btn_adm_broadcast": "ارسال پیام همگانی", "btn_adm_manage_admins": "مدیریت ادمین‌ها",
        "btn_adm_force_join": "مدیریت عضویت اجباری",
        "btn_adm_kb_mode": "تغییر حالت دکمه‌ها (شیشه‌ای/کیبوردی)",
        "start_text": "سلام! به سامانه مدیریت سرویس خوش آمدید", "start_entities": "[]",
        "support_text": "جهت ارتباط با پشتیبانی:\n\n@{id}", "support_entities": "[]", "support_user": "None",
        "order_rejected_text": "❌ متأسفانه رسید ارسالی شما بررسی شد و مورد تأیید قرار نگرفت (رد شد).\n\nاگر اشتباهی رخ داده است، لطفاً با بخش پشتیبانی در ارتباط باشید.",
        "order_rejected_entities": "[]",
        "channel_link": "https://t.me/telegram",
        "btn_positions": json.dumps(default_positions),
        "force_join_enabled": "0", "keyboard_mode": "inline",
        "fj_text": "<b>برای استفاده از ربات ابتدا در کانال‌های زیر عضو شوید و سپس دکمه «عضو شدم» را بزنید:</b>",
        "fj_entities": "[]",
        "fj_btn_join": "عضویت در {title}",
        "fj_btn_check": "عضو شدم",
        "my_panels_text": "📂 سرویس‌های شما:\nبرای مشاهده مشخصات و وضعیت، روی هر سرویس بزنید:",
        "my_panels_entities": "[]",
        "shop_header_text": "🛍 لطفاً یکی از پلن‌های زیر را جهت خرید انتخاب کنید:",
        "shop_header_entities": "[]",
        "visible_main_buttons": json.dumps(
            ["btn_buy", "btn_my_panels", "btn_test", "btn_wallet", "btn_support", "btn_channel", "btn_miniapp",
             "btn_agency"]),
        "report_chat_id": "",
        "expiry_cleanup_minutes": "10",
        "cmd_btn_buy": "",
        "cmd_btn_my_panels": "",
        "cmd_btn_test": "",
        "cmd_btn_support": "",
        "cmd_btn_channel": "",
        "card_number": "", "card_holder": "",
        "test_ded_traffic_mb": "1024", "test_ded_time_hours": "24",
        "test_sh_traffic_mb": "512", "test_sh_time_hours": "12",
        "test_panel_enabled": "1", "test_shared_enabled": "1",
        "bot_status": "on",
        "bot_off_text": "ربات جهت انجام به‌روزرسانی موقتاً خاموش است. لطفاً بعداً مراجعه نمایید.",
        "bot_off_entities": "[]",
        "account_mode": "user",
    }
    defaults.update({"rules_text": "", "rules_entities": "[]", "btn_rules_accept": "می‌پذیرم",
                     "wallet_amount_text": "💳 شارژ کیف پول\n\nمبلغ موردنظر را به تومان (فقط عدد) بفرستید:",
                     "wallet_amount_entities": "[]",
                     "wallet_pay_text": "💳 اطلاعات پرداخت", "wallet_pay_entities": "[]"})
    for k, v in defaults.items():
        c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (k, v))
    # دیتابیس جدید = ربات ساده بدون رنگ؛ دیتابیس قدیمی = رنگ‌ها مثل قبل روشن می‌ماند
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('colors_enabled', ?)", ("1" if _had_db else "0",))
    for b in ("btn_buy", "btn_my_panels", "btn_test", "btn_support", "btn_channel", "btn_wallet", "btn_miniapp",
              "btn_agency"):
        c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (f"style_{b}", "primary"))
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("style_btn_custom_volume", "primary"))
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("style_btn_back_groups", "danger"))
    # مهاجرت یک‌باره: دکمه نمایندگی برای دیتابیس‌های قدیمی به منوی اصلی اضافه شود
    c.execute("SELECT value FROM settings WHERE key = 'agency_btn_migrated'")
    if not c.fetchone():
        try:
            c.execute("SELECT value FROM settings WHERE key = 'visible_main_buttons'")
            r = c.fetchone()
            vis = json.loads(r[0]) if r and r[0] else []
            if "btn_agency" not in vis:
                vis.append("btn_agency")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('visible_main_buttons', ?)",
                      (json.dumps(vis),))
            c.execute("SELECT value FROM settings WHERE key = 'btn_positions'")
            r = c.fetchone()
            pos = json.loads(r[0]) if r and r[0] else {}
            pos.setdefault("btn_agency", "bot_left")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('btn_positions', ?)",
                      (json.dumps(pos),))
        except Exception:
            traceback.print_exc()
        c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('agency_btn_migrated', '1')")
    # مهاجرت یک‌باره: دکمه کیف پول برای دیتابیس‌های قدیمی به منوی اصلی اضافه شود
    c.execute("SELECT value FROM settings WHERE key = 'wallet_btn_migrated'")
    if not c.fetchone():
        try:
            c.execute("SELECT value FROM settings WHERE key = 'visible_main_buttons'")
            r = c.fetchone()
            vis = json.loads(r[0]) if r and r[0] else []
            if "btn_wallet" not in vis:
                vis.append("btn_wallet")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('visible_main_buttons', ?)",
                      (json.dumps(vis),))
            c.execute("SELECT value FROM settings WHERE key = 'btn_positions'")
            r = c.fetchone()
            pos = json.loads(r[0]) if r and r[0] else {}
            pos.setdefault("btn_wallet", "bot_right")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('btn_positions', ?)",
                      (json.dumps(pos),))
        except Exception:
            traceback.print_exc()
        c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('wallet_btn_migrated', '1')")
    # مهاجرت یک‌باره: دکمه مینی‌اپ برای دیتابیس‌های قدیمی به لیست دکمه‌ها اضافه شود
    c.execute("SELECT value FROM settings WHERE key = 'miniapp_btn_migrated'")
    if not c.fetchone():
        try:
            c.execute("SELECT value FROM settings WHERE key = 'visible_main_buttons'")
            r = c.fetchone()
            vis = json.loads(r[0]) if r and r[0] else []
            if "btn_miniapp" not in vis:
                vis.append("btn_miniapp")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('visible_main_buttons', ?)",
                      (json.dumps(vis),))
            c.execute("SELECT value FROM settings WHERE key = 'btn_positions'")
            r = c.fetchone()
            pos = json.loads(r[0]) if r and r[0] else {}
            pos.setdefault("btn_miniapp", "bot_single")
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('btn_positions', ?)",
                      (json.dumps(pos),))
        except Exception:
            traceback.print_exc()
        c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('miniapp_btn_migrated', '1')")
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("style_fj_check", "success"))
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("keyboard_mode", "inline"))
    for b, stl in (("pnl_open", "success"), ("pnl_pw", "primary"), ("pnl_chpw", "primary"),
                   ("pnl_toggle", "danger"), ("pnl_rev", "primary"), ("pnl_refresh", "primary"),
                   ("pnl_back", "primary")):
        c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (f"style_{b}", stl))

    # ترمیم مقادیری که به‌خاطر باگ قبلی با "[]" بازنویسی شده بودند
    for k in ("btn_buy", "btn_my_panels", "btn_test", "btn_support", "btn_channel", "btn_admin_panel",
              "btn_back_main"):
        c.execute("SELECT value FROM settings WHERE key = ?", (k,))
        r = c.fetchone()
        if r is None or (r[0] or "").strip() in ("", "[]"):
            c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (k, defaults[k]))

    c.execute("""CREATE TABLE IF NOT EXISTS users (
        user_id INTEGER PRIMARY KEY, username TEXT DEFAULT '', first_name TEXT DEFAULT '', last_name TEXT DEFAULT '',
        last_menu_id INTEGER DEFAULT 0, has_used_test INTEGER DEFAULT 0, is_blocked INTEGER DEFAULT 0,
        wallet_balance INTEGER DEFAULT 0, referred_by INTEGER DEFAULT 0)""")

    # به‌روزرسانی ستون‌های جدول users
    c.execute("PRAGMA table_info(users)")
    u_cols = [col[1] for col in c.fetchall()]
    for col, ddl in (("first_name", "TEXT DEFAULT ''"), ("last_name", "TEXT DEFAULT ''"),
                     ("wallet_balance", "INTEGER DEFAULT 0"), ("referred_by", "INTEGER DEFAULT 0"),
                     ("rules_ok", "INTEGER DEFAULT 0")):
        if col not in u_cols:
            try:
                c.execute(f"ALTER TABLE users ADD COLUMN {col} {ddl}")
            except Exception:
                pass

    c.execute("CREATE TABLE IF NOT EXISTS admins (user_id INTEGER PRIMARY KEY, "
              "can_settings INTEGER DEFAULT 0, can_admins INTEGER DEFAULT 0, permissions TEXT DEFAULT '')")
    c.execute("PRAGMA table_info(admins)")
    admin_cols = [col[1] for col in c.fetchall()]
    if "permissions" not in admin_cols:
        c.execute("ALTER TABLE admins ADD COLUMN permissions TEXT DEFAULT ''")
    c.execute("""CREATE TABLE IF NOT EXISTS user_panels (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, name TEXT, username TEXT, password TEXT,
        url TEXT, is_test INTEGER DEFAULT 0, panel_id INTEGER DEFAULT 0)""")
    c.execute("""CREATE TABLE IF NOT EXISTS server_panels (
        id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT, username TEXT, password TEXT,
        group_name TEXT DEFAULT 'ALL')""")

    c.execute("PRAGMA table_info(shop_plans)")
    p_cols = [col[1] for col in c.fetchall()]
    if not p_cols or "name" not in p_cols:
        c.execute("DROP TABLE IF EXISTS shop_plans")
        c.execute("""CREATE TABLE shop_plans (
            id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, traffic_gb INTEGER DEFAULT 0,
            days INTEGER DEFAULT 0, price INTEGER DEFAULT 0, description TEXT DEFAULT '',
            panel_id INTEGER DEFAULT 0, description_entities TEXT DEFAULT '[]')""")
    else:
        if "panel_id" not in p_cols:
            c.execute("ALTER TABLE shop_plans ADD COLUMN panel_id INTEGER DEFAULT 0")
        if "description_entities" not in p_cols:
            c.execute("ALTER TABLE shop_plans ADD COLUMN description_entities TEXT DEFAULT '[]'")

    # گروه‌بندی پلن‌ها + حجم دلخواه
    c.execute("PRAGMA table_info(shop_plans)")
    sp_cols = [col[1] for col in c.fetchall()]
    for col, ddl in (("group_id", "INTEGER DEFAULT 0"), ("is_custom", "INTEGER DEFAULT 0"),
                     ("btn_style", "TEXT DEFAULT 'primary'"), ("sort_order", "INTEGER DEFAULT 0")):
        if col not in sp_cols:
            c.execute(f"ALTER TABLE shop_plans ADD COLUMN {col} {ddl}")
    c.execute("""CREATE TABLE IF NOT EXISTS plan_groups (
        id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, header TEXT DEFAULT '',
        custom_enabled INTEGER DEFAULT 0, custom_price INTEGER DEFAULT 0,
        custom_min INTEGER DEFAULT 1, custom_max INTEGER DEFAULT 1000,
        custom_days INTEGER DEFAULT 0, custom_panel_id INTEGER DEFAULT 0,
        btn_style TEXT DEFAULT 'primary')""")
    c.execute("PRAGMA table_info(plan_groups)")
    pg_cols = [col[1] for col in c.fetchall()]
    for col, ddl in (("sort_order", "INTEGER DEFAULT 0"), ("cols", "INTEGER DEFAULT 2")):
        if col not in pg_cols:
            c.execute(f"ALTER TABLE plan_groups ADD COLUMN {col} {ddl}")

    c.execute("PRAGMA table_info(user_panels)")
    up_cols = [col[1] for col in c.fetchall()]
    for col, ddl in (("panel_id", "INTEGER DEFAULT 0"), ("kind", "TEXT DEFAULT 'user'"),
                     ("expire_ts", "INTEGER DEFAULT 0"), ("is_disabled", "INTEGER DEFAULT 0"),
                     ("expire_warned_at", "INTEGER DEFAULT 0"), ("delete_after_ts", "INTEGER DEFAULT 0"),
                     ("locked_pw", "TEXT DEFAULT ''")):
        if col not in up_cols:
            try:
                c.execute(f"ALTER TABLE user_panels ADD COLUMN {col} {ddl}")
            except Exception:
                pass

    c.execute("""CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, plan_id INTEGER, custom_username TEXT,
        custom_password TEXT, file_id TEXT, file_type TEXT, status TEXT DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
    c.execute("PRAGMA table_info(orders)")
    order_cols = [col[1] for col in c.fetchall()]
    if "renew_panel_id" not in order_cols:
        c.execute("ALTER TABLE orders ADD COLUMN renew_panel_id INTEGER DEFAULT 0")
    if "amount" not in order_cols:
        c.execute("ALTER TABLE orders ADD COLUMN amount INTEGER DEFAULT 0")
        c.execute("UPDATE orders SET amount = COALESCE((SELECT price FROM shop_plans WHERE shop_plans.id = orders.plan_id), 0)")
    if "payment_method" not in order_cols:
        c.execute("ALTER TABLE orders ADD COLUMN payment_method TEXT DEFAULT 'receipt'")
    c.execute("""CREATE TABLE IF NOT EXISTS wallet_transactions (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, admin_id INTEGER NOT NULL,
        amount INTEGER NOT NULL, balance_after INTEGER NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, note TEXT DEFAULT '')""")

    c.execute("CREATE TABLE IF NOT EXISTS force_join_channels (id INTEGER PRIMARY KEY AUTOINCREMENT, "
              "chat_id TEXT NOT NULL UNIQUE, title TEXT, invite_link TEXT, added_by INTEGER)")
    c.execute("CREATE TABLE IF NOT EXISTS buy_drafts (user_id INTEGER PRIMARY KEY, plan_id INTEGER, "
              "username TEXT, password TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "
              "payment_token TEXT DEFAULT '', payment_status TEXT DEFAULT 'ready', quoted_amount INTEGER DEFAULT 0)")
    c.execute("PRAGMA table_info(buy_drafts)")
    draft_cols = [col[1] for col in c.fetchall()]
    if "renew_panel_id" not in draft_cols:
        c.execute("ALTER TABLE buy_drafts ADD COLUMN renew_panel_id INTEGER DEFAULT 0")
    if "payment_token" not in draft_cols:
        c.execute("ALTER TABLE buy_drafts ADD COLUMN payment_token TEXT DEFAULT ''")
    if "payment_status" not in draft_cols:
        c.execute("ALTER TABLE buy_drafts ADD COLUMN payment_status TEXT DEFAULT 'ready'")
    if "quoted_amount" not in draft_cols:
        c.execute("ALTER TABLE buy_drafts ADD COLUMN quoted_amount INTEGER DEFAULT 0")
    c.execute("PRAGMA table_info(users)")
    user_cols = [col[1] for col in c.fetchall()]
    if "test_unlimited" not in user_cols:
        c.execute("ALTER TABLE users ADD COLUMN test_unlimited INTEGER DEFAULT 0")
    c.execute("""CREATE TABLE IF NOT EXISTS discount_codes (
        id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE COLLATE NOCASE,
        amount INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL DEFAULT 0,
        used_count INTEGER DEFAULT 0, created_by INTEGER DEFAULT 0,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
    c.execute("PRAGMA table_info(discount_codes)")
    _dc = [col[1] for col in c.fetchall()]
    for col, ddl in (("plan_ids", "TEXT DEFAULT ''"), ("min_gb", "INTEGER DEFAULT 0")):
        if col not in _dc:
            c.execute(f"ALTER TABLE discount_codes ADD COLUMN {col} {ddl}")
    c.execute("PRAGMA table_info(buy_drafts)")
    if "discount_code" not in [col[1] for col in c.fetchall()]:
        c.execute("ALTER TABLE buy_drafts ADD COLUMN discount_code TEXT DEFAULT ''")
    c.execute("PRAGMA table_info(orders)")
    if "discount_code" not in [col[1] for col in c.fetchall()]:
        c.execute("ALTER TABLE orders ADD COLUMN discount_code TEXT DEFAULT ''")
    c.execute("""CREATE TABLE IF NOT EXISTS wallet_charges (
        id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, amount INTEGER, file_id TEXT DEFAULT '',
        file_type TEXT DEFAULT '', status TEXT DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('discount_enabled', '0')")
    c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('last_db_backup_ts', '0')")
    c.execute("INSERT OR IGNORE INTO users (user_id) VALUES (?)", (OWNER_ID,))
    conn.commit()
    conn.close()


def add_user_if_new(user, referral_id=None):
    """
    بررسی کاربر؛ اگر برای اولین بار باشد ثبت کرده و فوراً گزارش ورود را به مالک می‌فرستد.
    """
    user_id = user.id
    username = user.username or ""
    fn = user.first_name or ""
    ln = user.last_name or ""
    row = dbx("SELECT user_id FROM users WHERE user_id = ?", (user_id,), "one")
    if not row:
        valid_referrer = None
        try:
            candidate = int(referral_id or 0)
            if candidate != user_id and dbx("SELECT 1 FROM users WHERE user_id = ?", (candidate,), "one"):
                valid_referrer = candidate
        except (ValueError, TypeError):
            pass
        dbx("INSERT INTO users (user_id, username, first_name, last_name, referred_by) VALUES (?, ?, ?, ?, ?)",
            (user_id, username, fn, ln, valid_referrer or 0))
        now_str = datetime.now().strftime("%Y-%m-%d | %H:%M:%S")
        full_name = html.escape((fn + " " + ln).strip() or "بدون نام")
        full_name = f'<a href="tg://user?id={user_id}">{full_name}</a>'
        u_name = f"@{html.escape(username)}" if username else "ندارد"
        alert_msg = (
            "🆕 <b>کاربرِ جدید وارد ربات شد</b>\n\n"
            f"👤 <b>نام:</b> {full_name}\n"
            f"   • <b>یوزرنیم:</b> {u_name}\n"
            f"   • <b>آیدی عددی:</b> <code>{user_id}</code>\n"
            f"🕒 <b>زمان و ساعت:</b> <code>{now_str}</code>"
        )
        send_report(alert_msg, user_id=user_id, username=username, topic="users")
    else:
        dbx("UPDATE users SET username = ?, first_name = ?, last_name = ? WHERE user_id = ?",
            (username, fn, ln, user_id))


def is_user_blocked(user_id) -> bool:
    if user_id == OWNER_ID:
        return False
    row = dbx("SELECT is_blocked FROM users WHERE user_id = ?", (user_id,), "one")
    return bool(row and row[0] == 1)


def is_admin(user_id) -> bool:
    if user_id == OWNER_ID:
        return True
    return bool(dbx("SELECT 1 FROM admins WHERE user_id = ?", (user_id,), "one"))


def get_all_admin_ids():
    ids = {OWNER_ID}
    for r in dbx("SELECT user_id FROM admins", (), "all"):
        ids.add(r[0])
    return list(ids)


def get_admin_perms(user_id):
    if user_id == OWNER_ID:
        return {"settings": True, "admins": True, "orders": True, "features": set(), "explicit": False}
    row = dbx("SELECT can_settings, can_admins, permissions FROM admins WHERE user_id = ?", (user_id,), "one")
    if row:
        raw = row[2] or ""
        if raw:
            try:
                features = set(json.loads(raw))
            except Exception:
                features = set()
            return {"settings": False, "admins": "admins" in features,
                    "orders": "orders" in features, "features": features, "explicit": True}
        return {"settings": bool(row[0]), "admins": bool(row[1]),
                "orders": True, "features": set(), "explicit": False}
    return {"settings": False, "admins": False, "orders": False, "features": set(), "explicit": False}


ADMIN_FEATURES = {
    "orders": "سفارش‌ها",
    "servers": "پنل‌های سرور",
    "tests": "تنظیم تست‌ها",
    "payment": "اطلاعات پرداخت",
    "users": "مدیریت کاربران",
    "shop": "فروشگاه و پلن‌ها",
    "buttons": "چینش و نمایش دکمه‌ها",
    "texts": "متن‌ها و ایموجی‌ها",
    "links": "لینک‌ها و گزارش‌ها",
    "force_join": "عضویت اجباری",
    "broadcast": "پیام همگانی",
    "system": "وضعیت و حالت ربات",
    "admins": "مدیریت ادمین‌ها",
}


def admin_can(user_id, feature):
    if user_id == OWNER_ID:
        return True
    if not is_admin(user_id):
        return False
    perms = get_admin_perms(user_id)
    if perms["explicit"]:
        return feature in perms["features"]
    if feature == "orders":
        return perms["orders"]
    if feature == "admins":
        return perms["admins"]
    return perms["settings"]


def user_private_link(user_id):
    return f"tg://user?id={int(user_id)}"


# ==================== گروه گزارشات (هر نوع گزارش در تاپیک خودش) ====================
REPORT_TOPICS = [
    ("users", "کاربران جدید", "👥"),
    ("tests", "گزارش تست", "🎁"),
    ("sales", "گزارش مالی", "💰"),
    ("expiry", "پایان سرویس‌ها", "⏳"),
    ("errors", "گزارش خطاها", "❌"),
    ("backup", "بکاپ دیتابیس", "💾"),
    ("other", "سایر گزارشات", "⚙️"),
]
REPORT_TOPIC_MAP = {k: (t, e) for k, t, e in REPORT_TOPICS}
_report_topic_lock = threading.Lock()


def report_topics_enabled() -> bool:
    return get_setting("report_use_topics") == "1"


def get_report_topic_id(key):
    v = (get_setting(f"report_topic_{key}") or "").strip()
    return int(v) if v.isdigit() else None


def clear_report_topics():
    for k, _t, _e in REPORT_TOPICS:
        set_setting(f"report_topic_{k}", "")
    set_setting("report_use_topics", "0")


def _is_thread_missing(e) -> bool:
    return "thread not found" in str(e).lower()


def _create_forum_topic(chat_id, name):
    if hasattr(bot, "create_forum_topic"):
        res = bot.create_forum_topic(chat_id, name)
        return int(res.message_thread_id)
    res = telebot.apihelper._make_request(
        TOKEN, "createForumTopic", method="post", params={"chat_id": chat_id, "name": name})
    return int(res["message_thread_id"])


def ensure_report_topic(chat_id, key, announce=True):
    """شناسه تاپیک را برمی‌گرداند؛ اگر نبود می‌سازد. در صورت خطا None."""
    if key not in REPORT_TOPIC_MAP:
        key = "other"
    title, emoji = REPORT_TOPIC_MAP[key]
    with _report_topic_lock:
        tid = get_report_topic_id(key)
        if tid:
            return tid
        try:
            tid = _create_forum_topic(chat_id, f"{emoji} {title}")
        except Exception:
            traceback.print_exc()
            return None
        set_setting(f"report_topic_{key}", str(tid))
    if announce:
        try:
            bot.send_message(chat_id, f"{emoji} <b>{title}</b> ایجاد شد ✅",
                             parse_mode="HTML", message_thread_id=tid)
        except Exception:
            pass
    return tid


def _send_in_topic(chat_id, key, sender):
    """sender(**kw) ارسال را انجام می‌دهد و در خطا Exception می‌دهد. True یعنی ارسال شد."""
    if key not in REPORT_TOPIC_MAP:
        key = "other"
    tid = None
    if report_topics_enabled():
        tid = get_report_topic_id(key) or ensure_report_topic(chat_id, key)
    try:
        sender(**({"message_thread_id": tid} if tid else {}))
        return True
    except Exception as e:
        if tid and _is_thread_missing(e):
            # تاپیک پاک شده؛ یک بار دوباره می‌سازیم
            set_setting(f"report_topic_{key}", "")
            new_tid = ensure_report_topic(chat_id, key)
            if new_tid:
                try:
                    sender(message_thread_id=new_tid)
                    return True
                except Exception:
                    traceback.print_exc()
                    return False
        traceback.print_exc()
        return False


def setup_report_topics(chat_id):
    """ساخت تاپیک‌ها در گروه (باید سوپرگروه با Topics فعال باشد). خروجی: (ok, متن)"""
    try:
        chat_obj = bot.get_chat(chat_id)
    except Exception as e:
        return False, f"❌ دسترسی به گروه ممکن نشد:\n<code>{html.escape(str(e)[:200])}</code>"
    if not getattr(chat_obj, "is_forum", False):
        return False, ("❌ تاپیک‌ها در این گروه فعال نیست.\n"
                       "در تنظیمات گروه گزینه «Topics» را روشن کنید و دوباره «ساخت تاپیک‌ها» را بزنید.\n"
                       "تا آن موقع گزارش‌ها در چت اصلی گروه ارسال می‌شوند.")
    try:
        me = bot.get_chat_member(chat_id, bot.get_me().id)
    except Exception as e:
        return False, f"❌ وضعیت ربات در گروه معلوم نشد:\n<code>{html.escape(str(e)[:200])}</code>"
    if me.status != "creator" and not (me.status == "administrator" and getattr(me, "can_manage_topics", False)):
        return False, ("❌ ربات باید ادمین گروه باشد و دسترسی «Manage Topics» "
                       "(مدیریت تاپیک‌ها) داشته باشد.")
    set_setting("report_use_topics", "1")
    lines, ok_all = [], True
    for key, title, emoji in REPORT_TOPICS:
        existed = bool(get_report_topic_id(key))
        tid = ensure_report_topic(chat_id, key)
        if tid:
            lines.append(f"✅ {emoji} {title}" + (" (از قبل بود)" if existed else " (ساخته شد)"))
        else:
            ok_all = False
            lines.append(f"❌ {emoji} {title}")
    return ok_all, "<b>نتیجه ساخت تاپیک‌ها</b>\n\n" + "\n".join(lines)


def test_report_connection(chat_id):
    """پیام آزمایشی به گروه (و در حالت تاپیک، به تک‌تک تاپیک‌ها) می‌فرستد و نتیجه را برمی‌گرداند."""
    lines = []
    try:
        chat_obj = bot.get_chat(chat_id)
        lines.append(f"✅ گروه پیدا شد: <b>{html.escape(chat_obj.title or str(chat_id))}</b>")
    except Exception as e:
        return f"❌ گروه پیدا نشد:\n<code>{html.escape(str(e)[:200])}</code>"
    try:
        me = bot.get_chat_member(chat_id, bot.get_me().id)
        if me.status in ("administrator", "creator"):
            lines.append("✅ ربات ادمین گروه است")
        elif me.status == "member":
            lines.append("⚠️ ربات عضو است ولی ادمین نیست (برای ساخت تاپیک باید ادمین باشد)")
        else:
            lines.append("❌ ربات عضو گروه نیست")
    except Exception as e:
        lines.append(f"⚠️ وضعیت ربات معلوم نشد: <code>{html.escape(str(e)[:120])}</code>")
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    if report_topics_enabled():
        for key, title, emoji in REPORT_TOPICS:
            ok = _send_in_topic(
                chat_id, key,
                lambda **kw: bot.send_message(
                    chat_id, f"🔌 تست اتصال موفق بود\n{emoji} {title}\n🕒 {now}", **kw))
            lines.append(f"{'✅' if ok else '❌'} {emoji} {title}")
    else:
        ok = _send_in_topic(chat_id, "other",
                            lambda **kw: bot.send_message(chat_id, f"🔌 تست اتصال موفق بود\n🕒 {now}", **kw))
        lines.append(f"{'✅' if ok else '❌'} ارسال پیام آزمایشی به چت گروه")
        lines.append("ℹ️ حالت تاپیک فعال نیست (Topics گروه را روشن کنید و «ساخت تاپیک‌ها» را بزنید).")
    return "<b>نتیجه تست اتصال</b>\n\n" + "\n".join(lines)


def _report_user_links(user_id, username=None):
    """لینک‌های ممکن برای باز کردن پی‌وی کاربر، به ترتیب اولویت.
    - کاربر با یوزرنیم: https://t.me/username (همیشه در گروه قبول می‌شود)
    - tg://user?id=  (اگر کاربر محدودیت حریم خصوصی داشته باشد تلگرام دکمه را رد می‌کند)
    - tg://openmessage?user_id=  (جایگزین برای کاربرانی که یوزرنیم ندارند)
    """
    links = []
    if username:
        links.append(f"https://t.me/{username}")
    links.append(user_private_link(user_id))
    links.append(f"tg://openmessage?user_id={int(user_id)}")
    return links


def _report_user_markup(user_id, username, url):
    kb = types.InlineKeyboardMarkup()
    label = f"@{username}" if username else "باز کردن پی‌وی کاربر"
    kb.add(make_inline_button(text=label, url=url, style="none", keep_emoji=True))
    return kb


def send_report(text, user_id=None, username=None, topic="other"):
    """گزارش را در گروه گزارشات (تاپیک مربوط به همان نوع) می‌فرستد؛ اگر گروه نبود یا ارسال نشد، به ادمین‌ها.
    اگر user_id داده شود، دکمه باز کردن پی‌وی کاربر هم اضافه می‌شود؛ در صورت رد شدن یک لینک
    توسط تلگرام، لینک بعدی امتحان می‌شود و فقط در آخر بدون دکمه ارسال می‌گردد.
    ایموجی پرمیوم/ساده هم مثل بقیه ربات به صورت خودکار (pem) روی همین پیام اعمال می‌شود."""
    chat_id = get_setting("report_chat_id").strip()
    urls = _report_user_links(user_id, username) if user_id is not None else []

    def _send_with_fallback(send_fn):
        """send_fn(markup) را با هر لینک امتحان می‌کند. خطای تاپیک را بالا می‌دهد."""
        last_err = None
        for url in urls:
            try:
                return send_fn(_report_user_markup(user_id, username, url))
            except Exception as e:
                if _is_thread_missing(e):
                    raise
                last_err = e
                continue
        # هیچ لینکی قبول نشد (یا کاربری نبود) → بدون دکمه
        try:
            return send_fn(None)
        except Exception:
            if last_err:
                traceback.print_exc()
            raise

    if chat_id:
        def _sender(**kw):
            _send_with_fallback(
                lambda mk: bot.send_message(chat_id, text, parse_mode="HTML", reply_markup=mk, **kw))
        if _send_in_topic(chat_id, topic, _sender):
            return True
    sent = False
    for target in get_all_admin_ids():
        try:
            _send_with_fallback(
                lambda mk, t=target: bot.send_message(t, text, parse_mode="HTML", reply_markup=mk))
            sent = True
        except Exception:
            continue
    return sent


# ==================== بکاپ / دیتابیس ====================
_backup_lock = threading.Lock()


def make_db_snapshot():
    """یک کپی سالم از دیتابیس می‌سازد و مسیر آن را برمی‌گرداند."""
    stamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    out = os.path.join(BASE_DIR, f"backup_{stamp}.db")
    src = sqlite3.connect(DB_FILE, timeout=30)
    dst = sqlite3.connect(out)
    try:
        src.backup(dst)
    finally:
        dst.close()
        src.close()
    return out


def _db_stats_caption(title):
    try:
        users = dbx("SELECT COUNT(*) FROM users", (), "one")[0]
    except Exception:
        users = "?"
    size = os.path.getsize(DB_FILE) if os.path.exists(DB_FILE) else 0
    return (f"💾 <b>{title}</b>\n🕒 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
            f"👥 کاربران: <b>{users}</b>\n📦 حجم: <b>{format_bytes(size)}</b>")


def send_db_backup(target_chat=None, title="بکاپ خودکار دیتابیس"):
    """بکاپ را به گروه گزارشات (تاپیک بکاپ) یا به چت داده‌شده می‌فرستد."""
    with _backup_lock:
        path = make_db_snapshot()
        try:
            caption = _db_stats_caption(title)
            if target_chat:
                with open(path, "rb") as f:
                    bot.send_document(target_chat, f, caption=caption, parse_mode="HTML",
                                      visible_file_name=os.path.basename(path))
                return True
            chat_id = get_setting("report_chat_id").strip()
            if not chat_id:
                return False

            def _sender(**kw):
                with open(path, "rb") as f:
                    bot.send_document(chat_id, f, caption=caption, parse_mode="HTML",
                                      visible_file_name=os.path.basename(path), **kw)
            ok = _send_in_topic(chat_id, "backup", _sender)
            if ok:
                set_setting("last_db_backup_ts", str(int(time.time())))
            return ok
        finally:
            try:
                os.remove(path)
            except Exception:
                pass


def get_backup_interval_hours():
    try:
        h = int(get_setting("backup_interval_hours") or 24)
    except Exception:
        h = 24
    return h if 1 <= h <= 720 else 24


def backup_check_once():
    try:
        if not get_setting("report_chat_id").strip():
            return
        last = int(get_setting("last_db_backup_ts") or 0)
        if time.time() - last >= get_backup_interval_hours() * 3600:
            send_db_backup()
    except Exception:
        traceback.print_exc()


def backup_loop():
    while True:
        time.sleep(60)
        backup_check_once()


def is_valid_sqlite_file(path):
    try:
        with open(path, "rb") as f:
            if f.read(16) != b"SQLite format 3\x00":
                return False
        conn = sqlite3.connect(path)
        try:
            return conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
        finally:
            conn.close()
    except Exception:
        return False


# ==================== کد تخفیف ====================
def discount_enabled() -> bool:
    return get_setting("discount_enabled") == "1"


def get_valid_discount(code):
    """کد معتبر و منقضی‌نشده را برمی‌گرداند: (id, code, amount, expires_at) یا None"""
    code = (code or "").strip()
    if not code:
        return None
    row = dbx("SELECT id, code, amount, expires_at, COALESCE(plan_ids, ''), COALESCE(min_gb, 0) "
              "FROM discount_codes WHERE code = ? COLLATE NOCASE", (code,), "one")
    if not row or int(row[3] or 0) <= int(time.time()):
        return None
    return row


def discount_applies(d, plan_id):
    """آیا کد برای این پلن (یا حجم دلخواه) قابل استفاده است؟ -> (True/False, پیام خطا)"""
    if plan_id is None:
        return True, ""
    pl = dbx("SELECT COALESCE(is_custom, 0), traffic_gb FROM shop_plans WHERE id = ?", (plan_id,), "one")
    if not pl:
        return False, "پلن پیدا نشد."
    is_custom, gb = int(pl[0]), int(pl[1] or 0)
    ids = {t for t in str(d[4] or "").split(",") if t}
    if ids and ((is_custom and "c" not in ids) or (not is_custom and str(plan_id) not in ids)):
        return False, "این کد برای این پلن قابل استفاده نیست."
    min_gb = int(d[5] or 0)
    if is_custom and min_gb and gb < min_gb:
        return False, f"این کد فقط برای حجم دلخواه {min_gb} گیگ به بالا قابل استفاده است."
    return True, ""


def discount_scope_text(plan_ids, min_gb):
    ids = [t for t in str(plan_ids or "").split(",") if t]
    if not ids:
        txt = "همه پلن‌ها"
    else:
        names = []
        for t in ids:
            if t == "c":
                names.append("حجم دلخواه")
            else:
                r = dbx("SELECT name FROM shop_plans WHERE id = ?", (int(t),), "one")
                if r:
                    names.append(str(r[0]))
        txt = "، ".join(names) or "—"
    if int(min_gb or 0) and (not ids or "c" in ids):
        txt += f" | حداقل حجم دلخواه: {int(min_gb)} گیگ"
    return txt


def discounted_price(price, code, plan_id=None):
    """(قیمت نهایی, مبلغ تخفیف, کد) — اگر کد معتبر نبود همان قیمت."""
    price = int(price or 0)
    if not discount_enabled() or not code:
        return price, 0, ""
    d = get_valid_discount(code)
    if not d or int(d[2]) >= price or not discount_applies(d, plan_id)[0]:
        return price, 0, ""
    return price - int(d[2]), int(d[2]), d[1]


def mark_discount_used(code):
    if code:
        dbx("UPDATE discount_codes SET used_count = used_count + 1 WHERE code = ? COLLATE NOCASE", (code,))


def show_disc_pick_menu(chat_id, msg_id, td):
    picks = td.setdefault("disc_picks", set())
    kb = types.InlineKeyboardMarkup()
    for pl in get_all_shop_plans():
        mark = "✅ " if str(pl[0]) in picks else "⬜ "
        kb.add(make_inline_button(text=f"{mark}{pl[1]}", callback_data=f"shop_disc_pick:{pl[0]}", style="none",
                                  keep_emoji=True))
    kb.add(make_inline_button(text=("✅ " if "c" in picks else "⬜ ") + "🎛 حجم دلخواه",
                              callback_data="shop_disc_pick:c", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="✔️ ادامه", callback_data="shop_disc_done", style="success", keep_emoji=True))
    kb.add(make_inline_button(text="انصراف", callback_data="shop_discounts"))
    update_message_safe(chat_id, msg_id, "<b>پلن‌هایی که کد برایشان کار می‌کند را انتخاب کنید:</b>", reply_markup=kb)


def ask_disc_min_gb(uid, chat_id, msg_id, td):
    user_states[uid] = "shop_disc_mingb"
    td["last_msg_id"] = msg_id
    update_message_safe(chat_id, msg_id,
                        "📦 حداقل حجم برای <b>حجم دلخواه</b> چند گیگ باشد تا کد کار کند؟\n"
                        "(عدد به گیگ؛ برای بدون محدودیت 0 بفرستید)", reply_markup=cancel_kb("shop_discounts"))


def finalize_discount(uid, chat_id):
    td = user_temp_data.get(uid, {})
    code, amount, hours = td.get("disc_code"), td.get("disc_amount"), td.get("disc_hours")
    if not code or not amount or not hours:
        user_states.pop(uid, None)
        step_reply(uid, chat_id, "اطلاعات ناقص بود؛ دوباره از ابتدا بسازید.", cancel_kb("shop_discounts"))
        return
    ids = ",".join(sorted(td.get("disc_picks") or set()))
    min_gb = int(td.get("disc_min_gb") or 0)
    try:
        dbx("INSERT INTO discount_codes (code, amount, expires_at, created_by, plan_ids, min_gb) "
            "VALUES (?, ?, ?, ?, ?, ?)", (code, int(amount), int(time.time()) + int(hours) * 3600, uid, ids, min_gb))
    except sqlite3.IntegrityError:
        user_states.pop(uid, None)
        step_reply(uid, chat_id, "این کد قبلاً ساخته شده است.", cancel_kb("shop_discounts"))
        return
    user_states.pop(uid, None)
    step_reply(uid, chat_id, f"✅ کد تخفیف <code>{html.escape(code)}</code> به مبلغ <b>{int(amount):,} تومان</b> "
                             f"با اعتبار <b>{hours} ساعت</b> ساخته شد.\n🎯 {html.escape(discount_scope_text(ids, min_gb))}")
    show_discounts_menu(chat_id, td.get("last_msg_id"))


def hours_left_text(expires_at):
    left = int(expires_at) - int(time.time())
    if left <= 0:
        return "منقضی شده"
    h, m = divmod(left // 60, 60)
    return f"{h} ساعت و {m} دقیقه" if h else f"{m} دقیقه"


def show_discounts_menu(chat_id, msg_id):
    rows = dbx("SELECT id, code, amount, expires_at, used_count, COALESCE(plan_ids, ''), COALESCE(min_gb, 0) "
               "FROM discount_codes ORDER BY id DESC", (), "all")
    on = discount_enabled()
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(
        text=("🟢 کد تخفیف در خرید: فعال (غیرفعال کردن)" if on else "🔴 کد تخفیف در خرید: غیرفعال (فعال کردن)"),
        callback_data="shop_disc_toggle", style="danger" if on else "success", keep_emoji=True))
    kb.add(make_inline_button(text="➕ ساخت کد تخفیف جدید", callback_data="shop_disc_add", style="success",
                              keep_emoji=True))
    lines = []
    for d_id, code, amount, exp, used, pids, mgb in rows:
        lines.append(f"• <code>{html.escape(code)}</code> | {int(amount):,} تومان | "
                     f"{hours_left_text(exp)} | استفاده: {used}\n   🎯 {html.escape(discount_scope_text(pids, mgb))}")
        kb.row(make_inline_button(text=f"{code} | {int(amount):,} ت", callback_data="shop_disc_noop",
                                  style="none"),
               make_inline_button(text="🗑 حذف", callback_data=f"shop_disc_del:{d_id}", style="danger"))
    kb.add(make_inline_button(text="بازگشت به تنظیمات فروشگاه", callback_data="shop_settings"))
    body = "\n".join(lines) if lines else "هنوز کد تخفیفی ساخته نشده است."
    update_message_safe(chat_id, msg_id,
                        f"<b>🎟 مدیریت کدهای تخفیف</b>\n\nوضعیت در صفحه خرید: "
                        f"<b>{'نمایش داده می‌شود' if on else 'مخفی است'}</b>\n\n{body}", reply_markup=kb)


def show_db_menu(chat_id, msg_id):
    size = os.path.getsize(DB_FILE) if os.path.exists(DB_FILE) else 0
    last = int(get_setting("last_db_backup_ts") or 0)
    last_txt = datetime.fromtimestamp(last).strftime("%Y-%m-%d %H:%M") if last else "هنوز ارسال نشده"
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=f"⏱ زمان ارسال بکاپ: هر {get_backup_interval_hours()} ساعت",
                              callback_data="adm_db_interval", keep_emoji=True))
    kb.add(make_inline_button(text="📤 دریافت بکاپ همین الان", callback_data="adm_db_download", keep_emoji=True))
    kb.add(make_inline_button(text="📥 آپلود دیتابیس", callback_data="adm_db_upload", keep_emoji=True))
    kb.add(make_inline_button(text="🗑 حذف دیتابیس", callback_data="adm_db_delete", style="danger",
                              keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id,
                        f"<b>🗄 مدیریت دیتابیس</b>\n\nحجم فعلی: <b>{format_bytes(size)}</b>\n"
                        f"آخرین بکاپ خودکار به گروه: <b>{last_txt}</b>\n\n"
                        f"بکاپ خودکار هر {get_backup_interval_hours()} ساعت در تاپیک «💾 بکاپ دیتابیس» گروه گزارشات ارسال می‌شود.",
                        reply_markup=kb)


def show_bot_stats(chat_id, msg_id):
    t0 = time.time()
    try:
        bot.get_me()
        ping = f"{int((time.time() - t0) * 1000)} میلی‌ثانیه"
    except Exception:
        ping = "خطا در اتصال"
    q = lambda sql: (dbx(sql, (), "one") or [0])[0] or 0
    up = int(time.time() - BOT_START_TS)
    d, rem = divmod(up, 86400)
    h, rem = divmod(rem, 3600)
    m = rem // 60
    size = os.path.getsize(DB_FILE) if os.path.exists(DB_FILE) else 0
    now = int(time.time())
    n_ok = q("SELECT COUNT(*) FROM orders WHERE status = 'approved'")
    n_pend = q("SELECT COUNT(*) FROM orders WHERE status = 'pending'")
    n_sum = q("SELECT SUM(amount) FROM orders WHERE status = 'approved'")
    txt = (f"<b>📊 آمار ربات</b>\n\n"
           f"📶 پینگ ربات: <b>{ping}</b>\n"
           f"⏱ مدت روشن بودن: <b>{d} روز، {h} ساعت، {m} دقیقه</b>\n"
           f"🟢 وضعیت: <b>{'روشن' if get_setting('bot_status') != 'off' else 'خاموش'}</b>\n\n"
           f"👥 تعداد کل کاربران: <b>{q('SELECT COUNT(*) FROM users'):,}</b>\n"
           f"🚫 کاربران مسدود: <b>{q('SELECT COUNT(*) FROM users WHERE is_blocked = 1'):,}</b>\n"
           f"🛡 ادمین‌ها: <b>{q('SELECT COUNT(*) FROM admins'):,}</b>\n"
           f"🎁 کاربرانی که تست گرفته‌اند: <b>{q('SELECT COUNT(*) FROM users WHERE has_used_test = 1'):,}</b>\n\n"
           f"📂 سرویس‌های فعال کاربران: <b>{q('SELECT COUNT(*) FROM user_panels WHERE is_test = 0'):,}</b>\n"
           f"🧪 سرویس‌های تست: <b>{q('SELECT COUNT(*) FROM user_panels WHERE is_test = 1'):,}</b>\n"
           f"🖥 سرورها: <b>{q('SELECT COUNT(*) FROM server_panels'):,}</b>\n"
           f"🛍 پلن‌های فروشگاه: <b>{q('SELECT COUNT(*) FROM shop_plans WHERE COALESCE(is_custom, 0) = 0'):,}</b>\n\n"
           f"🧾 سفارش‌های تأییدشده: <b>{n_ok:,}</b>\n"
           f"⏳ سفارش‌های در انتظار: <b>{n_pend:,}</b>\n"
           f"💰 مجموع فروش: <b>{n_sum:,} تومان</b>\n"
           f"👛 مجموع موجودی کیف پول‌ها: <b>{q('SELECT SUM(wallet_balance) FROM users'):,} تومان</b>\n"
           f"🎟 کدهای تخفیف فعال: <b>{q(f'SELECT COUNT(*) FROM discount_codes WHERE expires_at > {now}'):,}</b>\n\n"
           f"🗄 حجم دیتابیس: <b>{format_bytes(size)}</b>")
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="🔄 بروزرسانی", callback_data="adm_stats", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def bot_is_off_for(user_id) -> bool:
    if is_admin(user_id):
        return False
    return get_setting("bot_status") == "off"


def can_get_test(user_id) -> bool:
    row = dbx("SELECT has_used_test, test_unlimited FROM users WHERE user_id = ?", (user_id,), "one")
    return bool(row and (row[1] or row[0] == 0))


def set_user_tested(user_id):
    dbx("UPDATE users SET has_used_test = 1 WHERE user_id = ?", (user_id,))


def claim_test_slot(user_id):
    """Atomically reserve a one-time test; status 2 is the short-lived reservation."""
    if not dbx("SELECT 1 FROM users WHERE user_id = ?", (user_id,), "one"):
        dbx("INSERT OR IGNORE INTO users (user_id) VALUES (?)", (user_id,))
    row = dbx("SELECT test_unlimited FROM users WHERE user_id = ?", (user_id,), "one")
    if row and row[0]:
        return True
    return dbx("UPDATE users SET has_used_test = 2 WHERE user_id = ? AND has_used_test = 0",
               (user_id,)) == 1


def release_test_slot(user_id, success):
    if success:
        dbx("UPDATE users SET has_used_test = 1 WHERE user_id = ? AND has_used_test = 2", (user_id,))
    else:
        dbx("UPDATE users SET has_used_test = 0 WHERE user_id = ? AND has_used_test = 2", (user_id,))


def get_user_last_menu(user_id) -> int:
    row = dbx("SELECT last_menu_id FROM users WHERE user_id = ?", (user_id,), "one")
    return row[0] if row and row[0] else 0


def set_user_last_menu(user_id, message_id):
    dbx("UPDATE users SET last_menu_id = ? WHERE user_id = ?", (message_id, user_id))


def parse_db_entities(raw_json):
    if not raw_json:
        return None
    try:
        data = json.loads(raw_json)
        if not data:
            return None
        return [types.MessageEntity(type=d.get("type"), offset=d.get("offset"), length=d.get("length"),
                                    url=d.get("url"), language=d.get("language"),
                                    custom_emoji_id=d.get("custom_emoji_id")) for d in data]
    except Exception:
        return None


def _u16(t):
    return len(t.encode("utf-16-le")) // 2


def render_user_text(text, entities, name):
    """جایگزینی {name} با اسم کاربر و جابه‌جایی درست entityها."""
    text = text or ""
    name = name or "کاربر"
    if "{name}" not in text:
        return text, entities
    if not entities:
        return text.replace("{name}", html.escape(name)), None
    ents = [types.MessageEntity(type=e.type, offset=e.offset, length=e.length, url=getattr(e, "url", None),
                                language=getattr(e, "language", None),
                                custom_emoji_id=getattr(e, "custom_emoji_id", None)) for e in entities]
    old_len, new_len = _u16("{name}"), _u16(name)
    positions = [m.start() for m in re.finditer(re.escape("{name}"), text)]
    for pos in reversed(positions):
        p16 = _u16(text[:pos])
        delta = new_len - old_len
        for e in ents:
            if e.offset >= p16 + old_len:
                e.offset += delta
            elif e.offset <= p16 and e.offset + e.length >= p16 + old_len:
                e.length += delta
        text = text[:pos] + name + text[pos + len("{name}"):]
    return text, ents


def get_user_display_name(uid, user=None):
    if user is not None and getattr(user, "first_name", None):
        return user.first_name
    row = dbx("SELECT first_name, username FROM users WHERE user_id = ?", (uid,), "one")
    if row:
        return row[0] or (row[1] and "@" + row[1]) or "کاربر"
    return "کاربر"


LAST_REACTION_ERR = ""


def set_reaction(chat_id, message_id, emoji):
    """emoji: normal emoji, or 'ce:<custom_emoji_id>|<fallback>' for premium emoji.
    On failure the real Telegram error is kept in LAST_REACTION_ERR and printed to the log."""
    global LAST_REACTION_ERR
    LAST_REACTION_ERR = ""
    emoji = (emoji or "").strip()
    tries = []
    if emoji.startswith("ce:"):
        cid, _, fb = emoji[3:].partition("|")
        tries.append({"type": "custom_emoji", "custom_emoji_id": cid})
        if fb:
            for v in (fb, fb.replace("\ufe0f", "")):
                tries.append({"type": "emoji", "emoji": v})
    elif emoji:
        # try as typed, then without the invisible variation selector (e.g. heart)
        tries.append({"type": "emoji", "emoji": emoji})
        plain = emoji.replace("\ufe0f", "")
        if plain != emoji:
            tries.append({"type": "emoji", "emoji": plain})
    for rt in tries:
        try:
            r = requests.post(f"https://api.telegram.org/bot{bot.token}/setMessageReaction",
                              json={"chat_id": chat_id, "message_id": message_id,
                                    "reaction": [rt]}, timeout=20)
            j = r.json()
            if j.get("ok"):
                return True
            LAST_REACTION_ERR = str(j.get("description") or j)[:200]
        except Exception as e:
            LAST_REACTION_ERR = f"{type(e).__name__}: {e}"[:200]
        print("[set_reaction] failed:", rt, LAST_REACTION_ERR, flush=True)
    return False


def reaction_label(val):
    val = (val or "").strip()
    if not val:
        return "خاموش"
    if val.startswith("ce:"):
        fb = val.partition("|")[2]
        return f"پریمیوم {fb}".strip()
    return val


def entities_to_json(entities) -> str:
    out = []
    for e in entities or []:
        out.append({"type": e.type, "offset": e.offset, "length": e.length,
                    "url": getattr(e, "url", None), "language": getattr(e, "language", None),
                    "custom_emoji_id": getattr(e, "custom_emoji_id", None)})
    return json.dumps(out)


def custom_entities_for_text(entities, offset=0):
    """Keep Telegram formatting and custom-emoji entities when composing plain text."""
    result = []
    for entity in entities or []:
        entity_type = getattr(entity, "type", None)
        if entity_type not in ("custom_emoji", "bold", "italic", "underline", "strikethrough",
                               "spoiler", "code", "pre", "text_link", "text_mention"):
            continue
        kwargs = {}
        for name in ("url", "user", "language", "custom_emoji_id"):
            value = getattr(entity, name, None)
            if value is not None:
                kwargs[name] = value
        try:
            result.append(types.MessageEntity(
                type=entity_type, offset=int(entity.offset) + int(offset),
                length=int(entity.length), **kwargs))
        except Exception:
            continue
    return result


def utf16_len(text):
    return len((text or "").encode("utf-16-le")) // 2


def _utf16_to_index(text, offset):
    units = 0
    for index, char in enumerate(text or ""):
        if units >= offset:
            return index
        units += 2 if ord(char) > 0xFFFF else 1
    return len(text or "")


def custom_emoji_html(text, entities):
    """Escape description text and preserve premium emoji when rendering HTML messages."""
    text = text or ""
    ranges = []
    for entity in entities or []:
        if getattr(entity, "type", None) != "custom_emoji" or not getattr(entity, "custom_emoji_id", None):
            continue
        start = _utf16_to_index(text, int(entity.offset))
        end = _utf16_to_index(text, int(entity.offset) + int(entity.length))
        if end > start:
            ranges.append((start, end, str(entity.custom_emoji_id)))
    ranges.sort()
    parts, pos = [], 0
    for start, end, emoji_id in ranges:
        if start < pos:
            continue
        parts.append(html.escape(text[pos:start]))
        glyph = html.escape(text[start:end])
        parts.append(f'<tg-emoji emoji-id="{html.escape(emoji_id)}">{glyph}</tg-emoji>')
        pos = end
    parts.append(html.escape(text[pos:]))
    return "".join(parts)


# کلید متن دکمه ← شناسه‌ای که برای ایموجی پریمیوم همان دکمه استفاده می‌شود
BTN_ICON_KEYS = {
    "btn_buy": "btn_buy", "btn_my_panels": "btn_my_panels", "btn_test": "btn_test",
    "btn_support": "btn_support", "btn_channel": "btn_channel", "btn_wallet": "btn_wallet",
    "btn_miniapp": "btn_miniapp", "btn_agency": "btn_agency",
    "btn_custom_volume": "btn_custom_volume", "btn_back_groups": "btn_back_groups",
    "btn_admin_panel": "btn_admin_panel", "btn_back_main": "back_to_main",
    "fj_btn_join": "fj_join", "fj_btn_check": "fj_check",
    "server_title": "server_title",
}


def extract_custom_emoji(text, entities):
    """ایموجی‌های پریمیوم را از متن جدا می‌کند. خروجی: (متن بدون ایموجی، اولین custom_emoji_id یا None)"""
    text = text or ""
    ranges, first_id = [], None
    for e in entities or []:
        if getattr(e, "type", None) == "custom_emoji":
            ranges.append((e.offset, e.length))
            if first_id is None:
                first_id = getattr(e, "custom_emoji_id", None)
    if not ranges:
        return text.strip(), None
    raw = text.encode("utf-16-le")
    for off, ln in sorted(ranges, reverse=True):
        raw = raw[:off * 2] + raw[(off + ln) * 2:]
    return raw.decode("utf-16-le", errors="ignore").strip(), first_id


def is_valid_url(url) -> bool:
    if not url or not isinstance(url, str):
        return False
    regex = re.compile(r'^(https?://)(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,24}\.?|localhost|'
                       r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d+)?(?:/?|[/?]\S+)$', re.IGNORECASE)
    return bool(regex.match(url.strip()))


def generate_strong_password_32(length=32) -> str:
    """رمز مطابق قوانین پاسارگاد: حداقل ۲ بزرگ، ۲ کوچک، ۲ عدد و ۱ علامت."""
    chars = string.ascii_letters + string.digits + "@#_!$%"
    pwd = [random.choice(string.ascii_lowercase), random.choice(string.ascii_lowercase),
           random.choice(string.ascii_uppercase), random.choice(string.ascii_uppercase),
           random.choice(string.digits), random.choice(string.digits),
           random.choice("@#_!$%"), random.choice("@#_!$%")]
    pwd += [random.choice(chars) for _ in range(length - len(pwd))]
    random.shuffle(pwd)
    return ''.join(pwd)


MIN_PASSWORD_LEN = 15
PASSWORD_RULE_TEXT = "حداقل ۱۵ کاراکتر، شامل ۲ حرف بزرگ، ۲ حرف کوچک، ۲ عدد و ۱ علامت (@#_!$%)"


def is_strong_password_32(pwd: str) -> bool:
    pwd = pwd or ""
    if len(pwd) < MIN_PASSWORD_LEN:
        return False
    return (sum(c.islower() for c in pwd) >= 2 and sum(c.isupper() for c in pwd) >= 2
            and sum(c.isdigit() for c in pwd) >= 2 and any(c in "@#_!$%&*+=-" for c in pwd))


def ensure_panel_password(pwd):
    """در حالت «ادمین»، اگر رمز ذخیره‌شده با قوانین پنل نمی‌خواند (مثلاً سفارش‌های قدیمی)، رمز جدید و معتبر می‌سازد."""
    if get_account_mode() != "admin":
        return pwd
    return pwd if is_strong_password_32(pwd or "") else generate_strong_password_32(32)


def to_en_digits(s: str) -> str:
    table = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
    return (s or "").translate(table)


def parse_int(s, default=None):
    try:
        return int(to_en_digits(str(s)).replace(",", "").replace("،", "").strip())
    except (ValueError, TypeError):
        return default


def parse_signed_number(s):
    value = to_en_digits(str(s or "")).strip().replace("٫", ".").replace("،", ".").replace(",", ".")
    if not re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", value):
        return None
    try:
        return float(value)
    except (ValueError, TypeError):
        return None


def get_button_display(key: str) -> str:
    raw = get_setting(key)
    if get_setting(f"emoji_{BTN_ICON_KEYS.get(key, key)}"):
        return strip_button_emoji(raw) or raw
    return raw


def price_str(p) -> str:
    return "رایگان 🎁" if (p or 0) <= 0 else f"{p:,} تومان"


def format_mb_display(mb_val: float) -> str:
    if mb_val >= 1024:
        return f"{mb_val / 1024:.2f} گیگابایت"
    return f"{int(mb_val)} مگابایت"


def get_all_shop_plans():
    return dbx("SELECT id, name, traffic_gb, days, price, description, panel_id FROM shop_plans "
               "WHERE COALESCE(is_custom, 0) = 0 ORDER BY COALESCE(sort_order, 0), id", (), "all")


def get_shop_plan_by_id(pid):
    return dbx("SELECT id, name, traffic_gb, days, price, description, panel_id FROM shop_plans WHERE id = ?",
               (pid,), "one")


def get_shop_plan_entities(pid):
    row = dbx("SELECT description_entities FROM shop_plans WHERE id = ?", (pid,), "one")
    return parse_db_entities(row[0]) if row and row[0] else None


def save_draft(uid, plan_id, username, password, renew_panel_id=0, amount=None, discount_code=""):
    payment_token = secrets.token_hex(6)
    plan = get_shop_plan_by_id(plan_id)
    quoted_amount = int(plan[4] or 0) if plan else 0
    if amount is not None:
        quoted_amount = int(amount)
    dbx("INSERT OR REPLACE INTO buy_drafts "
        "(user_id, plan_id, username, password, renew_panel_id, payment_token, payment_status, quoted_amount, "
        "discount_code) VALUES (?, ?, ?, ?, ?, ?, 'ready', ?, ?)",
        (uid, plan_id, username, password, int(renew_panel_id or 0), payment_token, quoted_amount,
         discount_code or ""))
    return payment_token


def load_draft(uid):
    return dbx("SELECT plan_id, username, password, renew_panel_id FROM buy_drafts WHERE user_id = ?",
               (uid,), "one")


def get_draft_payment_token(uid):
    row = dbx("SELECT payment_token FROM buy_drafts WHERE user_id = ?", (uid,), "one")
    return row[0] if row and row[0] else ""


def get_draft_quoted_amount(uid):
    row = dbx("SELECT quoted_amount FROM buy_drafts WHERE user_id = ?", (uid,), "one")
    return int(row[0] or 0) if row else 0


def get_draft_discount_code(uid):
    row = dbx("SELECT discount_code FROM buy_drafts WHERE user_id = ?", (uid,), "one")
    return row[0] if row and row[0] else ""


def clear_draft(uid):
    dbx("DELETE FROM buy_drafts WHERE user_id = ?", (uid,))


# ---------- ساخت خودکار نام کاربری اشتراکی‌ها ----------
SU_MODES = {"ask": "کاربر خودش بنویسد", "id": "آیدی عددی تلگرام", "tg": "نام کاربری تلگرام",
            "random": "تصادفی", "pattern": "الگوی دلخواه"}


def _su_clean(txt: str) -> str:
    return re.sub(r'[^a-z0-9_]', '', (txt or "").lower())


def shared_username_mode() -> str:
    m = get_setting("su_mode")
    return m if m in SU_MODES else "ask"


def build_shared_username(uid, fallback=None):
    """بر اساس تنظیم ادمین، نام کاربری اشتراک را می‌سازد. اگر حالت «ask» باشد fallback برمی‌گردد."""
    mode = shared_username_mode()
    if mode == "ask" or get_account_mode() == "admin":
        return fallback
    prefix = _su_clean(get_setting("su_prefix"))
    try:
        rlen = min(16, max(3, int(get_setting("su_rand_len") or 6)))
    except Exception:
        rlen = 6
    row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
    tg = _su_clean(row[0] if row and row[0] else "")
    rnd = ''.join(random.choices(string.ascii_lowercase + string.digits, k=rlen))
    if mode == "id":
        name = f"{prefix}{uid}"
    elif mode == "tg":
        name = f"{prefix}{tg or uid}"
    elif mode == "random":
        name = f"{prefix}{rnd}"
    else:
        pat = get_setting("su_pattern") or "{prefix}{id}"
        n = int(dbx("SELECT COUNT(*) FROM user_panels WHERE user_id = ?", (uid,), "one")[0]) + 1
        name = (pat.replace("{prefix}", prefix).replace("{id}", str(uid)).replace("{username}", tg or str(uid))
                .replace("{rand}", rnd).replace("{n}", str(n)))
        name = _su_clean(name)
    name = _su_clean(name)[:32]
    if len(name) < 3:
        name = (name + "usr" + rnd)[:32]
    return name


def show_shared_username_menu(chat_id, msg_id):
    mode = shared_username_mode()
    kb = types.InlineKeyboardMarkup()
    btns = [make_inline_button(text=("✅ " if mode == k else "") + v, callback_data=f"adm_su_mode:{k}",
                               style="none", keep_emoji=True) for k, v in SU_MODES.items()]
    for i in range(0, len(btns), 2):
        kb.row(*btns[i:i + 2])
    kb.row(make_inline_button(text="✏️ پیشوند", callback_data="adm_su_prefix", keep_emoji=True),
           make_inline_button(text="🔢 طول بخش تصادفی", callback_data="adm_su_len", keep_emoji=True))
    kb.add(make_inline_button(text="🧩 تنظیم الگوی دلخواه", callback_data="adm_su_pattern", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="adm_cat:sales", style="none"))
    sample = build_shared_username(123456789, "—") if mode != "ask" else "—"
    update_message_safe(
        chat_id, msg_id,
        "<b>👤 نام کاربری اشتراکی‌ها</b>\n\n"
        f"حالت فعلی: <b>{SU_MODES[mode]}</b>\n"
        f"پیشوند: <code>{html.escape(get_setting('su_prefix') or '—')}</code>\n"
        f"طول بخش تصادفی: <code>{get_setting('su_rand_len') or '6'}</code>\n"
        f"الگو: <code>{html.escape(get_setting('su_pattern') or '{prefix}{id}')}</code>\n"
        f"نمونه خروجی: <code>{html.escape(sample)}</code>\n\n"
        "این تنظیم برای همهٔ خریدها و تست‌های اشتراکی یکسان اعمال می‌شود.\n"
        "متغیرهای الگو: <code>{id}</code> <code>{username}</code> <code>{rand}</code> <code>{n}</code> <code>{prefix}</code>\n"
        "اگر کاربر نام کاربری تلگرام نداشته باشد، آیدی عددی جایگزین می‌شود.",
        reply_markup=kb)


def get_account_mode() -> str:
    return "admin" if get_setting("account_mode") == "admin" else "user"


# ==================== ارسال و ویرایش پیام ====================
# ==================== ارسال همگانی ====================
_BCAST_DEAD_MARKERS = ("blocked", "chat not found", "deactivated", "can't initiate",
                       "cannot initiate", "kicked", "user not found", "peer_id_invalid")


def _short_err(e) -> str:
    s = str(e)
    if "Description:" in s:
        s = s.split("Description:", 1)[1]
    s = s.strip().replace("\n", " ")
    return (s or e.__class__.__name__)[:120]


def _retry_after(e) -> int:
    try:
        return int(((getattr(e, "result_json", None) or {}).get("parameters") or {}).get("retry_after") or 0)
    except Exception:
        return 0


def _broadcast_send(u, src_chat_id, message):
    """یک پیام را برای یک کاربر می‌فرستد؛ اگر copy جواب نداد با روش مستقیم. خروجی: (ok, دلیل)"""
    last = ""
    for attempt in range(2):
        try:
            bot.copy_message(u, src_chat_id, message.message_id, timeout=20)
            return True, ""
        except Exception as e:
            last = _short_err(e)
            wait = _retry_after(e)
            if wait and attempt == 0:
                time.sleep(min(wait, 30))
                continue
            break
    if any(k in last.lower() for k in _BCAST_DEAD_MARKERS):
        return False, last
    try:
        ct = message.content_type
        if ct == "text":
            bot.send_message(u, message.text, entities=message.entities, timeout=20)
        elif ct == "photo":
            bot.send_photo(u, message.photo[-1].file_id, caption=message.caption,
                           caption_entities=message.caption_entities, timeout=30)
        elif ct == "video":
            bot.send_video(u, message.video.file_id, caption=message.caption,
                           caption_entities=message.caption_entities, timeout=30)
        elif ct == "document":
            bot.send_document(u, message.document.file_id, caption=message.caption,
                              caption_entities=message.caption_entities, timeout=30)
        elif ct == "animation":
            bot.send_animation(u, message.animation.file_id, caption=message.caption,
                               caption_entities=message.caption_entities, timeout=30)
        elif ct == "audio":
            bot.send_audio(u, message.audio.file_id, caption=message.caption,
                           caption_entities=message.caption_entities, timeout=30)
        elif ct == "voice":
            bot.send_voice(u, message.voice.file_id, caption=message.caption,
                           caption_entities=message.caption_entities, timeout=30)
        elif ct == "sticker":
            bot.send_sticker(u, message.sticker.file_id, timeout=20)
        else:
            return False, last
        return True, ""
    except Exception as e2:
        return False, _short_err(e2)


def _broadcast_summary(ok_count, reasons) -> str:
    fail = sum(reasons.values())
    lines = ["<b>ارسال همگانی پایان یافت.</b>", "",
             f"✅ موفق: <b>{ok_count}</b>", f"❌ ناموفق: <b>{fail}</b>"]
    if reasons:
        lines += ["", "<b>دلیل خطاها:</b>"]
        for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1])[:5]:
            lines.append(f"• {html.escape(reason)} ({n})")
        joined = " ".join(reasons).lower()
        if ok_count == 0 and any(k in joined for k in ("chat not found", "initiate", "blocked")):
            lines += ["", "ℹ️ این کاربران ربات فعلی را استارت نکرده‌اند یا آن را بلاک کرده‌اند "
                          "(مثلاً دیتابیس از ربات دیگری آمده یا توکن عوض شده است)."]
    return "\n".join(lines)


def send_message_safe(chat_id, text, reply_markup=None, entities=None, parse_mode=None):
    try:
        if entities:
            return bot.send_message(chat_id, text, reply_markup=reply_markup, entities=entities)
        return bot.send_message(chat_id, text, reply_markup=reply_markup, parse_mode=parse_mode or "HTML")
    except Exception:
        try:
            return bot.send_message(chat_id, text, reply_markup=reply_markup)
        except Exception:
            return None


def update_message_safe(chat_id, message_id, text, reply_markup=None, entities=None, parse_mode=None):
    if not message_id:
        return send_message_safe(chat_id, text, reply_markup=reply_markup, entities=entities, parse_mode=parse_mode)
    try:
        if entities:
            return bot.edit_message_text(text=text, chat_id=chat_id, message_id=message_id,
                                         reply_markup=reply_markup, entities=entities)
        return bot.edit_message_text(text=text, chat_id=chat_id, message_id=message_id,
                                     reply_markup=reply_markup, parse_mode=parse_mode or "HTML")
    except Exception as e:
        if "not modified" in str(e).lower():
            return None
        try:
            return bot.edit_message_text(text=text, chat_id=chat_id, message_id=message_id,
                                         reply_markup=reply_markup)
        except Exception as e2:
            if "not modified" in str(e2).lower():
                return None
            return send_message_safe(chat_id, text, reply_markup=reply_markup, entities=entities,
                                     parse_mode=parse_mode)


def edit_admin_msg(chat_id, message_id, text):
    try:
        bot.edit_message_caption(caption=text, chat_id=chat_id, message_id=message_id,
                                 reply_markup=None, parse_mode="HTML")
        return
    except Exception:
        pass
    try:
        bot.edit_message_text(text=text, chat_id=chat_id, message_id=message_id,
                              reply_markup=None, parse_mode="HTML")
    except Exception:
        pass


def del_msg(chat_id, message_id):
    try:
        bot.delete_message(chat_id, message_id)
    except Exception:
        pass


def step_reply(uid, chat_id, text, kb=None, entities=None):
    td = user_temp_data.setdefault(uid, {})
    lm = td.get("last_msg_id")
    if lm:
        try:
            if entities:
                bot.edit_message_text(text=text, chat_id=chat_id, message_id=lm, reply_markup=kb, entities=entities)
            else:
                bot.edit_message_text(text=text, chat_id=chat_id, message_id=lm, reply_markup=kb, parse_mode="HTML")
            return
        except Exception as e:
            if "not modified" in str(e).lower():
                return
    sent = send_message_safe(chat_id, text, reply_markup=kb, entities=entities, parse_mode="HTML")
    if sent:
        td["last_msg_id"] = sent.message_id


# ==================== کیبوردها ====================
def get_back_keyboard():
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main", btn_id="back_to_main"))
    return kb


def cancel_kb(cb="back_to_main"):
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="انصراف", callback_data=cb))
    return kb


PAIR_SLOTS = ("mid_right", "mid_left", "bot_right", "bot_left")


def compute_main_rows(avail_ids, positions=None):
    """چینش منوی اصلی را به‌صورت ردیف‌هایی از شناسهٔ دکمه‌ها برمی‌گرداند.
    دکمهٔ «راست» در تلگرام باید آخرِ ردیف باشد؛ هیچ دکمه‌ای هم نباید جا بیفتد."""
    if positions is None:
        try:
            positions = json.loads(get_setting("btn_positions"))
        except Exception:
            positions = {}
    avail = [i for i in MAIN_BTN_IDS if i in avail_ids]
    top, bot, slots, placed = [], [], {k: None for k in PAIR_SLOTS}, set()
    for b_id, pos in positions.items():
        if b_id not in avail:
            continue
        if pos == "top_single":
            top.append(b_id)
        elif pos == "bot_single":
            bot.append(b_id)
        elif pos in slots:
            if slots[pos] is None:
                slots[pos] = b_id
            else:
                bot.append(b_id)  # دو دکمه در یک جایگاه: دومی به پایین می‌رود تا ناپدید نشود
        else:
            continue
        placed.add(b_id)
    bot += [i for i in avail if i not in placed]  # دکمه‌های بدون جایگاه هم نمایش داده شوند
    rows = [[i] for i in top]
    for pair in (("mid_left", "mid_right"), ("bot_left", "bot_right")):
        r = [slots[k] for k in pair if slots[k]]
        if r:
            rows.append(r)
    rows += [[i] for i in bot]
    return rows


def get_main_keyboard(user_id):
    chan_link = get_setting("channel_link")
    if not is_valid_url(chan_link):
        chan_link = "https://t.me/telegram"

    btns_dict = {
        "btn_buy": make_inline_button(text=get_button_display("btn_buy"), callback_data="btn_buy", btn_id="btn_buy"),
        "btn_my_panels": make_inline_button(text=get_button_display("btn_my_panels"), callback_data="btn_my_panels",
                                            btn_id="btn_my_panels"),
        "btn_test": make_inline_button(text=get_button_display("btn_test"), callback_data="btn_test", btn_id="btn_test"),
        "btn_support": make_inline_button(text=get_button_display("btn_support"), callback_data="btn_support",
                                          btn_id="btn_support"),
        "btn_channel": make_inline_button(text=get_button_display("btn_channel"), url=chan_link, btn_id="btn_channel"),
        "btn_wallet": make_inline_button(text=get_button_display("btn_wallet"), callback_data="btn_wallet",
                                         btn_id="btn_wallet"),
    }
    btns_dict["btn_agency"] = make_inline_button(text=get_button_display("btn_agency"),
                                                 callback_data="btn_agency", btn_id="btn_agency")
    _mini = miniapp_url()
    if _mini:  # بدون لینک https معتبر، دکمه مینی‌اپ نمایش داده نمی‌شود
        btns_dict["btn_miniapp"] = make_inline_button(
            text=get_button_display("btn_miniapp"), btn_id="btn_miniapp", web_app=types.WebAppInfo(_mini))
    try:
        positions = json.loads(get_setting("btn_positions"))
    except Exception:
        positions = {}
    try:
        visible = set(json.loads(get_setting("visible_main_buttons") or "[]"))
    except Exception:
        visible = set(MAIN_BTN_IDS)
    btns_dict = {key: value for key, value in btns_dict.items() if key in visible}

    kb = types.InlineKeyboardMarkup()
    for row_ids in compute_main_rows(list(btns_dict.keys()), positions):
        btns = [btns_dict[i] for i in row_ids]
        if len(btns) == 1:
            kb.add(btns[0])
        else:
            kb.row(*btns)
    if is_admin(user_id):
        kb.add(make_inline_button(text=get_button_display("btn_admin_panel"), callback_data="admin_panel",
                                  btn_id="btn_admin_panel"))
    return kb


def kb_mode() -> str:
    return "reply" if get_setting("keyboard_mode") == "reply" else "inline"


def main_reply_keyboard(uid):
    ik = get_main_keyboard(uid)
    rk = types.ReplyKeyboardMarkup(resize_keyboard=True)
    for row in ik.keyboard:
        buttons = []
        for b in row:
            cd = getattr(b, "callback_data", None) or ""
            wa = getattr(b, "web_app", None)
            if wa:
                buttons.append(make_keyboard_button(b.text, btn_id="btn_miniapp", web_app=wa))
                continue
            bid = cd if cd.startswith("btn_") else ("btn_channel" if getattr(b, "url", None) else "btn_admin_panel")
            buttons.append(make_keyboard_button(b.text, btn_id=bid))
        rk.row(*buttons)
    return rk


def miniapp_url() -> str:
    """لینک مینی‌اپ؛ تلگرام فقط آدرس https قبول می‌کند. اگر نامعتبر باشد رشته خالی."""
    url = (get_setting("miniapp_url") or "").strip() or DEFAULT_MINIAPP_URL
    return url if url.lower().startswith("https://") and is_valid_url(url) else ""


def send_miniapp_open(chat_id):
    url = miniapp_url()
    if not url:
        send_message_safe(chat_id, "<b>مینی‌اپ فعلاً فعال نیست.</b>")
        return
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=get_button_display("btn_miniapp"), btn_id="btn_miniapp",
                              web_app=types.WebAppInfo(url)))
    send_message_safe(chat_id, "برای باز کردن مینی‌اپ دکمه زیر را بزنید 👇", reply_markup=kb)


def main_menu_markup(uid):
    return main_reply_keyboard(uid) if kb_mode() == "reply" else get_main_keyboard(uid)


def run_main_button(chat_id, uid, button_id):
    if button_id == "btn_admin_panel":
        if is_admin(uid):
            user_states.pop(uid, None)
            show_admin_panel(chat_id, None, uid)
        return
    if button_id == "btn_buy":
        show_shop_user_menu(chat_id, None, uid)
    elif button_id == "btn_my_panels":
        show_my_panels(chat_id, None, uid)
    elif button_id == "btn_wallet":
        show_wallet(chat_id, None, uid)
    elif button_id == "btn_test":
        show_test_menu(chat_id, None)
    elif button_id == "btn_support":
        show_support(chat_id, None)
    elif button_id == "btn_channel":
        link = get_setting("channel_link")
        if is_valid_url(link):
            send_message_safe(chat_id, f'<a href="{html.escape(link, quote=True)}">ورود به کانال</a>')
    elif button_id == "btn_agency":
        start_agency_link(chat_id, None, uid)
    elif button_id == "btn_miniapp":
        send_miniapp_open(chat_id)


def dispatch_reply_button(message):
    """متن دکمه‌ی کیبوردی منوی اصلی را به همان عملکرد دکمه شیشه‌ای وصل می‌کند."""
    uid, chat_id = message.from_user.id, message.chat.id
    label = strip_button_emoji((message.text or "").strip()) or (message.text or "").strip()
    if not label:
        return False
    mapping = {}
    for bid in MAIN_BTN_IDS:
        mapping[strip_button_emoji(get_button_display(bid)) or get_button_display(bid)] = bid
    if is_admin(uid):
        _ap = get_button_display("btn_admin_panel")
        mapping[strip_button_emoji(_ap) or _ap] = "btn_admin_panel"
    button_id = mapping.get(label)
    if not button_id:
        return False
    add_user_if_new(message.from_user)
    if button_id != "btn_admin_panel":
        if is_user_blocked(uid):
            send_message_safe(chat_id, "<b>حساب کاربری شما مسدود است.</b>")
            return True
        if bot_is_off_for(uid):
            send_message_safe(chat_id, get_setting("bot_off_text"),
                              entities=parse_db_entities(get_setting("bot_off_entities")))
            return True
        missing = get_missing_channels(uid)
        if missing:
            send_force_join_prompt(chat_id, None, missing)
            return True
        if rules_pending(uid):
            send_rules(chat_id, None, uid)
            return True
    run_main_button(chat_id, uid, button_id)
    return True


def show_main_menu(chat_id, msg_id, uid):
    text, entities = render_user_text(get_setting("start_text"), parse_db_entities(get_setting("start_entities")),
                                      get_user_display_name(uid))
    if kb_mode() == "reply":
        if msg_id:
            del_msg(chat_id, msg_id)
        sent = send_message_safe(chat_id, text, reply_markup=main_reply_keyboard(uid), entities=entities)
        if sent:
            set_user_last_menu(uid, sent.message_id)
        return
    kb = get_main_keyboard(uid)
    update_message_safe(chat_id, msg_id, text, reply_markup=kb, entities=entities)


# ---------- قوانین ----------
def rules_pending(uid) -> bool:
    if not (get_setting("rules_text") or "").strip():
        return False
    row = dbx("SELECT COALESCE(rules_ok, 0) FROM users WHERE user_id = ?", (uid,), "one")
    return not (row and int(row[0]) == 1)


def send_rules(chat_id, msg_id, uid):
    text, entities = render_user_text(get_setting("rules_text"), parse_db_entities(get_setting("rules_entities")),
                                      get_user_display_name(uid))
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=get_setting("btn_rules_accept") or "می‌پذیرم", callback_data="rules_accept",
                              btn_id="rules_accept"))
    sent = update_message_safe(chat_id, msg_id, text, reply_markup=kb, entities=entities)
    try:
        if sent is not None and hasattr(sent, "message_id"):
            set_user_last_menu(uid, sent.message_id)
    except Exception:
        pass
    return sent


# ---------- لینک مستقیم (دکمه کانال ← ورود به ربات و باز شدن بخش) ----------
pending_target = PersistentMap("pending_target")
_BOT_USERNAME = ""
DEEPLINK_CODES = {"btn_buy": "buy", "btn_my_panels": "panels", "btn_test": "test", "btn_wallet": "wallet",
                  "btn_support": "support", "btn_agency": "agency"}


def get_bot_username():
    global _BOT_USERNAME
    if not _BOT_USERNAME:
        try:
            _BOT_USERNAME = bot.get_me().username or ""
        except Exception:
            pass
    return _BOT_USERNAME or DEFAULT_BOT_USERNAME


def deeplink_for(target):
    """target: شناسه دکمه منو، g:<گروه> یا p:<پلن>"""
    if target in DEEPLINK_CODES:
        code = DEEPLINK_CODES[target]
    elif re.fullmatch(r"[gp]:\d+", target or ""):
        code = target.replace(":", "")
    else:
        return None
    uname = get_bot_username()
    return f"https://t.me/{uname}?start=go_{code}" if uname else None


def parse_deeplink(payload):
    m = re.fullmatch(r"go_(buy|panels|test|wallet|support|agency|g\d+|p\d+)", (payload or "").strip())
    return m.group(1) if m else None


def open_target(chat_id, uid, code, msg_id=None):
    if not msg_id:
        m = send_message_safe(chat_id, "⏳")
        msg_id = m.message_id if m else None
    if msg_id:
        set_user_last_menu(uid, msg_id)
    try:
        if code == "buy":
            show_shop_user_menu(chat_id, msg_id, uid)
        elif code == "panels":
            show_my_panels(chat_id, msg_id, uid)
        elif code == "test":
            show_test_menu(chat_id, msg_id)
        elif code == "wallet":
            show_wallet(chat_id, msg_id, uid)
        elif code == "support":
            show_support(chat_id, msg_id)
        elif code == "agency":
            start_agency_link(chat_id, msg_id, uid)
        elif code.startswith("g") and get_plan_group(int(code[1:])):
            show_shop_user_menu(chat_id, msg_id, uid, int(code[1:]))
        elif code.startswith("p") and get_shop_plan_by_id(int(code[1:])):
            show_plan_detail(chat_id, msg_id, uid, int(code[1:]))
        else:
            show_shop_user_menu(chat_id, msg_id, uid)
    except Exception:
        traceback.print_exc()
        show_main_menu(chat_id, msg_id, uid)


# ==================== منوی پنل‌های پاسارگاد ====================
def show_pasarguard_menu(chat_id, msg_id):
    panels = get_all_server_panels()
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="➕ ثبت پنل جدید (مرحله‌ای)", callback_data="pg_wizard_start", style="success"))
    for p in panels:
        kb.row(make_inline_button(text=str(p[1]), callback_data=f"pg_view:{p[0]}", btn_id="server_title",
                                  style="none"),
               make_inline_button(text="🗑 حذف", callback_data=f"pg_del_confirm:{p[0]}", style="danger"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    txt = ("<b>مدیریت اتصال به پنل‌های پاسارگاد (PasarGuard)</b>\n\n"
           f"تعداد پنل‌های متصل: <b>{len(panels)} عدد</b>\n\n"
           "برای مشاهده مشخصات، تغییرات یا بررسی اتصال، روی نام هر پنل بزنید:")
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def show_single_panel_details(chat_id, msg_id, pid):
    p = get_server_panel_by_id(pid)
    if not p:
        show_pasarguard_menu(chat_id, msg_id)
        return
    grp_display = "همه گروه‌ها" if p[5] == "ALL" else (p[5] or "پیش‌فرض")
    txt = (f"<b>مدیریت پنل: {html.escape(str(p[1]))}</b>\n\n"
           f"<b>آدرس پنل:</b> <code>{html.escape(str(p[2]))}</code>\n"
           f"<b>نام کاربری:</b> <code>{html.escape(str(p[3]))}</code>\n"
           f"<b>رمز عبور:</b> <code>********</code>\n"
           f"<b>گروه پیش‌فرض:</b> <code>{html.escape(str(grp_display))}</code>\n\n"
           "عملیات مورد نظر را انتخاب کنید:")
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="تغییر آدرس", callback_data=f"pg_edit_url:{pid}"),
           make_inline_button(text="تغییر یوزرنیم", callback_data=f"pg_edit_user:{pid}"))
    kb.row(make_inline_button(text="تغییر پسورد", callback_data=f"pg_edit_pass:{pid}"),
           make_inline_button(text="انتخاب گروه پنل", callback_data=f"pg_choose_groups:{pid}"))
    kb.row(make_inline_button(text="🔍 دیباگ API گروه‌ها", callback_data=f"pg_debug_groups:{pid}"),
           make_inline_button(text="تست و بررسی اتصال", callback_data=f"pg_check_conn:{pid}"))
    kb.row(make_inline_button(text="🗑 حذف این پنل", callback_data=f"pg_del_confirm:{pid}", style="danger"))
    kb.add(make_inline_button(text="بازگشت به لیست پنل‌ها", callback_data="adm_pasarguard"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def show_group_selection_menu(chat_id, msg_id, uid, pid):
    p = get_server_panel_by_id(pid)
    if not p:
        return
    panel_info = {"id": p[0], "name": p[1], "url": p[2], "username": p[3], "password": p[4]}
    all_records, _ = fetch_panel_group_records(panel_info)
    all_groups = [g["name"] for g in all_records]
    td = user_temp_data.setdefault(uid, {})
    td["grp_list"] = all_groups
    if "selected_groups" not in td:
        saved_str = p[5] or ""
        if saved_str == "ALL":
            td["selected_groups"] = list(all_groups)
            td["is_all"] = True
        else:
            td["selected_groups"] = [g.strip() for g in saved_str.split(",") if g.strip()]
            td["is_all"] = False
    sel = td["selected_groups"]
    is_all = td.get("is_all", False)

    kb = types.InlineKeyboardMarkup()
    if all_groups:
        kb.add(make_inline_button(text=f"{'✅' if is_all else '⬜'} انتخاب همه گروه‌ها",
                                  callback_data=f"toggle_grp_all:{pid}", style="", keep_emoji=True))
        for idx, grp in enumerate(all_groups):
            icon = "✅" if (grp in sel or is_all) else "⬜"
            kb.add(make_inline_button(text=f"{icon} {grp}", callback_data=f"toggle_grp:{pid}:{idx}",
                                      style="", keep_emoji=True))
        kb.row(make_inline_button(text="تایپ دستی ID گروه", callback_data=f"manual_type_group:{pid}", style=""),
               make_inline_button(text="تأیید نهایی", callback_data=f"save_groups_sel:{pid}"))
        note = ""
    else:
        kb.add(make_inline_button(text="تایپ دستی ID عددی گروه", callback_data=f"manual_type_group:{pid}", style=""))
        note = "\n\n⚠️ لیست گروه‌ها از پنل دریافت نشد (ادمین سودو نیست). ID گروه را دستی وارد کنید."
    kb.add(make_inline_button(text="انصراف", callback_data=f"pg_view:{pid}"))
    txt = f"<b>انتخاب گروه‌های پنل {html.escape(str(p[1]))}:</b>\nگروه‌های مورد نظر را تعیین و تأیید را بزنید:{note}"
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


# ==================== تنظیمات فروشگاه ====================
def show_shop_settings_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="➕ افزودن پلن جدید", callback_data="shop_add_plan", style="success"))
    kb.row(make_inline_button(text="📋 فهرست و ویرایش پلن‌ها", callback_data="shop_list_plans"))
    kb.row(make_inline_button(text="📁 گروه‌های پلن و حجم دلخواه", callback_data="shop_groups", keep_emoji=True))
    kb.row(make_inline_button(text="🎟 کدهای تخفیف", callback_data="shop_discounts", keep_emoji=True))
    kb.row(make_inline_button(
        text=("💲 قیمت روی دکمه پلن: روشن" if get_setting("shop_btn_price") == "1" else "💲 قیمت روی دکمه پلن: خاموش"),
        callback_data="shop_btn_price_toggle", keep_emoji=True))
    kb.row(make_inline_button(text="✏️ تغییر متن بالای فهرست پلن‌ها", callback_data="edit_text:shop_header_text"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, "<b>🛍 تنظیمات فروشگاه ربات:</b>\n\nبرای تعریف پلن‌های خرید و قیمت‌گذاری "
                                         "از دکمه‌های زیر استفاده کنید:", reply_markup=kb)


def show_plans_list_admin(chat_id, msg_id):
    plans = get_all_shop_plans()
    kb = types.InlineKeyboardMarkup()
    if not plans:
        kb.add(make_inline_button(text="➕ افزودن پلن جدید", callback_data="shop_add_plan"))
        kb.add(make_inline_button(text="بازگشت به تنظیمات فروشگاه", callback_data="shop_settings"))
        update_message_safe(chat_id, msg_id, "هنوز هیچ پلنی تعریف نشده است.", reply_markup=kb)
        return
    for pl in plans:
        p_id, p_name, _, _, p_price, _, p_server_id = pl
        p_server = get_server_panel_by_id(p_server_id)
        server_title = p_server[1] if p_server else "نامشخص"
        _grp = get_plan_group(get_plan_group_id(p_id)) if get_plan_group_id(p_id) else None
        _grp_name = f" | 📁{_grp[1]}" if _grp else ""
        kb.row(make_inline_button(text=f"{p_name} | {server_title} | {price_str(p_price)}{_grp_name}",
                                  callback_data=f"edit_plan:{p_id}", style="none", keep_emoji=True),
               make_inline_button(text="🗑 حذف", callback_data=f"del_plan:{p_id}", style="danger"))
    kb.add(make_inline_button(text="➕ افزودن پلن جدید", callback_data="shop_add_plan"))
    kb.add(make_inline_button(text="بازگشت به تنظیمات فروشگاه", callback_data="shop_settings"))
    update_message_safe(chat_id, msg_id, "<b>لیست پلن‌های فعال فروشگاه:</b>", reply_markup=kb)


def _ask_new_plan_color(chat_id, msg_id, td):
    if not colors_on():
        td["new_style"] = "primary"
        _ask_new_plan_emoji(td_uid(td), chat_id, msg_id, td)
        return
    kb = types.InlineKeyboardMarkup()
    for val, name in BTN_STYLE_CHOICES:
        kb.add(make_inline_button(text=name, callback_data=f"shop_new_color:{val}", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="انصراف", callback_data="shop_settings"))
    update_message_safe(chat_id, msg_id, "<b>مرحله ۸ از ۹:</b>\nرنگ دکمه این پلن را انتخاب کنید:", reply_markup=kb)


def td_uid(td):
    return td.get("_uid")


def _ask_new_plan_emoji(uid, chat_id, msg_id, td):
    user_states[uid] = "shop_add_emoji"
    td["last_msg_id"] = msg_id
    update_message_safe(chat_id, msg_id,
                        "<b>مرحله ۹ از ۹:</b>\nایموجی پریمیوم دکمه این پلن را بفرستید (یا عدد 0 برای رد شدن):",
                        reply_markup=cancel_kb("shop_settings"))


def finish_new_plan(uid, chat_id, msg_id):
    td = user_temp_data.get(uid, {})
    new_id = dbx("INSERT INTO shop_plans (name, traffic_gb, days, price, description, panel_id, "
                 "description_entities, group_id, btn_style) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                 (str(td.get("name") or "پلن جدید"), int(td.get("gb", 0)), int(td.get("days", 0)),
                  int(td.get("price", 0)), str(td.get("desc") or ""), int(td.get("new_panel_id") or 0),
                  str(td.get("desc_entities") or "[]"), int(td.get("new_group_id") or 0),
                  td.get("new_style") or "primary"), "id")
    if td.get("new_emoji"):
        set_setting(f"emoji_plan_{new_id}", str(td["new_emoji"]))
    user_temp_data.pop(uid, None)
    user_states.pop(uid, None)
    show_plan_edit_menu(chat_id, msg_id, new_id)


def show_plan_edit_menu(chat_id, msg_id, plan_id):
    plan = get_shop_plan_by_id(plan_id)
    if not plan:
        update_message_safe(chat_id, msg_id, "این پلن پیدا نشد.", reply_markup=cancel_kb("shop_list_plans"))
        return
    p_id, name, gb, days, price, desc, panel_id = plan
    panel = get_server_panel_by_id(panel_id)
    panel_name = panel[1] if panel else "نامشخص"
    desc_view = tableize_html(custom_emoji_html(desc, get_shop_plan_entities(plan_id))) if desc else "ثبت نشده"
    _gid = get_plan_group_id(plan_id)
    _grp = get_plan_group(_gid) if _gid else None
    _style_row = dbx("SELECT COALESCE(btn_style, 'primary') FROM shop_plans WHERE id = ?", (plan_id,), "one")
    _style_name = dict(BTN_STYLE_CHOICES).get(_style_row[0] if _style_row else "primary", "🔵 آبی")
    text = (f"<b>ویرایش پلن:</b> {html.escape(str(name))}\n\n"
            f"گروه: <b>{html.escape(str(_grp[1])) if _grp else 'بدون گروه'}</b>\n"
            f"رنگ دکمه: <b>{_style_name}</b>\n"
            f"ایموجی پریمیوم: <b>{'ثبت شده' if get_setting(f'emoji_plan_{plan_id}') else 'ندارد'}</b>\n"
            f"حجم: <b>{gb if gb else 'نامحدود'} گیگابایت</b>\n"
            f"مدت: <b>{days if days else 'نامحدود'} روز</b>\n"
            f"قیمت: <b>{price_str(price)}</b>\n"
            f"سرور: <b>{html.escape(str(panel_name))}</b>\n"
            f"توضیحات: {desc_view}")
    kb = types.InlineKeyboardMarkup()
    for field, label in (("name", "نام"), ("gb", "حجم"), ("days", "مدت"), ("price", "قیمت"),
                         ("description", "توضیحات"), ("panel", "سرور")):
        kb.add(make_inline_button(text=f"تغییر {label}", callback_data=f"edit_plan_field:{p_id}:{field}"))
    kb.add(make_inline_button(text="📁 تغییر گروه", callback_data=f"edit_plan_grp:{p_id}", keep_emoji=True))
    kb.row(make_inline_button(text="🎨 رنگ دکمه", callback_data=f"edit_plan_color:{p_id}", keep_emoji=True),
           make_inline_button(text="✨ ایموجی پریمیوم", callback_data=f"edit_plan_emoji:{p_id}", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به فهرست پلن‌ها", callback_data="shop_list_plans"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


GROUP_COLS = ("id, name, header, custom_enabled, custom_price, custom_min, custom_max, "
              "custom_days, custom_panel_id, btn_style")
BTN_STYLE_CHOICES = (("primary", "🔵 آبی"), ("success", "🟢 سبز"), ("danger", "🔴 قرمز"), ("none", "⚪ بدون رنگ"))


def get_plan_groups():
    return dbx(f"SELECT {GROUP_COLS} FROM plan_groups ORDER BY COALESCE(sort_order, 0), id", (), "all")


def get_plan_group(gid):
    return dbx(f"SELECT {GROUP_COLS} FROM plan_groups WHERE id = ?", (gid,), "one")


def get_plans_in_group(gid):
    return dbx("SELECT id, name, traffic_gb, days, price, description, panel_id, COALESCE(btn_style, 'primary') "
               "FROM shop_plans WHERE COALESCE(is_custom, 0) = 0 AND COALESCE(group_id, 0) = ? "
               "ORDER BY COALESCE(sort_order, 0), id",
               (gid,), "all")


def group_cols(gid) -> int:
    row = dbx("SELECT COALESCE(cols, 2) FROM plan_groups WHERE id = ?", (gid,), "one")
    return 1 if row and int(row[0]) == 1 else 2


def move_in_order(table, ids, target_id, direction):
    ids = list(ids)
    if target_id not in ids:
        return
    i = ids.index(target_id)
    j = i - 1 if direction == "up" else i + 1
    if j < 0 or j >= len(ids):
        return
    ids[i], ids[j] = ids[j], ids[i]
    for n, rid in enumerate(ids, 1):
        dbx(f"UPDATE {table} SET sort_order = ? WHERE id = ?", (n, rid))


def show_group_order_menu(chat_id, msg_id, gid):
    g = get_plan_group(gid)
    if not g:
        show_groups_admin(chat_id, msg_id)
        return
    plans = get_plans_in_group(gid)
    kb = types.InlineKeyboardMarkup()
    for i, pl in enumerate(plans):
        kb.row(make_inline_button(text=f"{i + 1}. {pl[1]}", callback_data="shop_noop", style="none", keep_emoji=True),
               make_inline_button(text="⬆️", callback_data=f"shop_plan_move:{gid}:{pl[0]}:up", style="none",
                                  keep_emoji=True),
               make_inline_button(text="⬇️", callback_data=f"shop_plan_move:{gid}:{pl[0]}:down", style="none",
                                  keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به گروه", callback_data=f"shop_grp_edit:{gid}"))
    body = "ترتیب پلن‌ها در صفحه گروه همین‌طور نمایش داده می‌شود. با فلش‌ها جابه‌جا کنید:" if plans \
        else "هنوز پلنی در این گروه ثبت نشده است."
    update_message_safe(chat_id, msg_id, f"<b>↕️ چیدن پلن‌های گروه «{html.escape(str(g[1]))}»</b>\n\n{body}",
                        reply_markup=kb)


def get_plan_group_id(pid):
    row = dbx("SELECT COALESCE(group_id, 0) FROM shop_plans WHERE id = ?", (pid,), "one")
    return int(row[0]) if row else 0


def _add_grid(kb, buttons, cols=2):
    for i in range(0, len(buttons), cols):
        kb.row(*buttons[i:i + cols])


def plan_button_label(pl):
    label = str(pl[1])
    if get_setting("shop_btn_price") == "1":
        label += f" ◂ {price_str(pl[4])}"
    return label


def show_shop_user_menu(chat_id, msg_id, uid, group_id=0):
    groups = get_plan_groups()
    group = get_plan_group(group_id) if group_id else None
    kb = types.InlineKeyboardMarkup()

    if group:
        gid, g_name = group[0], group[1]
        plans = get_plans_in_group(gid)
        buttons = [make_inline_button(text=plan_button_label(pl), callback_data=f"buy_plan_sel:{pl[0]}",
                                      btn_id=f"plan_{pl[0]}", style=pl[7] or "primary", keep_emoji=True)
                   for pl in plans]
        _add_grid(kb, buttons, group_cols(gid))
        if group[3] and int(group[4] or 0) > 0:
            kb.add(make_inline_button(text=get_setting("btn_custom_volume") or "حجم دلخواه",
                                      callback_data=f"buy_custom:{gid}", btn_id="btn_custom_volume",
                                      keep_emoji=True))
        kb.add(make_inline_button(text=get_setting("btn_back_groups") or "بازگشت به گروه‌ها",
                                  callback_data="btn_buy", btn_id="btn_back_groups", keep_emoji=True))
        if not plans and not (group[3] and int(group[4] or 0) > 0):
            body = "هنوز پلنی در این گروه ثبت نشده است."
        else:
            body = group[2] or "پکیج موردنظر را انتخاب کنید؛ جزئیات و توضیحات آن در مرحله بعد نمایش داده می‌شود."
        update_message_safe(chat_id, msg_id,
                            f"<b>{html.escape(str(g_name))}</b>\n\n{tableize_html(html.escape(body))}",
                            reply_markup=kb)
        return

    ungrouped = [pl for pl in get_all_shop_plans() if get_plan_group_id(pl[0]) == 0]
    if not groups and not ungrouped:
        update_message_safe(chat_id, msg_id, "در حال حاضر هیچ پلنی برای فروش ثبت نشده است.",
                            reply_markup=get_back_keyboard())
        return
    _add_grid(kb, [make_inline_button(text=str(g[1]), callback_data=f"ugrp:{g[0]}", btn_id=f"grp_{g[0]}",
                                      style=g[9] or "primary", keep_emoji=True) for g in groups])
    for pl in ungrouped:
        p_id, p_name, p_gb, p_days, p_price, _, _ = pl
        traf_str = f"{p_gb} گیگابایت" if p_gb > 0 else "نامحدود"
        days_str = f"{p_days} روز" if p_days > 0 else "نامحدود"
        kb.add(make_inline_button(text=f"{p_name} ({traf_str} - {days_str}) ◂ {price_str(p_price)}",
                                  callback_data=f"buy_plan_sel:{p_id}", style="none",
                                  btn_id="server_title", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به منوی اصلی", callback_data="back_to_main"))
    title = get_setting("shop_header_text") or "لطفاً یکی از پلن‌های زیر را جهت خرید انتخاب کنید:"
    entities = parse_db_entities(get_setting("shop_header_entities"))
    update_message_safe(chat_id, msg_id, title, reply_markup=kb, entities=entities)


def show_groups_admin(chat_id, msg_id):
    groups = get_plan_groups()
    kb = types.InlineKeyboardMarkup()
    for g in groups:
        n = (dbx("SELECT COUNT(*) FROM shop_plans WHERE COALESCE(is_custom, 0) = 0 AND COALESCE(group_id, 0) = ?",
                 (g[0],), "one") or [0])[0]
        kb.row(make_inline_button(text=f"📁 {g[1]} ({n} پلن)", callback_data=f"shop_grp_edit:{g[0]}",
                                  style="none", keep_emoji=True),
               make_inline_button(text="⬆️", callback_data=f"shop_grp_move:{g[0]}:up", style="none", keep_emoji=True),
               make_inline_button(text="⬇️", callback_data=f"shop_grp_move:{g[0]}:down", style="none",
                                  keep_emoji=True),
               make_inline_button(text="🗑", callback_data=f"shop_grp_del:{g[0]}", style="danger", keep_emoji=True))
    kb.add(make_inline_button(text="➕ ساخت گروه جدید", callback_data="shop_grp_add", style="success",
                              keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به تنظیمات فروشگاه", callback_data="shop_settings"))
    update_message_safe(chat_id, msg_id,
                        "<b>📁 گروه‌های پلن</b>\n\nهر گروه یک دکمه در صفحه خرید می‌شود و پلن‌هایش دو‌ستونه کنار هم "
                        "نمایش داده می‌شوند. هر گروه می‌تواند «حجم دلخواه» جدا داشته باشد.\n"
                        "پلن‌های بدون گروه مثل قبل زیر گروه‌ها نشان داده می‌شوند.", reply_markup=kb)


def show_group_edit_menu(chat_id, msg_id, gid):
    g = get_plan_group(gid)
    if not g:
        show_groups_admin(chat_id, msg_id)
        return
    panel = get_server_panel_by_id(g[8]) if g[8] else None
    style_name = dict(BTN_STYLE_CHOICES).get(g[9] or "primary", "🔵 آبی")
    days_txt = f"{g[7]} روز" if int(g[7] or 0) > 0 else "نامحدود"
    on = bool(g[3])
    text = (f"<b>📁 گروه: {html.escape(str(g[1]))}</b>\n\n"
            f"رنگ دکمه گروه: <b>{style_name}</b>\n"
            f"ایموجی پریمیوم: <b>{'ثبت شده' if get_setting(f'emoji_grp_{gid}') else 'ندارد'}</b>\n"
            f"متن بالای گروه: <b>{'ثبت شده' if g[2] else 'پیش‌فرض'}</b>\n\n"
            f"<b>✏️ حجم دلخواه:</b> {'🟢 فعال' if on else '🔴 غیرفعال'}\n"
            f"قیمت هر گیگابایت: <b>{int(g[4] or 0):,} تومان</b>\n"
            f"حداقل خرید: <b>{g[5]} گیگابایت</b>\n"
            f"حداکثر خرید: <b>{g[6]} گیگابایت</b>\n"
            f"مدت اعتبار: <b>{days_txt}</b>\n"
            f"سرور: <b>{html.escape(str(panel[1])) if panel else 'اولین سرور ثبت‌شده'}</b>")
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="✏️ نام گروه", callback_data=f"shop_grp_field:{gid}:name", keep_emoji=True),
           make_inline_button(text="📝 متن بالای گروه", callback_data=f"shop_grp_field:{gid}:header",
                              keep_emoji=True))
    kb.row(make_inline_button(text="🎨 رنگ دکمه", callback_data=f"shop_grp_color:{gid}", keep_emoji=True),
           make_inline_button(text="✨ ایموجی پریمیوم", callback_data=f"shop_grp_field:{gid}:emoji",
                              keep_emoji=True))
    kb.add(make_inline_button(text=("🔴 غیرفعال کردن حجم دلخواه" if on else "🟢 فعال کردن حجم دلخواه"),
                              callback_data=f"shop_grp_toggle:{gid}", style="danger" if on else "success",
                              keep_emoji=True))
    kb.row(make_inline_button(text="💰 قیمت هر گیگ", callback_data=f"shop_grp_field:{gid}:price", keep_emoji=True),
           make_inline_button(text="⏳ مدت (روز)", callback_data=f"shop_grp_field:{gid}:days", keep_emoji=True))
    kb.row(make_inline_button(text="📉 حداقل گیگ", callback_data=f"shop_grp_field:{gid}:min", keep_emoji=True),
           make_inline_button(text="📈 حداکثر گیگ", callback_data=f"shop_grp_field:{gid}:max", keep_emoji=True))
    kb.add(make_inline_button(text="🖥 سرور حجم دلخواه", callback_data=f"shop_grp_field:{gid}:panel",
                              keep_emoji=True))
    kb.row(make_inline_button(text="↕️ چیدن پلن‌ها", callback_data=f"shop_grp_order:{gid}", keep_emoji=True),
           make_inline_button(text=f"▦ ستون پلن‌ها: {group_cols(gid)}", callback_data=f"shop_grp_cols:{gid}",
                              keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به گروه‌ها", callback_data="shop_groups"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def _render_table_block(block):
    rows, sep_after = [], None
    for ln in block:
        plain = html.unescape(re.sub(r"<[^>]+>", "", ln)).strip()
        cells = [c.strip() for c in plain.strip("|").split("|")]
        if cells and all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c) and any(cells):
            if sep_after is None and rows:
                sep_after = len(rows)
            continue
        rows.append(cells)
    if not rows:
        return "\n".join(block)
    ncol = max(len(r) for r in rows)
    rows = [r + [""] * (ncol - len(r)) for r in rows]
    widths = [max(len(r[i]) for r in rows) for i in range(ncol)]
    out = []
    for idx, r in enumerate(rows):
        out.append(" │ ".join(r[i].ljust(widths[i]) for i in range(ncol)).rstrip())
        if sep_after is not None and idx + 1 == sep_after:
            out.append("─┼─".join("─" * widths[i] for i in range(ncol)))
    return "<pre>" + html.escape("\n".join(out)) + "</pre>"


def tableize_html(h):
    """خطوط پشت‌سرهم با | را به جدول هم‌تراز (monospace) تبدیل می‌کند."""
    if not h or "|" not in h:
        return h
    lines, out, i = h.split("\n"), [], 0
    while i < len(lines):
        if "|" in lines[i]:
            j = i
            while j < len(lines) and "|" in lines[j]:
                j += 1
            if j - i >= 2:
                out.append(_render_table_block(lines[i:j]))
                i = j
                continue
        out.append(lines[i])
        i += 1
    return "\n".join(out)


def show_plan_detail(chat_id, msg_id, uid, p_id):
    user_temp_data.setdefault(uid, {}).pop("discount", None)
    pl = get_shop_plan_by_id(p_id)
    if not pl:
        update_message_safe(chat_id, msg_id, "این پلن دیگر وجود ندارد.", reply_markup=cancel_kb("btn_buy"))
        return
    p_id, p_name, p_gb, p_days, p_price, p_desc, _ = pl
    p_desc_html = tableize_html(custom_emoji_html(p_desc, get_shop_plan_entities(p_id))) if p_desc else ""
    desc_block = f"📝 <b>توضیحات:</b>\n{p_desc_html}\n\n" if p_desc_html else ""
    txt = (f"<b>اطلاعات پلن انتخابی:</b>\n\n🏷️ <b>عنوان:</b> {html.escape(str(p_name))}\n"
           f"📊 <b>حجم:</b> {f'{p_gb} گیگابایت' if p_gb > 0 else 'نامحدود'}\n"
           f"⏳ <b>زمان:</b> {f'{p_days} روز' if p_days > 0 else 'نامحدود'}\n"
           f"💰 <b>قیمت:</b> {price_str(p_price)}\n\n"
           f"{desc_block}"
           f"جهت ادامه مراحل، تأیید را بزنید:")
    gid = get_plan_group_id(p_id)
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="تأیید و ادامه خرید 💳", callback_data=f"buy_confirm_plan:{p_id}",
                              style="success", keep_emoji=True))
    kb.add(make_inline_button(text="انصراف و بازگشت", callback_data=f"ugrp:{gid}" if gid else "btn_buy"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def get_or_create_custom_plan(group, gb):
    gid, g_name = group[0], group[1]
    price = int(gb) * int(group[4] or 0)
    days, panel_id = int(group[7] or 0), int(group[8] or 0)
    row = dbx("SELECT id FROM shop_plans WHERE COALESCE(is_custom, 0) = 1 AND traffic_gb = ? AND days = ? "
              "AND price = ? AND panel_id = ? AND COALESCE(group_id, 0) = ?", (int(gb), days, price, panel_id, gid),
              "one")
    if row:
        return row[0]
    return dbx("INSERT INTO shop_plans (name, traffic_gb, days, price, description, panel_id, "
               "description_entities, group_id, is_custom, btn_style) VALUES (?, ?, ?, ?, '', ?, '[]', ?, 1, 'primary')",
               (f"{g_name} - حجم دلخواه {gb} گیگ", int(gb), days, price, panel_id, gid), "id")


def show_wallet(chat_id, msg_id, uid):
    row = dbx("SELECT wallet_balance FROM users WHERE user_id = ?", (uid,), "one")
    balance = int(row[0] or 0) if row else 0
    txs = dbx("SELECT amount, balance_after, note, created_at FROM wallet_transactions WHERE user_id = ? "
              "ORDER BY id DESC LIMIT 5", (uid,), "all") or []
    header = get_setting("wallet_text") or "👛 کیف پول شما"
    entities = parse_db_entities(get_setting("wallet_entities"))
    esc = (lambda t: t) if entities else html.escape
    lines = [header, "", f"💰 موجودی: {balance:,} تومان"]
    if txs:
        lines += ["", "🧾 آخرین تراکنش‌ها:"]
        for amount, after, note, created in txs:
            sign = "➕" if int(amount) > 0 else "➖"
            lines.append(f"{sign} {abs(int(amount)):,} تومان — {esc(str(note or ''))} (موجودی: {int(after):,})")
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="💳 شارژ کیف پول", callback_data="wallet_charge", style="success",
                              keep_emoji=True))
    kb.add(make_inline_button(text=get_button_display("btn_buy"), callback_data="btn_buy", btn_id="btn_buy"))
    kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main",
                              btn_id="back_to_main"))
    update_message_safe(chat_id, msg_id, "\n".join(lines), reply_markup=kb, entities=entities)


def wallet_pay_message(amount):
    header = get_setting("wallet_pay_text") or ""
    ents = parse_db_entities(get_setting("wallet_pay_entities"))
    c_num, c_name = get_setting("card_number"), get_setting("card_holder")
    tail = "\n\nبعد از واریز، تصویر رسید را همین‌جا بفرستید."
    if ents:
        text = header + f"\n\n💰 مبلغ: {int(amount):,} تومان"
        off = None
        if c_num:
            text += "\n\n💳 شماره کارت:\n"
            off = utf16_len(text)
            text += c_num + f"\n👤 به نام: {c_name}"
        text += tail
        ents = list(ents)
        if off is not None:
            ents.append(types.MessageEntity(type="code", offset=off, length=utf16_len(c_num)))
        return text, ents
    text = (html.escape(header) + f"\n\n💰 مبلغ: <b>{int(amount):,} تومان</b>")
    if c_num:
        text += (f"\n\n💳 شماره کارت:\n<code>{html.escape(c_num)}</code>\n"
                 f"👤 به نام: <b>{html.escape(c_name)}</b>")
    return text + tail, None


def wallet_charge_caption(cid):
    row = dbx("SELECT user_id, amount, status FROM wallet_charges WHERE id = ?", (cid,), "one")
    if not row:
        return ""
    uid_, amount, status = row
    u = dbx("SELECT username, first_name, last_name FROM users WHERE user_id = ?", (uid_,), "one") or ("", "", "")
    name = html.escape((f"{u[1] or ''} {u[2] or ''}").strip() or "ندارد")
    uname = f"@{html.escape(u[0])}" if u[0] else "ندارد"
    st = {"pending": "⏳ در انتظار بررسی", "approved": "✅ تأیید شد", "rejected": "❌ رد شد"}.get(status, status)
    return (f"<b>💳 درخواست شارژ کیف پول #{cid}</b>\n\n👤 {name} | {uname}\n🆔 <code>{uid_}</code>\n"
            f"💰 مبلغ: <b>{int(amount):,} تومان</b>\nوضعیت: <b>{st}</b>")


def notify_admins_wallet_charge(cid):
    row = dbx("SELECT file_id, file_type FROM wallet_charges WHERE id = ?", (cid,), "one")
    cap = wallet_charge_caption(cid)
    if not row or not cap:
        return 0
    file_id, ftype = row
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="✅ تأیید و شارژ", callback_data=f"adm_order_wc_ok:{cid}", style="success",
                              keep_emoji=True),
           make_inline_button(text="❌ رد", callback_data=f"adm_order_wc_no:{cid}", style="danger", keep_emoji=True))
    count = 0
    for adm in get_all_admin_ids():
        try:
            if file_id and ftype == "photo":
                bot.send_photo(adm, file_id, caption=cap, reply_markup=kb, parse_mode="HTML")
            elif file_id and ftype == "document":
                bot.send_document(adm, file_id, caption=cap, reply_markup=kb, parse_mode="HTML")
            else:
                bot.send_message(adm, cap, reply_markup=kb, parse_mode="HTML")
            count += 1
        except Exception:
            traceback.print_exc()
    return count


def notify_user(uid, text):
    """اطلاعیه به کاربر درباره‌ی اقدام مدیر؛ اگر کاربر ربات را بسته باشد بی‌صدا رد می‌شود."""
    if get_setting("notify_user_actions") == "0":
        return None
    try:
        return bot.send_message(uid, text, parse_mode="HTML")
    except Exception:
        return None


def capture_emoji_value(message, text):
    """ایموجی پریمیوم را از پیام یا آیدی عددی می‌گیرد. خروجی: (ok, مقدار؛ رشته‌ی خالی یعنی حذف)"""
    if (text or "").strip() == "0":
        return True, ""
    _, emoji_id = extract_custom_emoji(message.text or message.caption or "",
                                       message.entities or message.caption_entities)
    if emoji_id:
        return True, str(emoji_id)
    digits = to_en_digits(text or "").strip()
    if digits.isdigit() and len(digits) > 5:
        return True, digits
    return False, ""


# ==================== ساخت اکانت (فقط حجم روی ادمین اعمال می‌شود) ====================
def provision_account(username, password, gb, days, panel_id, mb=None, hours=None):
    if mb is None:
        mb = (gb or 0) * 1024
    if hours is None:
        hours = (days or 0) * 24
    if get_account_mode() == "admin":
        exp_ts = int(time.time() + hours * 3600) if hours > 0 else 0
        ok, res = create_pasarguard_admin(username, password, panel_id,
                                          data_limit_bytes=int(mb * 1024 * 1024) if mb > 0 else 0, expire_ts=exp_ts)
        kind = "admin"
    else:
        ok, res = create_pasarguard_user(username, float(mb), float(hours), panel_id)
        kind = "user"
    if not ok:
        return False, res
    uname, link, spid = res
    expire_ts = int(time.time() + hours * 3600) if hours > 0 else 0
    return True, (uname, link, spid, kind, expire_ts)


def save_user_panel(uid, name, uname, pwd, link, is_test, spid, kind, expire_ts):
    dbx("INSERT INTO user_panels (user_id, name, username, password, url, is_test, panel_id, kind, expire_ts) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (uid, name, uname, pwd or "", link, 1 if is_test else 0, spid, kind, expire_ts))


def account_text(header, rows, uname, pwd, link, kind):
    lines = [f"{header}\n"]
    for k, v in rows:
        lines.append(f"<b>{k}:</b> {v}")
    lines.append(f"<b>نام کاربری:</b> <code>{html.escape(str(uname))}</code>")
    if kind == "admin" and pwd:
        lines.append(f"<b>رمز عبور:</b> <code>{html.escape(str(pwd))}</code>")
    lines.append(f"\n<b>{'لینک پنل' if kind == 'admin' else 'لینک اتصال'}:</b>\n<code>{html.escape(str(link))}</code>")
    return "\n".join(lines)


def my_panels_kb():
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="مشاهده در پنل‌های من", callback_data="btn_my_panels"))
    kb.add(make_inline_button(text="بازگشت به منوی اصلی", callback_data="back_to_main"))
    return kb


# ==================== مراحل خرید ====================
def show_payment_and_receipt_step(chat_id, msg_id, uid):
    u_data = user_temp_data.get(uid, {})
    plan_id = u_data.get("plan_id")
    pl = get_shop_plan_by_id(plan_id)
    if not pl:
        user_states.pop(uid, None)
        update_message_safe(chat_id, msg_id, "این پلن دیگر وجود ندارد؛ لطفاً دوباره از فهرست پلن‌ها انتخاب کنید.",
                            reply_markup=cancel_kb("btn_buy"))
        return
    c_num = get_setting("card_number") or "ثبت نشده"
    c_name = get_setting("card_holder") or "ثبت نشده"
    base_price = int(pl[4] or 0)
    disc = u_data.get("discount") or {}
    if disc.get("plan") != plan_id:
        disc = {}
    price, disc_amount, disc_code = discounted_price(base_price, disc.get("code"), plan_id)
    if disc.get("code") and not disc_code:
        u_data.pop("discount", None)
    renew_panel_id = int(u_data.get("renew_panel_id") or 0)
    payment_token = save_draft(uid, plan_id, u_data.get("custom_username"),
                               u_data.get("custom_password"), renew_panel_id, price, disc_code)
    user_states[uid] = "buy_wait_receipt"
    wallet_row = dbx("SELECT wallet_balance FROM users WHERE user_id = ?", (uid,), "one")
    wallet_balance = int(wallet_row[0] or 0) if wallet_row else 0
    pass_line = ""
    if get_account_mode() == "admin" and u_data.get("custom_password"):
        pass_line = f"🔑 <b>رمز عبور:</b> <code>{html.escape(str(u_data.get('custom_password')))}</code>\n"
    renewal_line = "<b>نوع سفارش:</b> تمدید سرویس موجود\n\n" if renew_panel_id else ""
    account_line = "" if renew_panel_id else (
        f"👤 <b>نام کاربری:</b> <code>{html.escape(str(u_data.get('custom_username')))}</code>\n"
        f"{pass_line}\n")
    wallet_line = (f"💰 <b>موجودی کیف پول:</b> {wallet_balance:,} تومان\n"
                   if wallet_balance >= price else
                   f"💰 <b>موجودی کیف پول:</b> {wallet_balance:,} تومان "
                   f"(برای پرداخت کامل {max(0, price - wallet_balance):,} تومان کم است)\n")
    txt = (f"<b>مرحله نهایی پرداخت</b>\n\n"
           f"{renewal_line}"
           + (f"🏷 <b>قیمت پلن:</b> <s>{base_price:,}</s> تومان\n"
              f"🎟 <b>کد تخفیف</b> <code>{html.escape(disc_code)}</code>: {disc_amount:,}- تومان\n"
              if disc_code else "") +
           f"💵 <b>مبلغ قابل پرداخت:</b> <code>{price:,} تومان</code>\n"
           f"{wallet_line}\n"
           f"💳 <b>شماره کارت:</b>\n<code>{html.escape(str(c_num))}</code>\n\n"
           f"👤 <b>به نام:</b> <b>{html.escape(str(c_name))}</b>\n\n"
           f"{account_line}"
           f"<i>لطفاً پس از واریز مبلغ، تصویر فیش پرداختی خود را ارسال نمایید.</i>")
    kb = types.InlineKeyboardMarkup()
    if wallet_balance >= price and price > 0:
        kb.add(make_inline_button(text=f"پرداخت {price:,} تومان از کیف پول",
                                  callback_data=f"buy_wallet_pay:{payment_token}", style="success"))
    if discount_enabled():
        if disc_code:
            kb.add(make_inline_button(text="❌ حذف کد تخفیف", callback_data="buy_disc_remove", keep_emoji=True))
        else:
            kb.add(make_inline_button(text="🎟 وارد کردن کد تخفیف", callback_data="buy_disc_enter",
                                      keep_emoji=True))
    kb.add(make_inline_button(text="انصراف و بازگشت", callback_data="back_to_main"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)
    if msg_id:
        user_temp_data.setdefault(uid, {})["last_msg_id"] = msg_id


def proceed_after_credentials(chat_id, msg_id, uid):
    u = user_temp_data.get(uid, {})
    pl = get_shop_plan_by_id(u.get("plan_id"))
    if pl and (pl[4] or 0) <= 0:
        finish_free_purchase(chat_id, msg_id, uid)
    else:
        show_payment_and_receipt_step(chat_id, msg_id, uid)


def finish_free_purchase(chat_id, msg_id, uid):
    u = user_temp_data.get(uid, {})
    pl = get_shop_plan_by_id(u.get("plan_id"))
    c_user = u.get("custom_username") or "user"
    c_pass = u.get("custom_password") or ""
    if not pl:
        update_message_safe(chat_id, msg_id, "این پلن دیگر وجود ندارد.", reply_markup=get_back_keyboard())
        return
    _, p_name, p_gb, p_days, _, _, p_server_id = pl
    update_message_safe(chat_id, msg_id, "<b>در حال ساخت سرویس رایگان شما...</b>")
    c_pass = ensure_panel_password(c_pass)
    ok, result = provision_account(c_user, c_pass, p_gb, p_days, p_server_id)
    if not ok:
        update_message_safe(chat_id, msg_id, f"<b>خطا در ساخت سرویس:</b>\n<code>{html.escape(str(result))}</code>",
                            reply_markup=get_back_keyboard())
        return
    final_uname, link, server_pid, kind, expire_ts = result
    save_user_panel(uid, p_name, final_uname, c_pass, link, False, server_pid, kind, expire_ts)
    dbx("INSERT INTO orders (user_id, plan_id, custom_username, custom_password, status, amount, payment_method) "
        "VALUES (?, ?, ?, ?, 'approved', ?, 'free')", (uid, pl[0], final_uname, c_pass, int(pl[4] or 0)))
    user_states.pop(uid, None)
    user_temp_data.pop(uid, None)
    clear_draft(uid)
    days_str = f"{p_days} روز" if p_days > 0 else "نامحدود"
    traf_str = f"{p_gb} گیگابایت" if p_gb > 0 else "نامحدود"
    rows = [("پلن", html.escape(str(p_name))), ("حجم", traf_str), ("مدت", days_str)]
    txt = account_text("✅ <b>سرویس رایگان شما ساخته شد.</b>", rows, final_uname, c_pass, link, kind)
    update_message_safe(chat_id, msg_id, txt, reply_markup=my_panels_kb())
    user_row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
    tg_username = user_row[0] if user_row else ""
    send_report(f"ℹ️ خرید رایگان پلن «{html.escape(str(p_name))}» توسط کاربر "
                f"<a href=\"tg://user?id={uid}\">{uid}</a> انجام شد.",
                user_id=uid, username=tg_username, topic="sales")


# ==================== سفارش‌ها و رسید ====================
def order_caption(order_id):
    row = dbx("SELECT o.user_id, o.plan_id, o.custom_username, o.status, u.username, u.first_name, u.last_name, "
              "o.renew_panel_id, o.amount, o.payment_method "
              "FROM orders o LEFT JOIN users u ON u.user_id = o.user_id WHERE o.id = ?", (order_id,), "one")
    if not row:
        return None
    buyer_id, plan_id, c_user, status, tg_user, fn, ln, renew_panel_id, amount, payment_method = row
    pl = get_shop_plan_by_id(plan_id)
    p_name = pl[1] if pl else "نامشخص"
    price = int(amount or 0)
    full_name = html.escape((f"{fn or ''} {ln or ''}").strip() or "نامشخص")
    tg_user_txt = (f"@{html.escape(tg_user)}" if tg_user else
                   f'<a href="tg://user?id={buyer_id}">باز کردن پی‌وی کاربر</a>')
    renew_row = dbx("SELECT name FROM user_panels WHERE id = ? AND user_id = ?",
                    (renew_panel_id, buyer_id), "one") if renew_panel_id else None
    purchase_type = (f"تمدید سرویس «{html.escape(str(renew_row[0]))}»" if renew_row
                     else "خرید سرویس جدید")
    payment_label = "کیف پول" if payment_method == "wallet" else "رسید بانکی"
    return (f"📥 <b>رسید پرداخت جدید</b> — سفارش <code>#{order_id}</code>\n\n"
            f"🔄 <b>نوع سفارش:</b> {purchase_type}\n"
            f"🏷 <b>پلن:</b> {html.escape(str(p_name))}\n"
            f"💵 <b>مبلغ:</b> {price:,} تومان\n"
            f"💳 <b>روش پرداخت:</b> {payment_label}\n"
            f"👤 <b>نام تلگرام:</b> {full_name}\n"
            f"🆔 <b>آیدی عددی:</b> <a href=\"tg://user?id={buyer_id}\">{buyer_id}</a>\n"
            f"🔗 <b>یوزرنیم تلگرام:</b> {tg_user_txt}\n"
            f"🔤 <b>نام کاربری درخواستی:</b> <code>{html.escape(str(c_user))}</code>")


def order_admin_kb(order_id):
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="✅ تأیید و ساخت", callback_data=f"adm_order_approve:{order_id}", style="success"),
           make_inline_button(text="❌ رد", callback_data=f"adm_order_reject:{order_id}", style="danger"))
    return kb


def send_order_to(admin_id, order_id) -> bool:
    row = dbx("SELECT file_id, file_type FROM orders WHERE id = ?", (order_id,), "one")
    cap = order_caption(order_id)
    if not row or not cap:
        return False
    file_id, ftype = row
    kb = order_admin_kb(order_id)
    try:
        if file_id and ftype == "photo":
            bot.send_photo(admin_id, file_id, caption=cap, reply_markup=kb, parse_mode="HTML")
            return True
        elif file_id and ftype == "document":
            bot.send_document(admin_id, file_id, caption=cap, reply_markup=kb, parse_mode="HTML")
            return True
        else:
            bot.send_message(admin_id, cap, reply_markup=kb, parse_mode="HTML")
            return True
    except Exception:
        traceback.print_exc()
        try:
            bot.send_message(admin_id, cap, reply_markup=kb, parse_mode="HTML")
            return True
        except Exception:
            return False


def notify_admins_order(order_id) -> int:
    """رسید/سفارش را برای ادمین‌ها داخل خود ربات (پی‌وی) می‌فرستد."""
    count = 0
    for adm in get_all_admin_ids():
        if send_order_to(adm, order_id):
            count += 1
    return count


def send_pending_orders(chat_id):
    rows = dbx("SELECT id FROM orders WHERE status = 'pending' ORDER BY id", (), "all")
    if not rows:
        send_message_safe(chat_id, "سفارش در انتظاری وجود ندارد ✅")
        return
    for (oid,) in rows:
        send_order_to(chat_id, oid)


def process_order_approval(chat_id, msg_id, order_id, admin_uid):
    row = dbx("SELECT o.user_id, o.plan_id, o.custom_username, o.custom_password, o.status, "
              "u.username, u.first_name, u.last_name, o.renew_panel_id, o.amount "
              "FROM orders o LEFT JOIN users u ON u.user_id = o.user_id WHERE o.id = ?",
              (order_id,), "one")
    if not row:
        edit_admin_msg(chat_id, msg_id, f"سفارش <code>#{order_id}</code> یافت نشد.")
        return
    buyer_id, plan_id, c_user, c_pass, status, tg_user, fn, ln, renew_panel_id, order_amount = row
    if status != "pending":
        send_message_safe(chat_id, f"سفارش #{order_id} قبلاً تعیین وضعیت شده است (وضعیت: {status}).")
        return
    if dbx("UPDATE orders SET status = 'processing' WHERE id = ? AND status = 'pending'", (order_id,)) == 0:
        return

    def revert():
        dbx("UPDATE orders SET status = 'pending' WHERE id = ?", (order_id,))

    pl = get_shop_plan_by_id(plan_id)
    if not pl:
        revert()
        send_message_safe(chat_id, f"❌ پلن این سفارش دیگر وجود ندارد (سفارش #{order_id}).")
        return
    _, p_name, p_gb, p_days, p_price, _, p_server_id = pl

    if renew_panel_id:
        ok, result = fulfill_renewal(buyer_id, renew_panel_id, pl)
        if not ok:
            revert()
            send_message_safe(chat_id, f"❌ تمدید سفارش <code>#{order_id}</code> انجام نشد:\n"
                                       f"<code>{html.escape(str(result))}</code>")
            return
        dbx("UPDATE orders SET status = 'approved' WHERE id = ?", (order_id,))
        send_message_safe(buyer_id, result, reply_markup=cancel_kb("btn_my_panels"))
        edit_admin_msg(chat_id, msg_id, f"سفارش <code>#{order_id}</code> تأیید شد ✅ و سرویس تمدید شد.")
        return

    # تلاش برای ساخت اکانت
    c_pass = ensure_panel_password(c_pass)
    ok, result = provision_account(c_user, c_pass, p_gb, p_days, p_server_id)
    if not ok:
        revert()
        buyer_name = html.escape((f"{fn or ''} {ln or ''}").strip() or "ندارد")
        buyer_username = f"@{html.escape(tg_user)}" if tg_user else "ندارد"
        now_str = datetime.now().strftime("%Y-%m-%d | %H:%M:%S")

        # ارسال گزارش خطا به گروه گزارشات یا ادمین‌ها
        fail_report = (
            "⚠️ <b>خطا در ساخت پنل پس از تایید رسید!</b>\n"
            "┈┅━━⊷⊰❌⊱⊶━━┅┈\n"
            f"🔢 <b>شماره سفارش:</b> <code>#{order_id}</code>\n"
            f"👤 <b>خریدار:</b> {buyer_name}\n"
            f"   • <b>یوزرنیم:</b> {buyer_username}\n"
            f"   • <b>آیدی عددی:</b> <code>{buyer_id}</code>\n"
            f"📦 <b>پلن:</b> {html.escape(str(p_name))}\n"
            f"🕒 <b>زمان:</b> <code>{now_str}</code>\n"
            "┈┅━━⊷⊰❌⊱⊶━━┅┈\n"
            f"🔴 <b>علت خطا:</b>\n<code>{html.escape(str(result))}</code>"
        )
        send_report(fail_report, user_id=buyer_id, username=tg_user, topic="errors")
        return

    final_uname, link, server_pid, kind, expire_ts = result
    save_user_panel(buyer_id, p_name, final_uname, c_pass, link, False, server_pid, kind, expire_ts)
    dbx("UPDATE orders SET status = 'approved' WHERE id = ?", (order_id,))

    days_str = f"{p_days} روز" if p_days > 0 else "نامحدود"
    traf_str = f"{p_gb} گیگابایت" if p_gb > 0 else "نامحدود"
    rows = [("پلن", html.escape(str(p_name))), ("حجم", traf_str), ("مدت", days_str)]
    user_txt = account_text("✅ <b>پرداخت شما تأیید و سرویس ساخته شد.</b>", rows, final_uname, c_pass, link, kind)
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="مشاهده در پنل‌های من", callback_data="btn_my_panels"))
    send_message_safe(buyer_id, user_txt, reply_markup=kb)
    edit_admin_msg(chat_id, msg_id, f"سفارش <code>#{order_id}</code> تأیید شد ✅ و اکانت "
                                    f"<code>{html.escape(final_uname)}</code> ساخته شد.")

    # ارسال گزارش فروش موفق برای مالک و تمام ادمین‌ها
    p_server = get_server_panel_by_id(server_pid)
    server_title = p_server[1] if p_server else "نامشخص"
    buyer_name = html.escape((f"{fn or ''} {ln or ''}").strip() or "ندارد")
    buyer_username = f"@{html.escape(tg_user)}" if tg_user else "ندارد"
    now_str = datetime.now().strftime("%Y-%m-%d | %H:%M:%S")

    sales_report = (
        "🛍 <b>خرید جدید ثبت شد</b>\n"
        "┈┅━━⊷⊰⌛⊱⊶━━┅┈\n"
        f"📦 <b>مشخصات:</b> {html.escape(str(p_name))} | {traf_str} | {days_str}\n"
        f"🖥 <b>پنل:</b> {html.escape(str(server_title))}\n"
        f"💰 <b>مبلغِ پرداخت‌شده:</b>  {int(order_amount or 0):,} تومان\n"
        f"🕒 {now_str}\n"
        "┈┅━━⊷⊰⌛⊱⊶━━┅┈\n"
        f"👤 <b>خریدار:</b> {buyer_name}\n"
        f"   • <b>یوزرنیم:</b> {buyer_username}\n"
        f"   • <b>آیدی عددی:</b> <code>{buyer_id}</code>"
    )
    send_report(sales_report, user_id=buyer_id, username=tg_user, topic="sales")


# ==================== پنل‌های من / چینش / تست ====================
def show_my_panels(chat_id, msg_id, uid):
    rows = dbx("SELECT id, name, is_test FROM user_panels WHERE user_id = ?", (uid,), "all")
    kb = types.InlineKeyboardMarkup()
    if not rows:
        txt = (get_setting("my_panels_text") or "سرویس‌های شما:") + \
              "\n\nشما هنوز سرویسی ندارید؛ برای تهیه سرویس از «خرید» استفاده کنید."
        entities = parse_db_entities(get_setting("my_panels_entities"))
    else:
        txt = get_setting("my_panels_text") or "سرویس‌های شما:\nبرای مشاهده مشخصات و وضعیت، روی هر سرویس بزنید:"
        entities = parse_db_entities(get_setting("my_panels_entities"))
        for pid, name, is_test in rows:
            title = f"{name} (تست)" if is_test else str(name)
            kb.add(make_inline_button(text=title, callback_data=f"show_panel:{pid}",
                                      btn_id="server_title", style="none", keep_emoji=True))
    kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main", btn_id="back_to_main"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb, entities=entities)


def renew_plans_for_server(server_id):
    """پلن‌های تمدیدِ یک سرور؛ اگر برای آن سرور پلنی تعریف نشده (مثلاً پنل نمایندگی)، همهٔ پلن‌ها."""
    allp = get_all_shop_plans()
    own = [p for p in allp if int(p[6] or 0) == int(server_id or 0)]
    return own or allp


def renew_plan_allowed(plan, server_id):
    return any(int(p[0]) == int(plan[0]) for p in renew_plans_for_server(server_id))


def show_renewal_plans(chat_id, msg_id, uid, panel_id):
    service = dbx("SELECT name, panel_id FROM user_panels WHERE id = ? AND user_id = ?",
                  (panel_id, uid), "one")
    if not service:
        update_message_safe(chat_id, msg_id, "سرویس پیدا نشد.", reply_markup=get_back_keyboard())
        return
    plans = renew_plans_for_server(service[1])
    if not plans:
        update_message_safe(chat_id, msg_id, "برای سرور این سرویس پلن تمدید ثبت نشده است.",
                            reply_markup=cancel_kb(f"show_panel:{panel_id}"))
        return
    kb = types.InlineKeyboardMarkup()
    for p_id, p_name, gb, days, price, _desc, _server_id in plans:
        traffic = f"{gb} گیگابایت" if gb else "نامحدود"
        duration = f"{days} روز" if days else "نامحدود"
        kb.add(make_inline_button(
            text=f"{p_name} ({traffic}، {duration}) — {price_str(price)}",
            callback_data=f"renew_plan_sel:{panel_id}:{p_id}", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به سرویس", callback_data=f"show_panel:{panel_id}"))
    update_message_safe(chat_id, msg_id,
                        f"<b>تمدید سرویس «{html.escape(str(service[0]))}»</b>\n"
                        "پلن تمدید را انتخاب کنید؛ حجم و زمان آن به همان سرویس اضافه می‌شود.",
                        reply_markup=kb)


def fulfill_renewal(user_id, panel_id, plan):
    service = dbx("SELECT username, panel_id, kind, name FROM user_panels "
                  "WHERE id = ? AND user_id = ?", (panel_id, user_id), "one")
    if not service:
        return False, "سرویس برای تمدید پیدا نشد."
    username, server_id, kind, service_name = service
    _plan_id, plan_name, traffic_gb, days, _price, _desc, _plan_server_id = plan
    ok, result = renew_pasarguard_account(username, server_id, kind, traffic_gb, days)
    if not ok:
        return False, result
    traffic_text = f"{traffic_gb} گیگابایت" if traffic_gb else "نامحدود"
    duration_text = f"{days} روز" if days else "نامحدود"
    text = (f"✅ <b>تمدید سرویس انجام شد.</b>\n\n"
            f"<b>سرویس:</b> {html.escape(str(service_name))}\n"
            f"<b>پلن افزوده‌شده:</b> {html.escape(str(plan_name))}\n"
            f"<b>حجم:</b> {traffic_text}\n<b>مدت:</b> {duration_text}")
    user_row = dbx("SELECT username FROM users WHERE user_id = ?", (user_id,), "one")
    tg_username = user_row[0] if user_row else ""
    send_report(
        f"🔄 <b>سرویس تمدید شد</b>\n\n"
        f"👤 <b>کاربر:</b> <a href=\"tg://user?id={user_id}\">{user_id}</a>\n"
        f"🔗 <b>یوزرنیم:</b> {('@' + html.escape(tg_username)) if tg_username else 'ندارد'}\n"
        f"📦 <b>سرویس:</b> {html.escape(str(service_name))}\n"
        f"🛍 <b>پلن تمدید:</b> {html.escape(str(plan_name))}",
        user_id=user_id, username=tg_username, topic="sales")
    return True, text


def process_wallet_payment(chat_id, msg_id, user_id, payment_token):
    claimed, payment = reserve_wallet_order(user_id, payment_token)
    if not claimed:
        return False, payment

    order_id = payment["order_id"]
    try:
        _dcode = get_draft_discount_code(user_id)
        if _dcode:
            mark_discount_used(_dcode)
            dbx("UPDATE orders SET discount_code = ? WHERE id = ?", (_dcode, order_id))
    except Exception:
        traceback.print_exc()
    plan_id, custom_username, custom_password, renew_panel_id = payment["draft"]
    amount = payment["amount"]
    plan = get_shop_plan_by_id(plan_id)
    if not plan:
        ok, error = False, "پلن پیش از تکمیل پرداخت حذف شده است."
    else:
        action_applied = False
        try:
            if renew_panel_id:
                ok, error = fulfill_renewal(user_id, renew_panel_id, plan)
                if ok:
                    action_applied = True
                    dbx("UPDATE orders SET status = 'approved' WHERE id = ? AND status = 'processing'",
                        (order_id,))
                    clear_draft(user_id)
                    user_states.pop(user_id, None)
                    user_temp_data.pop(user_id, None)
                    update_message_safe(chat_id, msg_id, error,
                                        reply_markup=cancel_kb(f"show_panel:{renew_panel_id}"))
                    return True, "پرداخت کیف پول انجام و سرویس تمدید شد."
            else:
                custom_password = ensure_panel_password(custom_password)
                ok, error = provision_account(custom_username or "user", custom_password or "",
                                              plan[2], plan[3], plan[6])
                if ok:
                    action_applied = True
                    final_uname, link, server_pid, kind, expire_ts = error
                    save_user_panel(user_id, plan[1], final_uname, custom_password or "",
                                    link, False, server_pid, kind, expire_ts)
                    dbx("UPDATE orders SET status = 'approved' WHERE id = ? AND status = 'processing'",
                        (order_id,))
                    days_text = f"{plan[3]} روز" if plan[3] > 0 else "نامحدود"
                    traffic_text = f"{plan[2]} گیگابایت" if plan[2] > 0 else "نامحدود"
                    summary = account_text(
                        "✅ <b>پرداخت با کیف پول انجام شد و سرویس ساخته شد.</b>",
                        [("پلن", html.escape(str(plan[1]))),
                         ("حجم", traffic_text), ("مدت", days_text)],
                        final_uname, custom_password or "", link, kind)
                    user_states.pop(user_id, None)
                    user_temp_data.pop(user_id, None)
                    clear_draft(user_id)
                    update_message_safe(chat_id, msg_id, summary, reply_markup=my_panels_kb())
                    user_row = dbx("SELECT username FROM users WHERE user_id = ?", (user_id,), "one")
                    tg_username = user_row[0] if user_row else ""
                    send_report(
                        f"💳 <b>خرید با کیف پول انجام شد</b>\n\n"
                        f"👤 <b>کاربر:</b> <a href=\"tg://user?id={user_id}\">{user_id}</a>\n"
                        f"📦 <b>پلن:</b> {html.escape(str(plan[1]))}\n"
                        f"💰 <b>مبلغ:</b> {amount:,} تومان\n"
                        f"🔗 <b>یوزرنیم تلگرام:</b> "
                        f"{('@' + html.escape(tg_username)) if tg_username else 'ندارد'}",
                        user_id=user_id, username=tg_username, topic="sales")
                    return True, "پرداخت کیف پول انجام و سرویس ساخته شد."
        except Exception as e:
            if action_applied:
                traceback.print_exc()
                try:
                    dbx("UPDATE orders SET status = 'approved' WHERE id = ? AND status = 'processing'",
                        (order_id,))
                    clear_draft(user_id)
                    user_states.pop(user_id, None)
                    user_temp_data.pop(user_id, None)
                except Exception:
                    traceback.print_exc()
                send_report(
                    f"⚠️ <b>پرداخت کیف پول انجام شد اما ثبت نهایی نیاز به بررسی دارد</b>\n"
                    f"کاربر: <code>{user_id}</code> | سفارش: <code>#{order_id}</code>\n"
                    f"جزئیات: <code>{html.escape(str(e)[:300])}</code>",
                    user_id=user_id, topic="errors")
                update_message_safe(chat_id, msg_id,
                                    "✅ سرویس اعمال شد. ثبت نهایی سفارش نیاز به بررسی پشتیبانی دارد؛ "
                                    "لطفاً این پیام را نگه دارید.",
                                    reply_markup=get_back_keyboard())
                return True, "سرویس اعمال شده است؛ وضعیت نهایی برای بررسی مدیر گزارش شد."
            ok, error = False, str(e)

    reason = str(error or "عملیات خرید ناموفق بود.")
    try:
        refunded = refund_wallet_order(user_id, order_id, payment_token, amount, reason)
    except Exception:
        traceback.print_exc()
        refunded = False
    if refunded:
        return False, (f"سفارش انجام نشد و مبلغ {amount:,} تومان به کیف پول برگشت خورد.\n"
                       f"علت: {reason}")

    send_report(
        f"⚠️ <b>بازپرداخت خودکار کیف پول ناموفق بود</b>\n\n"
        f"کاربر: <code>{user_id}</code>\nسفارش: <code>#{order_id}</code>\n"
        f"مبلغ: <b>{amount:,} تومان</b>\nعلت: <code>{html.escape(reason[:300])}</code>",
        user_id=user_id, topic="errors")
    return False, ("خرید کامل نشد و بازپرداخت خودکار هم تأیید نشد؛ "
                   "مدیر باید سفارش را بررسی کند.")


POS_NAMES = {"top_single": "بالا تکی", "mid_right": "وسط راست", "mid_left": "وسط چپ",
             "bot_right": "پایین راست", "bot_left": "پایین چپ", "bot_single": "پایین تکی"}
MAIN_BTN_IDS = ["btn_buy", "btn_my_panels", "btn_test", "btn_wallet", "btn_support", "btn_channel", "btn_miniapp",
                "btn_agency"]


def show_layout_editor(chat_id, msg_id):
    try:
        positions = json.loads(get_setting("btn_positions"))
    except Exception:
        positions = {}
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(
        text=f"حالت دکمه‌های منوی اصلی: {'کیبوردی (دکمه‌ای)' if kb_mode() == 'reply' else 'شیشه‌ای'} ◂ تغییر",
        callback_data="adm_layout_mode", style="none", keep_emoji=True))
    for b_id in MAIN_BTN_IDS:
        pos = POS_NAMES.get(positions.get(b_id), "تعیین نشده (پایین)")
        kb.add(make_inline_button(text=f"▫️ {get_button_display(b_id)} ◂ [ {pos} ]", callback_data=f"set_pos_menu:{b_id}"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    try:
        vis = set(json.loads(get_setting("visible_main_buttons") or "[]"))
    except Exception:
        vis = set(MAIN_BTN_IDS)
    rows = compute_main_rows([i for i in MAIN_BTN_IDS if i in vis], positions)
    prev = "\n".join("  |  ".join(html.escape(str(get_button_display(i))) for i in r) for r in rows) or "—"
    update_message_safe(chat_id, msg_id,
                        "<b>چینش دکمه‌های منوی اصلی:</b>\nروی هر دکمه بزنید تا جایگاه آن را تغییر دهید. "
                        "اگر جایگاه پر باشد، دو دکمه با هم جابه‌جا می‌شوند.\n\n"
                        f"<b>پیش‌نمایش (چپ ← راست):</b>\n{prev}\n\n"
                        "<i>دکمه‌های مخفی‌شده در پیش‌نمایش نیستند؛ برای نمایش، از «نمایش/مخفی دکمه‌ها» استفاده کنید.</i>",
                        reply_markup=kb)


def show_test_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    enabled = []
    if get_setting("test_panel_enabled") != "0":
        kb.row(make_inline_button(text="تست پنل", callback_data="test_panel_mode"))
        enabled.append("تست پنل")
    if get_setting("test_shared_enabled") != "0":
        kb.row(make_inline_button(text="تست اشتراکی", callback_data="test_shared"))
        enabled.append("تست اشتراکی")
    kb.add(make_inline_button(text="بازگشت", callback_data="back_to_main"))
    if enabled:
        text = "<b>بخش تست سرویس:</b>\n\nنوع تست مورد نظر خود را انتخاب کنید:"
    else:
        text = "<b>در حال حاضر هیچ نوع تستی فعال نیست.</b>"
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def show_support(chat_id, msg_id):
    supp_text = get_setting("support_text")
    supp_user = get_setting("support_user")
    entities = parse_db_entities(get_setting("support_entities"))
    has_user = bool(supp_user and supp_user != "None")
    clean_user = supp_user.replace("@", "") if has_user else ""
    if "{id}" in supp_text:
        full_text = supp_text.replace("{id}", clean_user)
        entities = None
    elif has_user:
        full_text = f"{supp_text}\n\n@{clean_user}"
    else:
        full_text = supp_text
    update_message_safe(chat_id, msg_id, full_text, reply_markup=get_back_keyboard(), entities=entities)


def create_test_account(chat_id, msg_id, uid, uname, pwd):
    if get_setting("test_panel_enabled") == "0":
        update_message_safe(chat_id, msg_id, "تست پنل غیرفعال شده است.",
                            reply_markup=get_back_keyboard())
        return
    if not claim_test_slot(uid):
        update_message_safe(chat_id, msg_id, "شما قبلاً تست خود را دریافت کرده‌اید.",
                            reply_markup=get_back_keyboard())
        return
    ded_mb = float(get_setting("test_ded_traffic_mb") or 1024)
    ded_hr = float(get_setting("test_ded_time_hours") or 24)
    update_message_safe(chat_id, msg_id, "<b>در حال ایجاد تست روی سرور...</b>")
    pwd = ensure_panel_password(pwd)
    uname = build_shared_username(uid) or uname
    ok, result = provision_account(uname, pwd, 0, 0, None, mb=ded_mb, hours=ded_hr)
    if not ok:
        release_test_slot(uid, False)
        update_message_safe(chat_id, msg_id, f"<b>خطا در ساخت تست:</b>\n<code>{html.escape(str(result))}</code>",
                            reply_markup=get_back_keyboard())
        return
    final_uname, link, server_pid, kind, expire_ts = result
    save_user_panel(uid, final_uname, final_uname, pwd, link, True, server_pid, kind, expire_ts)
    release_test_slot(uid, True)
    user_states.pop(uid, None)
    user_temp_data.pop(uid, None)
    rows = [("حجم", format_mb_display(ded_mb)), ("اعتبار", f"{int(ded_hr)} ساعت")]
    txt = account_text("<b>تست ساخته شد:</b>", rows, final_uname, pwd, link, kind)
    update_message_safe(chat_id, msg_id, txt, reply_markup=my_panels_kb())
    user_row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
    tg_username = user_row[0] if user_row else ""
    send_report(
        f"🧪 <b>تست اختصاصی دریافت شد</b>\n\n"
        f"👤 <b>کاربر:</b> <a href=\"tg://user?id={uid}\">{uid}</a>\n"
        f"🔗 <b>یوزرنیم:</b> {('@' + html.escape(tg_username)) if tg_username else 'ندارد'}\n"
        f"🔢 <b>آیدی عددی:</b> <code>{uid}</code>\n"
        f"🖥 <b>نام سرویس:</b> {html.escape(str(final_uname))}",
        user_id=uid, username=tg_username, topic="tests")


# ==================== عضویت اجباری ====================
def get_missing_channels(uid):
    if is_admin(uid) or get_setting("force_join_enabled") != "1":
        return []
    channels = dbx("SELECT chat_id, title, invite_link FROM force_join_channels", (), "all")
    missing = []
    for cid, title, link in channels:
        try:
            m = bot.get_chat_member(cid, uid)
            if m.status in ("left", "kicked"):
                missing.append((cid, title, link))
        except Exception:
            pass
    return missing


def send_force_join_prompt(chat_id, msg_id, missing):
    kb = types.InlineKeyboardMarkup()
    tpl = strip_button_emoji(get_setting("fj_btn_join")) or "عضویت در {title}"
    for cid, title, link in missing:
        if link and is_valid_url(link):
            try:
                label = tpl.replace("{title}", str(title))
            except Exception:
                label = f"عضویت در {title}"
            kb.add(make_inline_button(text=label, url=link, btn_id="fj_join", keep_emoji=True))
    check_txt = get_button_display("fj_btn_check") or "عضو شدم"
    kb.add(make_inline_button(text=check_txt, callback_data="fj_check", btn_id="fj_check"))
    text = get_setting("fj_text") or "برای استفاده از ربات ابتدا در کانال‌های زیر عضو شوید:"
    entities = parse_db_entities(get_setting("fj_entities"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb, entities=entities)


# ==================== سایر منوهای ادمین ====================
BOT_VERSION = "1.3.0"
BOT_CHANGELOG = [
    ("1.3.0", ["منوی جدید پنل مدیریت با گزارش سریع", "ایموجی پرمیوم سراسری برای همه‌جای ربات",
               "بخش خطاها، سوالات متداول و تغییرات نسخه‌ها",
               "امکان ارسال خود ایموجی پرمیوم به‌جای آیدی عددی در تنظیم ایموجی دکمه‌ها"]),
]
_BOT_ERRORS = []


def log_bot_error(where, err):
    try:
        _BOT_ERRORS.append((time.strftime("%Y-%m-%d %H:%M"), str(where)[:60], str(err)[:300]))
        del _BOT_ERRORS[:-30]
    except Exception:
        pass


def _panel_quick_report():
    q = lambda sql, a=(): ((dbx(sql, a, "one") or [0])[0] or 0)
    try:
        n_users = q("SELECT COUNT(*) FROM users")
        n_sum = q("SELECT SUM(amount) FROM orders WHERE status = 'approved'")
        n_month = q("SELECT SUM(amount) FROM orders WHERE status = 'approved' "
                    "AND created_at >= datetime('now', '-30 days')")
        n_pend = q("SELECT COUNT(*) FROM orders WHERE status = 'pending'")
    except Exception:
        n_users = n_sum = n_month = n_pend = 0
    sale_on = get_setting("bot_status") != "off"
    return ("⚙️ <b>پنل مدیریت ربات</b>\n━━━━━━━━━━━━\n\n📊 <b>گزارش سریع</b>\n\n"
            f"👥 کاربران: <b>{n_users}</b>\n"
            f"💰 مجموع فروش: <b>{price_str(n_sum)}</b> تومان\n"
            f"📅 فروش ماه گذشته: <b>{price_str(n_month)}</b> تومان\n"
            f"🧾 در انتظار بررسی: <b>{n_pend}</b>\n\n"
            f"🛍 وضعیت ربات: {'🟢 روشن' if sale_on else '🔴 خاموش'}\n\n"
            f"🤖 <b>اطلاعات ربات</b>\nنسخه ربات: <b>{BOT_VERSION}</b>")


def _grid(kb, buttons):
    buttons = [b for b in buttons if b]
    for i in range(0, len(buttons), 2):
        kb.row(*buttons[i:i + 2])


def show_admin_panel(chat_id, msg_id, uid, cat=None):
    can = lambda *f: any(admin_can(uid, x) for x in f)
    B = lambda t, cb: make_inline_button(text=t, callback_data=cb, keep_emoji=True)
    back = lambda cb="admin_panel": make_inline_button(text="⬅️ بازگشت", callback_data=cb, keep_emoji=True)
    kb = types.InlineKeyboardMarkup()

    if cat is None:
        _grid(kb, [
            can("shop", "servers", "payment", "orders", "tests", "system") and B("💼 مدیریت کسب‌وکار", "adm_cat:biz"),
            can("users") and B("👥 کاربران", "adm_manage_users"),
            can("system") and B("🗄 مدیریت بکاپ‌ها", "adm_db_menu"),
            can("texts", "buttons") and B("🎨 شخصی‌سازی", "adm_cat:custom"),
            can("broadcast", "force_join", "links") and B("📢 مدیریت اعلان‌ها", "adm_cat:notif"),
            can("texts") and B("📜 قوانین", "adm_edit_texts_sec:rules"),
            can("texts") and B("❓ سوالات متداول", "adm_faq"),
            can("system") and B("🧯 خطاها", "adm_errors"),
            can("system") and B("📝 تغییرات نسخه‌ها", "adm_changelog"),
            can("admins") and B("👮 مدیریت ادمین‌ها", "adm_manage_admins"),
        ])
        kb.add(make_inline_button(text="🏠 منوی اصلی", callback_data="back_to_main", keep_emoji=True))
        update_message_safe(chat_id, msg_id, _panel_quick_report(), reply_markup=kb)
        return

    if cat == "biz":
        _grid(kb, [
            can("shop") and B("📦 مدیریت تعرفه‌ها", "shop_settings"),
            can("servers") and B("🖥 مدیریت سرورها", "adm_pasarguard"),
            can("system", "tests") and B("🟢 مدیریت فروش", "adm_cat:sales"),
            can("shop") and B("🏷 کدهای تخفیف", "shop_discounts"),
            can("orders") and B("🧾 پرداختی‌ها", "adm_pending_orders"),
            can("payment") and B("💳 روش‌های پرداخت", "adm_card_settings"),
            can("users") and B("🎁 ریست تست کاربران", "usr_reset_tests_confirm"),
            can("users") and B("👥 مدیریت زیرمجموعه‌ها", "adm_manage_users"),
        ])
        kb.add(back())
        update_message_safe(chat_id, msg_id, "💼 <b>مدیریت کسب‌وکار</b>\n\nبخش موردنظر را انتخاب کنید.",
                            reply_markup=kb)
        return

    if cat == "sales":
        is_on = get_setting("bot_status") != "off"
        mode_txt = "پنل ادمین (سودو)" if get_account_mode() == "admin" else "اشتراک"
        if can("system"):
            kb.add(make_inline_button(text="خاموش‌کردن ربات" if is_on else "روشن‌کردن ربات",
                                      callback_data="adm_toggle_bot_power",
                                      style="danger" if is_on else "success"))
            kb.add(make_inline_button(text="📊 آمار ربات", callback_data="adm_stats", keep_emoji=True))
            kb.add(make_inline_button(text=f"حالت ساخت اکانت: {mode_txt}", callback_data="adm_toggle_mode"))
            kb.add(make_inline_button(text="تنظیم Role ID ادمین", callback_data="set_role_id"))
            kb.add(make_inline_button(text=f"👤 نام کاربری اشتراکی‌ها: {SU_MODES[shared_username_mode()]}",
                                      callback_data="adm_su_menu", keep_emoji=True))
            kb.add(make_inline_button(
                text=f"مهلت حذف سرویس منقضی: {get_setting('expiry_cleanup_minutes') or '10'} دقیقه",
                callback_data="set_expiry_cleanup"))
        if can("tests"):
            kb.add(make_inline_button(text="تنظیم حجم و زمان تست", callback_data="adm_test_plans"))
        kb.add(back("adm_cat:biz"))
        update_message_safe(chat_id, msg_id,
                            "🛒 <b>مدیریت فروش</b>\n\n"
                            f"وضعیت ربات: {'🟢 روشن' if is_on else '🔴 خاموش'}\n"
                            f"حالت ساخت اکانت: <b>{mode_txt}</b>", reply_markup=kb)
        return

    if cat == "custom":
        _grid(kb, [
            can("texts") and B("🎨 رنگ دکمه‌ها", "adm_edit_colors"),
            (can("texts", "buttons")) and B("📝 متن‌ها و دکمه‌ها", "adm_cat:texts"),
        ])
        if can("texts"):
            kb.add(B("💎 ایموجی پرمیوم همه‌جای ربات", "adm_pem_menu"))
        kb.add(back())
        update_message_safe(chat_id, msg_id, "🎨 <b>شخصی‌سازی</b>\n\nبخش موردنظر را انتخاب کنید.",
                            reply_markup=kb)
        return

    if cat == "texts":
        if can("texts"):
            kb.add(make_inline_button(text=get_setting("btn_adm_texts") or "ویرایش متن‌ها",
                                      callback_data="adm_edit_texts"))
            _grid(kb, [B("✨ ایموجی دکمه‌ها", "adm_edit_emojis"),
                       B(f"😀 ری‌اکشن استارت: {reaction_label(get_setting('start_reaction'))}",
                         "adm_start_reaction")])
        if can("buttons"):
            _grid(kb, [make_inline_button(text=get_setting("btn_adm_layout") or "چیدمان دکمه‌ها",
                                          callback_data="adm_layout"),
                       make_inline_button(text="نمایش/مخفی‌سازی دکمه‌ها", callback_data="adm_button_visibility")])
            kb.add(make_inline_button(text="تنظیم کامند دکمه‌های اصلی", callback_data="adm_commands"))
        kb.add(back("adm_cat:custom"))
        update_message_safe(chat_id, msg_id, "📝 <b>متن‌ها و دکمه‌ها</b>\n\nگزینه موردنظر را انتخاب کنید.",
                            reply_markup=kb)
        return

    if cat == "notif":
        _grid(kb, [
            can("broadcast") and B("📣 پیام همگانی", "bc2_start"),
            can("force_join") and B("📢 عضویت اجباری", "adm_force_join"),
        ])
        if can("links"):
            _grid(kb, [B("📊 تنظیم گزارشات", "adm_report_settings"),
                       make_inline_button(text=get_setting("btn_adm_links") or "پشتیبانی و کانال",
                                          callback_data="adm_edit_links")])
        kb.add(back())
        update_message_safe(chat_id, msg_id, "📢 <b>مدیریت اعلان‌ها</b>\n\nبخش موردنظر را انتخاب کنید.",
                            reply_markup=kb)
        return

    show_admin_panel(chat_id, msg_id, uid, None)


def new_panel_callback(call, uid, chat_id, msg_id, data):
    kb = types.InlineKeyboardMarkup()
    if data in ("adm_faq", "adm_faq_reset"):
        if data == "adm_faq_reset":
            set_setting("faq_text", "")
            set_setting("faq_entities", "[]")
            safe_answer(call, "به حالت پیش‌فرض برگشت ✅")
        cur = (get_setting("faq_text") or "").strip()
        kb.add(make_inline_button(text="✏️ ویرایش متن", callback_data="edit_text:faq_text", keep_emoji=True))
        kb.add(make_inline_button(text="♻️ بازگردانی پیش‌فرض", callback_data="adm_faq_reset", keep_emoji=True))
        kb.add(make_inline_button(text="⬅️ بازگشت", callback_data="admin_panel", keep_emoji=True))
        update_message_safe(chat_id, msg_id,
                            "❓ <b>سوالات متداول</b>\n\nمتن نمایش‌داده‌شده برای کاربران را مدیریت کنید.\n\n"
                            f"وضعیت: {'✅ ثبت شده' if cur else 'هنوز متنی ثبت نشده است'}", reply_markup=kb)
    elif data in ("adm_errors", "adm_errors_clear"):
        if data == "adm_errors_clear":
            _BOT_ERRORS.clear()
            safe_answer(call, "پاک شد ✅")
        if _BOT_ERRORS:
            body = "\n\n".join(f"🕒 {t} — <code>{html.escape(w)}</code>\n{html.escape(e)}"
                               for t, w, e in reversed(_BOT_ERRORS[-15:]))
        else:
            body = "✅ خطایی ثبت نشده است."
        kb.add(make_inline_button(text="🧹 پاک کردن خطاها", callback_data="adm_errors_clear", keep_emoji=True))
        kb.add(make_inline_button(text="⬅️ بازگشت", callback_data="admin_panel", keep_emoji=True))
        update_message_safe(chat_id, msg_id, f"🧯 <b>خطاهای اخیر ربات</b>\n\n{body}"[:4000], reply_markup=kb)
    elif data == "adm_changelog":
        body = "\n\n".join(f"<b>نسخه {v}</b>\n" + "\n".join(f"• {html.escape(x)}" for x in items)
                           for v, items in BOT_CHANGELOG)
        kb.add(make_inline_button(text="⬅️ بازگشت", callback_data="admin_panel", keep_emoji=True))
        update_message_safe(chat_id, msg_id, f"📝 <b>تغییرات نسخه‌ها</b>\n\n{body}", reply_markup=kb)
    safe_answer(call)


def show_button_visibility_menu(chat_id, msg_id):
    try:
        visible = set(json.loads(get_setting("visible_main_buttons") or "[]"))
    except Exception:
        visible = set(MAIN_BTN_IDS)
    kb = types.InlineKeyboardMarkup()
    for b_id in MAIN_BTN_IDS:
        mark = "✅ نمایان" if b_id in visible else "🚫 مخفی"
        kb.add(make_inline_button(text=f"{mark} — {get_button_display(b_id)}",
                                  callback_data=f"toggle_visible_btn:{b_id}", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, "نمایش دکمه‌های اصلی برای کاربران را تنظیم کنید:", reply_markup=kb)


COMMAND_BUTTONS = [
    ("btn_buy", "خرید"), ("btn_my_panels", "سرویس‌های من"), ("btn_test", "تست"),
    ("btn_support", "پشتیبانی"), ("btn_channel", "کانال"), ("btn_wallet", "کیف پول"),
    ("btn_miniapp", "مینی‌اپ"), ("btn_agency", "نمایندگی"), ("btn_admin_panel", "پنل مدیریت"),
]


def show_command_settings_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    lines = []
    for key, label in COMMAND_BUTTONS:
        command = get_setting(f"cmd_{key}")
        lines.append(f"• {label}: <code>/{html.escape(command)}</code>" if command else f"• {label}: تنظیم نشده")
        kb.add(make_inline_button(text=f"تنظیم کامند {label}", callback_data=f"edit_command:{key}"))
    kb.add(make_inline_button(text="🧩 تنظیم لینک مینی‌اپ", callback_data="adm_commands_miniapp",
                              style="none", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="admin_panel"))
    _mu = miniapp_url()
    lines.append(f"\n🧩 لینک مینی‌اپ: <code>{html.escape(_mu)}</code>" if _mu
                 else "\n🧩 لینک مینی‌اپ: تنظیم نشده (تا لینک ثبت نشود دکمه مینی‌اپ دیده نمی‌شود)")
    update_message_safe(chat_id, msg_id,
                        "<b>کامندهای دکمه‌های اصلی</b>\n\n" + "\n".join(lines) +
                        "\n\nفقط نام کامند را بدون / وارد کنید؛ برای پاک‌کردن ۰ را بفرستید.", reply_markup=kb)


def show_report_settings_menu(chat_id, msg_id):
    rc = get_setting("report_chat_id").strip()
    current = rc or "تنظیم نشده (گزارش‌ها به ادمین‌ها می‌روند)"
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="تنظیم آیدی گروه یا @یوزرنیم", callback_data="set_report_chat"))
    topics_txt = ""
    if rc:
        kb.add(make_inline_button(text="ساخت تاپیک‌ها", callback_data="adm_report_topics"))
        kb.add(make_inline_button(text="تست اتصال به گروه", callback_data="adm_report_test"))
        kb.add(make_inline_button(text="حذف گروه گزارشات", callback_data="clear_report_chat", style="danger"))
        mode = ("هر گزارش در تاپیک خودش" if report_topics_enabled()
                else "همه گزارش‌ها در چت اصلی گروه (تاپیک فعال نیست)")
        rows = [f"{'✅' if get_report_topic_id(k) else '▫️'} {e} {t}" for k, t, e in REPORT_TOPICS]
        topics_txt = f"\nحالت: <b>{mode}</b>\n\n<b>تاپیک‌ها:</b>\n" + "\n".join(rows) + "\n"
    kb.add(make_inline_button(text="بازگشت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id,
                        f"<b>گروه گزارشات</b>\n\nمقصد فعلی: <code>{html.escape(current)}</code>\n"
                        f"{topics_txt}\n"
                        "برای تاپیک جدا: یک سوپرگروه بسازید، گزینه Topics را روشن کنید، ربات را ادمین کنید "
                        "(با دسترسی مدیریت تاپیک‌ها)، آیدی گروه را ثبت کنید و «ساخت تاپیک‌ها» را بزنید.",
                        reply_markup=kb)


def show_admin_permission_menu(chat_id, msg_id, target_id):
    row = dbx("SELECT permissions, can_settings, can_admins FROM admins WHERE user_id = ?", (target_id,), "one")
    if not row:
        update_message_safe(chat_id, msg_id, "ادمین پیدا نشد.", reply_markup=cancel_kb("adm_manage_admins"))
        return
    if row[0]:
        try:
            selected = set(json.loads(row[0]))
        except Exception:
            selected = set()
    else:
        selected = {"orders"} if not row[1] else {key for key in ADMIN_FEATURES if key != "admins"}
        if row[2]:
            selected.add("admins")
        dbx("UPDATE admins SET permissions = ?, can_settings = 0, can_admins = 0 WHERE user_id = ?",
            (json.dumps(sorted(selected)), target_id))
    kb = types.InlineKeyboardMarkup()
    for key, label in ADMIN_FEATURES.items():
        mark = "✅" if key in selected else "⬜"
        kb.add(make_inline_button(text=f"{mark} {label}", callback_data=f"perm_toggle:{target_id}:{key}",
                                  style="none", keep_emoji=True))
    kb.add(make_inline_button(text="ذخیره و بازگشت", callback_data=f"perm_done:{target_id}"))
    update_message_safe(chat_id, msg_id,
                        f"دسترسی دکمه‌های پنل مدیریت برای ادمین <code>{target_id}</code> را انتخاب کنید:",
                        reply_markup=kb)


def show_test_plans_menu(chat_id, msg_id):
    ded_mb = float(get_setting("test_ded_traffic_mb") or 1024)
    ded_hr = float(get_setting("test_ded_time_hours") or 24)
    sh_mb = float(get_setting("test_sh_traffic_mb") or 512)
    sh_hr = float(get_setting("test_sh_time_hours") or 12)
    text = ("<b>تنظیمات حجم و زمان اکانت‌های تست</b>\n\n"
            f"<b>تست پنل:</b> {'فعال ✅' if get_setting('test_panel_enabled') != '0' else 'غیرفعال ⛔'}\n"
            f"▫️ حجم: <b>{format_mb_display(ded_mb)}</b>\n"
            f"▫️ مدت زمان: <b>{int(ded_hr)} ساعت</b>\n\n"
            f"<b>تست اشتراکی:</b> {'فعال ✅' if get_setting('test_shared_enabled') != '0' else 'غیرفعال ⛔'}\n"
            f"▫️ حجم: <b>{format_mb_display(sh_mb)}</b>\n"
            f"▫️ مدت زمان: <b>{int(sh_hr)} ساعت</b>\n\n"
            "از دکمه‌های فعال/غیرفعال برای نمایش یا پنهان‌کردن هر نوع تست استفاده کنید:")
    kb = types.InlineKeyboardMarkup()
    panel_toggle = "غیرفعال کردن تست پنل" if get_setting("test_panel_enabled") != "0" else "فعال کردن تست پنل"
    shared_toggle = "غیرفعال کردن تست اشتراکی" if get_setting("test_shared_enabled") != "0" else "فعال کردن تست اشتراکی"
    kb.row(make_inline_button(text=panel_toggle, callback_data="toggle_test_type:panel",
                              style="danger" if get_setting("test_panel_enabled") != "0" else "success"))
    kb.row(make_inline_button(text=shared_toggle, callback_data="toggle_test_type:shared",
                              style="danger" if get_setting("test_shared_enabled") != "0" else "success"))
    kb.row(make_inline_button(text="تغییر حجم تست پنل", callback_data="set_plan:test_ded_traffic_mb"))
    kb.row(make_inline_button(text="تغییر زمان تست پنل", callback_data="set_plan:test_ded_time_hours"))
    kb.row(make_inline_button(text="تغییر حجم تست اشتراکی", callback_data="set_plan:test_sh_traffic_mb"))
    kb.row(make_inline_button(text="تغییر زمان تست اشتراکی", callback_data="set_plan:test_sh_time_hours"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def show_card_settings_menu(chat_id, msg_id):
    c_num = get_setting("card_number") or "تنظیم نشده"
    c_name = get_setting("card_holder") or "تنظیم نشده"
    text = ("<b>تنظیمات اطلاعات حساب و کارت بانکی</b>\n\n"
            f"<b>شماره کارت:</b> <code>{html.escape(c_num)}</code>\n"
            f"<b>نام صاحب حساب:</b> <b>{html.escape(c_name)}</b>\n\n"
            "جهت تغییر هر مورد، گزینه دلخواه را انتخاب کنید:")
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="تغییر شماره کارت", callback_data="set_card_num"),
           make_inline_button(text="تغییر نام صاحب کارت", callback_data="set_card_holder"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


USERS_PER_PAGE = 20


def show_users_management_menu(chat_id, msg_id, page=0):
    total = dbx("SELECT COUNT(*) FROM users", (), "one")[0]
    pages = max(1, (total + USERS_PER_PAGE - 1) // USERS_PER_PAGE)
    page = max(0, min(int(page or 0), pages - 1))
    rows = dbx("SELECT user_id, username, first_name, last_name, is_blocked FROM users "
               "ORDER BY rowid DESC LIMIT ? OFFSET ?", (USERS_PER_PAGE, page * USERS_PER_PAGE), "all")
    text = (f"<b>👥 کاربران | صفحه {page + 1}</b>\n\n"
            f"در هر صفحه {USERS_PER_PAGE} کاربر نمایش داده می‌شود. برای مدیریت روی کاربر بزنید.\n"
            f"کل کاربران: <b>{total}</b>")
    kb = types.InlineKeyboardMarkup()
    for uid_, uname, fn, ln, blocked in rows:
        name = (f"{fn or ''} {ln or ''}").strip() or str(uid_)
        label = f"{name} | @{uname}" if uname else f"{name} | {uid_}"
        if blocked:
            label = "🚫 " + label
        kb.add(make_inline_button(text=label[:60], callback_data=f"usr_profile:{uid_}", style="none",
                                  keep_emoji=True))
    nav = []
    if page > 0:
        nav.append(make_inline_button(text="◀️ قبلی", callback_data=f"usr_list:{page - 1}", style="none",
                                      keep_emoji=True))
    if page < pages - 1:
        nav.append(make_inline_button(text="بعدی ▶️", callback_data=f"usr_list:{page + 1}", style="none",
                                      keep_emoji=True))
    if nav:
        kb.row(*nav)
    kb.row(make_inline_button(text="🔎 جستجو", callback_data="usr_search", style="none", keep_emoji=True),
           make_inline_button(text="⬅️ بازگشت", callback_data="admin_panel", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="ریست محدودیت تست همه کاربران", callback_data="usr_reset_tests_confirm",
                              style="none"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def show_user_profile_admin(chat_id, msg_id, target_uid):
    row = dbx("SELECT user_id, username, has_used_test, is_blocked, first_name, last_name, test_unlimited, "
              "wallet_balance "
              "FROM users WHERE user_id = ?",
              (target_uid,), "one")
    if not row:
        update_message_safe(chat_id, msg_id, "کاربر یافت نشد.", reply_markup=cancel_kb("adm_manage_users"))
        return
    panel_count = dbx("SELECT COUNT(*) FROM user_panels WHERE user_id = ?", (target_uid,), "one")[0]
    approved_count, total_spent = dbx(
        "SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM orders WHERE user_id = ? AND status = 'approved'",
        (target_uid,), "one")
    referrals_count = dbx("SELECT COUNT(*) FROM users WHERE referred_by = ?", (target_uid,), "one")[0]
    uid, uname, tested, blocked, fn, ln, test_unlimited, wallet_balance = row
    full_name = html.escape((f"{fn or ''} {ln or ''}").strip() or "ندارد")
    text = (f"<b>اطلاعات کاربر:</b>\n\n"
            f"نام: <b>{full_name}</b>\n"
            f"آیدی عددی: <code>{uid}</code>\n"
            f"یوزرنیم: {('@' + html.escape(uname)) if uname else 'ندارد'}\n"
            f"وضعیت اکانت: <b>{'مسدود' if blocked else 'فعال'}</b>\n"
            f"وضعیت دریافت تست: <b>{'تست نامحدود فعال' if test_unlimited else ('بله (استفاده شده)' if tested else 'خیر (مجاز)')}</b>\n"
            f"موجودی کیف پول: <b>{int(wallet_balance or 0):,} تومان</b>\n"
            f"خریدهای موفق: <b>{approved_count}</b> — مجموع: <b>{int(total_spent or 0):,} تومان</b>\n"
            f"تعداد سرویس‌ها: <b>{panel_count}</b>\n"
            f"زیرمجموعه‌ها: <b>{referrals_count}</b>")
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="➕ شارژ کیف پول", callback_data=f"usr_wallet_add:{uid}", style="success"),
           make_inline_button(text="➖ کسر کیف پول", callback_data=f"usr_wallet_sub:{uid}", style="danger"))
    kb.add(make_inline_button(text="📅 افزودن/کاهش روز سرویس VPN", callback_data=f"usr_services:{uid}:days"))
    kb.add(make_inline_button(text="📦 افزودن/کاهش حجم سرویس VPN", callback_data=f"usr_services:{uid}:traffic"))
    kb.add(make_inline_button(text="⚙️ توقف/فعال‌سازی سرویس", callback_data=f"usr_services:{uid}:status"))
    kb.add(make_inline_button(text="🗑 حذف سرویس", callback_data=f"usr_services:{uid}:delete", style="danger"))
    kb.add(make_inline_button(text="♻️ ریست تست رایگان برای این کاربر",
                              callback_data=f"usr_reset_single_test:{uid}"))
    unlimited_text = "غیرفعال کردن تست نامحدود" if test_unlimited else "دادن تست نامحدود به این کاربر"
    kb.add(make_inline_button(text=f"♾ {unlimited_text}", callback_data=f"usr_toggle_test_unlimited:{uid}",
                              style="none"))
    kb.add(make_inline_button(text="🧾 رسیدها و خریدهای کاربر", callback_data=f"usr_history:{uid}"))
    kb.add(make_inline_button(text="💬 ارسال پیام اختصاصی", callback_data=f"usr_message:{uid}"))
    kb.add(make_inline_button(text=f"👥 زیرمجموعه‌های این کاربر ({referrals_count})",
                              callback_data=f"usr_referrals:{uid}"))
    if blocked:
        kb.row(make_inline_button(text="رفع مسدودیت (آنبلاک)", callback_data=f"usr_unblock:{uid}"))
    else:
        kb.row(make_inline_button(text="مسدود کردن (بلاک)", callback_data=f"usr_block:{uid}"))
    kb.add(make_inline_button(text="بازگشت به مدیریت کاربران", callback_data="adm_manage_users"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def apply_wallet_change(user_id, admin_id, amount, operation, note_text=None):
    """Atomically adjust a user's wallet and record the administrator's transaction."""
    amount = int(amount)
    if amount <= 0 or operation not in ("add", "sub"):
        return False, None
    conn = sqlite3.connect(DB_FILE, timeout=30)
    try:
        cur = conn.cursor()
        cur.execute("BEGIN IMMEDIATE")
        cur.execute("SELECT wallet_balance FROM users WHERE user_id = ?", (user_id,))
        row = cur.fetchone()
        if not row:
            conn.rollback()
            return False, None
        current = int(row[0] or 0)
        if operation == "sub" and current < amount:
            conn.rollback()
            return False, current
        new_balance = current + amount if operation == "add" else current - amount
        delta = amount if operation == "add" else -amount
        cur.execute("UPDATE users SET wallet_balance = ? WHERE user_id = ?", (new_balance, user_id))
        note = note_text or ("شارژ دستی توسط ادمین" if operation == "add" else "کسر دستی توسط ادمین")
        cur.execute("INSERT INTO wallet_transactions (user_id, admin_id, amount, balance_after, note) "
                    "VALUES (?, ?, ?, ?, ?)", (user_id, admin_id, delta, new_balance, note))
        conn.commit()
        return True, new_balance
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def reserve_wallet_order(user_id, payment_token):
    """Debit the wallet once and atomically reserve a draft as a wallet order."""
    conn = sqlite3.connect(DB_FILE, timeout=30)
    try:
        cur = conn.cursor()
        cur.execute("BEGIN IMMEDIATE")
        cur.execute(
            "SELECT d.plan_id, d.username, d.password, d.renew_panel_id, d.payment_status, "
            "d.quoted_amount, COALESCE(p.id, -1) FROM buy_drafts d "
            "LEFT JOIN shop_plans p ON p.id = d.plan_id "
            "WHERE d.user_id = ? AND d.payment_token = ?",
            (user_id, payment_token))
        draft = cur.fetchone()
        if not draft:
            conn.rollback()
            return False, "این درخواست پرداخت منقضی شده است؛ لطفاً دوباره سفارش دهید."
        plan_id, username, password, renew_panel_id, payment_status, price, existing_plan_id = draft
        if payment_status != "ready":
            conn.rollback()
            return False, "این پرداخت در حال انجام است یا قبلاً ثبت شده است."
        price = int(price or 0)
        if existing_plan_id < 0:
            conn.rollback()
            return False, "پلن این سفارش دیگر در فروشگاه وجود ندارد."
        if price <= 0:
            conn.rollback()
            return False, "قیمت این پلن معتبر نیست."
        cur.execute("SELECT wallet_balance FROM users WHERE user_id = ?", (user_id,))
        wallet_row = cur.fetchone()
        if not wallet_row:
            conn.rollback()
            return False, "حساب کاربری پیدا نشد."
        balance = int(wallet_row[0] or 0)
        if balance < price:
            conn.rollback()
            return False, f"موجودی کافی نیست؛ موجودی فعلی {balance:,} تومان است."

        cur.execute("UPDATE buy_drafts SET payment_status = 'processing' "
                    "WHERE user_id = ? AND payment_token = ? AND payment_status = 'ready'",
                    (user_id, payment_token))
        if cur.rowcount != 1:
            conn.rollback()
            return False, "این پرداخت قبلاً شروع شده است."
        new_balance = balance - price
        cur.execute("UPDATE users SET wallet_balance = ? WHERE user_id = ?", (new_balance, user_id))
        cur.execute(
            "INSERT INTO orders (user_id, plan_id, custom_username, custom_password, status, "
            "renew_panel_id, amount, payment_method) VALUES (?, ?, ?, ?, 'processing', ?, ?, 'wallet')",
            (user_id, plan_id, username, password or "", int(renew_panel_id or 0), price))
        order_id = cur.lastrowid
        cur.execute("INSERT INTO wallet_transactions (user_id, admin_id, amount, balance_after, note) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (user_id, user_id, -price, new_balance, f"پرداخت سفارش کیف پول #{order_id}"))
        conn.commit()
        return True, {"order_id": order_id, "draft": (plan_id, username, password, renew_panel_id),
                      "amount": price, "balance_after": new_balance}
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def refund_wallet_order(user_id, order_id, payment_token, amount, reason):
    """Refund a failed wallet purchase once and unlock its draft for another attempt."""
    conn = sqlite3.connect(DB_FILE, timeout=30)
    try:
        cur = conn.cursor()
        cur.execute("BEGIN IMMEDIATE")
        cur.execute("SELECT status FROM orders WHERE id = ? AND user_id = ? AND payment_method = 'wallet'",
                    (order_id, user_id))
        order = cur.fetchone()
        if not order or order[0] != "processing":
            conn.rollback()
            return False
        cur.execute("SELECT wallet_balance FROM users WHERE user_id = ?", (user_id,))
        wallet = cur.fetchone()
        if not wallet:
            conn.rollback()
            return False
        new_balance = int(wallet[0] or 0) + int(amount)
        cur.execute("UPDATE users SET wallet_balance = ? WHERE user_id = ?", (new_balance, user_id))
        cur.execute("UPDATE orders SET status = 'failed' WHERE id = ?", (order_id,))
        cur.execute("UPDATE buy_drafts SET payment_status = 'ready' "
                    "WHERE user_id = ? AND payment_token = ?", (user_id, payment_token))
        cur.execute("INSERT INTO wallet_transactions (user_id, admin_id, amount, balance_after, note) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (user_id, user_id, int(amount), new_balance,
                     f"بازپرداخت سفارش #{order_id}: {str(reason)[:120]}"))
        conn.commit()
        return True
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def show_admin_services(chat_id, msg_id, target_uid, action):
    labels = {"days": "افزودن یا کاهش مدت سرویس", "traffic": "افزودن یا کاهش حجم سرویس",
              "status": "توقف یا فعال‌سازی سرویس", "delete": "حذف سرویس"}
    if action not in labels:
        show_user_profile_admin(chat_id, msg_id, target_uid)
        return
    services = dbx("SELECT id, name, kind, is_disabled, expire_ts FROM user_panels "
                   "WHERE user_id = ? ORDER BY id DESC", (target_uid,), "all")
    kb = types.InlineKeyboardMarkup()
    if not services:
        text = f"<b>{labels[action]}</b>\n\nبرای این کاربر سرویسی ثبت نشده است."
    else:
        text = f"<b>{labels[action]}</b>\n\nسرویس مورد نظر را انتخاب کنید:"
        for service_id, name, kind, disabled, expire_ts in services:
            state = "غیرفعال" if disabled else "فعال"
            label = f"{name} — {state}"
            kb.add(make_inline_button(text=label[:60],
                                      callback_data=f"usr_svc_pick:{target_uid}:{action}:{service_id}"))
    kb.add(make_inline_button(text="بازگشت به پروفایل کاربر", callback_data=f"usr_profile:{target_uid}"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def show_user_order_history(chat_id, msg_id, target_uid):
    rows = dbx("SELECT o.id, COALESCE(p.name, 'پلن حذف‌شده'), o.amount, o.status, "
               "o.created_at, o.file_id, o.payment_method FROM orders o "
               "LEFT JOIN shop_plans p ON p.id = o.plan_id "
               "WHERE o.user_id = ? ORDER BY o.id DESC LIMIT 20", (target_uid,), "all")
    status_labels = {"pending": "در انتظار", "processing": "در حال پردازش",
                     "approved": "تأییدشده", "rejected": "ردشده", "failed": "ناموفق/بازپرداخت‌شده"}
    kb = types.InlineKeyboardMarkup()
    for order_id, plan_name, amount, status, created_at, file_id, payment_method in rows:
        date_text = str(created_at or "")[:10]
        method = "کیف پول" if payment_method == "wallet" else "رسید"
        title = f"#{order_id} | {status_labels.get(status, status)} | {method} | {int(amount or 0):,} تومان"
        if date_text:
            title += f" | {date_text}"
        if file_id:
            kb.add(make_inline_button(text=title[:60],
                                      callback_data=f"usr_receipt:{target_uid}:{order_id}"))
    approved_count, spent = dbx(
        "SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM orders WHERE user_id = ? AND status = 'approved'",
        (target_uid,), "one")
    text = (f"<b>رسیدها و خریدهای کاربر <code>{target_uid}</code></b>\n\n"
            f"خرید موفق: <b>{approved_count}</b>\nمجموع خرید موفق: <b>{int(spent or 0):,} تومان</b>\n\n"
            f"سفارش‌های نمایش‌داده‌شده: <b>{len(rows)}</b>"
            + ("\nبرای ارسال رسید به همین چت، روی سفارش دارای فایل بزنید." if rows else "\nسفارشی ثبت نشده است."))
    kb.add(make_inline_button(text="بازگشت به پروفایل کاربر", callback_data=f"usr_profile:{target_uid}"))
    update_message_safe(chat_id, msg_id, text, reply_markup=kb)


def show_user_referrals_admin(chat_id, msg_id, target_uid):
    total = dbx("SELECT COUNT(*) FROM users WHERE referred_by = ?", (target_uid,), "one")[0]
    rows = dbx("SELECT user_id, username, first_name, last_name FROM users "
               "WHERE referred_by = ? ORDER BY user_id DESC LIMIT 30", (target_uid,), "all")
    try:
        bot_name = bot.get_me().username or ""
    except Exception:
        bot_name = ""
    invite_link = f"https://t.me/{bot_name}?start=ref_{target_uid}" if bot_name else ""
    lines = [f"<b>زیرمجموعه‌های کاربر <code>{target_uid}</code></b>",
             f"تعداد ثبت‌شده: <b>{total}</b>"]
    if invite_link:
        lines.extend(["", "لینک دعوت:", f"<code>{html.escape(invite_link)}</code>"])
    if rows:
        lines.extend(["", "<b>فهرست (حداکثر ۳۰ نفر اخیر):</b>"])
        for user_id, username, first_name, last_name in rows:
            display = html.escape((f"{first_name or ''} {last_name or ''}").strip() or "بدون نام")
            user_name = f"@{html.escape(username)}" if username else "بدون یوزرنیم"
            lines.append(f"• <code>{user_id}</code> — {display} — {user_name}")
    else:
        lines.extend(["", "هنوز زیرمجموعه‌ای با لینک دعوت این کاربر ثبت نشده است."])
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="بازگشت به پروفایل کاربر", callback_data=f"usr_profile:{target_uid}"))
    update_message_safe(chat_id, msg_id, "\n".join(lines), reply_markup=kb)


STYLE_NAMES = {"primary": "🔵 آبی", "success": "🟢 سبز", "danger": "🔴 قرمز", None: "⚪ بدون رنگ"}
STYLE_TARGETS = [("btn_buy", "btn_buy"), ("btn_my_panels", "btn_my_panels"), ("btn_test", "btn_test"),
                 ("btn_support", "btn_support"), ("btn_channel", "btn_channel"),
                 ("btn_wallet", "btn_wallet"), ("btn_miniapp", "btn_miniapp"),
                 ("btn_agency", "btn_agency"),
                 ("btn_custom_volume", "btn_custom_volume"),
                 ("btn_back_groups", "btn_back_groups"),
                 ("server_title", "نام دکمه سرویس/سرور"),
                 ("btn_admin_panel", "btn_admin_panel"), ("back_to_main", "btn_back_main"),
                 ("pnl_open", "ورود به پنل"), ("pnl_pw", "نمایش/مخفی کردن رمز"), ("pnl_renew", "تمدید سرویس"),
                 ("pnl_chpw", "تغییر رمز پنل"), ("pnl_toggle", "فعال/غیرفعال کردن پنل"),
                 ("pnl_rev", "تغییر لینک اشتراک"), ("pnl_refresh", "بروزرسانی وضعیت"),
                 ("pnl_back", "بازگشت به لیست (پنل‌ها)"), ("pnl_delete", "حذف سرویس"),
                 ("fj_join", "fj_btn_join"), ("fj_check", "fj_btn_check")]


def show_colors_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=f"رنگ دکمه‌ها: {'روشن ✅' if colors_on() else 'خاموش ⚪'} ◂ تغییر",
                              callback_data="adm_edit_colors_toggle", style="none", keep_emoji=True))
    for b_id, text_key in STYLE_TARGETS:
        style_name = STYLE_NAMES.get(get_button_style_from_db(b_id), "⚪ بدون رنگ")
        kb.add(make_inline_button(text=f"▫️ {get_setting(text_key) or text_key} ◂ [ {style_name} ]",
                                  callback_data=f"set_color_menu:{b_id}", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, "<b>🎨 تنظیم رنگ دکمه‌ها:</b>\nروی هر دکمه بزنید تا رنگ آن را تغییر دهید.",
                        reply_markup=kb)


def show_emojis_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    for b_id, text_key in STYLE_TARGETS:
        icon = get_setting(f"emoji_{b_id}") or "خالی"
        kb.add(make_inline_button(text=f"▫️ {get_setting(text_key) or text_key} ◂ [ {icon} ]", callback_data=f"set_emoji:{b_id}",
                                  keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, "<b>✨ تنظیم ایموجی پریمیوم دکمه‌ها:</b>", reply_markup=kb)


TEXT_KEYS = [("start_text", "متن استارت"), ("support_text", "متن پشتیبانی"),
             ("order_rejected_text", "متن رد رسید"), ("bot_off_text", "پیام خاموشی ربات"),
             ("my_panels_text", "متن بالای سرویس‌های من"), ("shop_header_text", "متن بالای پلن‌های خرید"),
             ("btn_buy", "نام دکمه خرید"), ("btn_my_panels", "نام دکمه پنل‌های من"), ("btn_test", "نام دکمه تست"),
             ("btn_support", "نام دکمه پشتیبانی"), ("btn_channel", "نام دکمه کانال"),
             ("btn_wallet", "نام دکمه کیف پول"), ("btn_miniapp", "نام دکمه مینی‌اپ"),
             ("btn_agency", "نام دکمه نمایندگی"), ("agency_user_text", "متن سؤال نام کاربری نمایندگی"),
             ("agency_pass_text", "متن سؤال رمز نمایندگی"), ("wallet_text", "متن بالای کیف پول"),
             ("btn_custom_volume", "نام دکمه حجم دلخواه"), ("btn_back_groups", "نام دکمه بازگشت به گروه‌ها"),
             ("btn_admin_panel", "نام دکمه پنل مدیریت"), ("btn_back_main", "نام دکمه بازگشت"),
             ("fj_text", "متن عضویت اجباری"), ("fj_btn_join", "نام دکمه عضویت در کانال"),
             ("fj_btn_check", "نام دکمه «عضو شدم»"),
             ("rules_text", "متن قوانین"), ("btn_rules_accept", "نام دکمه «می‌پذیرم»"),
             ("faq_text", "متن سوالات متداول"),
             ("wallet_amount_text", "متن پرسیدن مبلغ شارژ"), ("wallet_pay_text", "متن بالای اطلاعات پرداخت شارژ")]
TEXT_KEYS_SET = {k for k, _ in TEXT_KEYS}
TEXT_SECTIONS = [
    ("main", "📋 منوی اصلی", ["start_text", "btn_buy", "btn_my_panels", "btn_test", "btn_wallet", "btn_support",
                               "btn_channel", "btn_miniapp", "btn_agency", "btn_admin_panel", "btn_back_main"]),
    ("agency", "🤝 نمایندگی", ["agency_user_text", "agency_pass_text"]),
    ("rules", "📜 قوانین", ["rules_text", "btn_rules_accept"]),
    ("faq", "❓ سوالات متداول", ["faq_text"]),
    ("shop", "🛒 خرید و پلن‌ها", ["shop_header_text", "btn_custom_volume", "btn_back_groups"]),
    ("wallet", "💰 کیف پول", ["wallet_text", "wallet_amount_text", "wallet_pay_text"]),
    ("panels", "📂 سرویس‌های من", ["my_panels_text"]),
    ("support", "💬 پشتیبانی", ["support_text"]),
    ("orders", "🧾 رسیدها", ["order_rejected_text"]),
    ("fj", "🔒 عضویت اجباری", ["fj_text", "fj_btn_join", "fj_btn_check"]),
    ("off", "⛔ خاموشی ربات", ["bot_off_text"]),
]
TEXT_KEY_SECTION = {k: sid for sid, _t, keys in TEXT_SECTIONS for k in keys}


def show_edit_texts_menu(chat_id, msg_id):
    kb = types.InlineKeyboardMarkup()
    for sid, title, _keys in TEXT_SECTIONS:
        kb.add(make_inline_button(text=title, callback_data=f"adm_edit_texts_sec:{sid}", style="none",
                                  keep_emoji=True))
    kb.add(make_inline_button(text="📂 چینش دکمه‌های پنل‌های من", callback_data="adm_pnl_layout", style="none",
                              keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="admin_panel", style="none"))
    update_message_safe(chat_id, msg_id,
                        "<b>📝 مدیریت متن‌ها و دکمه‌های ربات</b>\n\nفقط مواردی که کاربران عادی می‌بینند در این "
                        "بخش قرار دارند. بخش موردنظر را انتخاب کنید:", reply_markup=kb)


def show_text_section(chat_id, msg_id, sid):
    sec = next((x for x in TEXT_SECTIONS if x[0] == sid), None)
    if not sec:
        show_edit_texts_menu(chat_id, msg_id)
        return
    labels = dict(TEXT_KEYS)
    kb = types.InlineKeyboardMarkup()
    for key in sec[2]:
        kb.add(make_inline_button(text=labels.get(key, key), callback_data=f"edit_text:{key}", style="none"))
    if sid == "rules" and (get_setting("rules_text") or "").strip():
        kb.add(make_inline_button(text="🗑 حذف متن قوانین", callback_data="adm_rules_clear", style="danger",
                                  keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="adm_edit_texts", style="none"))
    extra = "\n\n💡 برای حذف قوانین، متن «-» را بفرستید." if sid == "rules" else ""
    update_message_safe(chat_id, msg_id, f"<b>{sec[1]}</b>\n\nهر مورد مربوط به این بخش است:{extra}",
                        reply_markup=kb)


def show_links_menu(chat_id, msg_id):
    su = get_setting("support_user")
    txt = (f"<b>تنظیم پشتیبانی و کانال</b>\n\nیوزرنیم پشتیبانی: <code>{html.escape(su)}</code>\n"
           f"لینک کانال: <code>{html.escape(get_setting('channel_link'))}</code>")
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="تغییر یوزرنیم پشتیبانی", callback_data="set_support_user"))
    kb.add(make_inline_button(text="تغییر لینک کانال", callback_data="set_channel_link"))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def show_admin_list(chat_id, msg_id):
    admins = dbx("SELECT user_id, can_settings, can_admins, permissions FROM admins", (), "all")
    kb = types.InlineKeyboardMarkup()
    kb.row(make_inline_button(text="➕ افزودن ادمین جدید", callback_data="add_new_admin", keep_emoji=True),
           make_inline_button(text="🗑 حذف ادمین", callback_data="del_admin_menu", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="admin_panel"))
    lines = []
    for a_id, can_settings, can_admins, raw_perms in admins:
        try:
            selected = set(json.loads(raw_perms or "[]")) if raw_perms else set()
        except Exception:
            selected = set()
        summary = (", ".join(ADMIN_FEATURES[k] for k in ADMIN_FEATURES if k in selected)
                   if raw_perms else ("تنظیمات + سفارش‌ها" if can_settings else "فقط سفارش‌ها"))
        lines.append(f"▫️ <code>{a_id}</code> — {html.escape(summary or 'بدون دسترسی')}")
        kb.add(make_inline_button(text=f"دسترسی‌های ادمین {a_id}", callback_data=f"admin_perms:{a_id}"))
    lines = "\n".join(lines)
    txt = f"<b>لیست ادمین‌ها:</b>\nمالک ربات: <code>{OWNER_ID}</code>\nتعداد ادمین‌ها: {len(admins)}\n{lines}"
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def show_delete_admin_menu(chat_id, msg_id):
    admins = dbx("SELECT user_id FROM admins", (), "all")
    kb = types.InlineKeyboardMarkup()
    for (a_id,) in admins:
        kb.add(make_inline_button(text=f"حذف ادمین {a_id}", callback_data=f"remove_adm:{a_id}"))
    kb.add(make_inline_button(text="بازگشت", callback_data="adm_manage_admins"))
    update_message_safe(chat_id, msg_id, "ادمین مورد نظر را برای حذف انتخاب کنید:", reply_markup=kb)


def show_force_join_menu(chat_id, msg_id):
    status = "فعال ✅" if get_setting("force_join_enabled") == "1" else "غیرفعال ❌"
    channels = dbx("SELECT chat_id, title FROM force_join_channels", (), "all")
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=f"وضعیت فعلی: {status} (تغییر)", callback_data="fj_toggle", keep_emoji=True))
    kb.add(make_inline_button(text="➕ افزودن کانال جدید", callback_data="fj_add_channel", keep_emoji=True))
    kb.add(make_inline_button(text="✏️ ویرایش متن پیام عضویت", callback_data="edit_text:fj_text", keep_emoji=True))
    kb.row(make_inline_button(text="✏️ نام دکمه کانال", callback_data="edit_text:fj_btn_join", keep_emoji=True),
           make_inline_button(text="✏️ نام دکمه عضو شدم", callback_data="edit_text:fj_btn_check", keep_emoji=True))
    for cid, ctitle in channels:
        kb.add(make_inline_button(text=f"🗑 حذف {ctitle}", callback_data=f"fj_del:{cid}", keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت به پنل مدیریت", callback_data="admin_panel"))
    update_message_safe(chat_id, msg_id, "<b>مدیریت قفل عضویت اجباری کانال‌ها:</b>\n"
                                         "ربات باید در کانال‌ها ادمین باشد.\n\nدر نام دکمه کانال می‌توانید <code>{title}</code> بنویسید تا نام کانال جایگزین شود.", reply_markup=kb)


def pw_change_kb(pid):
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text="استفاده از رمز پیشنهادی", callback_data=f"pnl_chpw_sug:{pid}",
                              btn_id="pnl_chpw", style="success"))
    kb.add(make_inline_button(text="انصراف", callback_data=f"show_panel:{pid}"))
    return kb


# ==================== اتصال نمایندگی (پنل خریداری‌شده دستی) ====================
_agency_fail = PersistentMap("agency_fail")  # uid -> [زمان تلاش‌های ناموفق]


def _agency_blocked(uid) -> bool:
    now = time.time()
    lst = [t for t in _agency_fail.get(uid, []) if now - t < 600]
    _agency_fail[uid] = lst
    return len(lst) >= 5


def start_agency_link(chat_id, msg_id, uid):
    if not get_all_server_panels():
        update_message_safe(chat_id, msg_id, "<b>فعلاً هیچ سروری ثبت نشده است.</b>", reply_markup=get_back_keyboard())
        return
    if _agency_blocked(uid):
        update_message_safe(chat_id, msg_id, "<b>تعداد تلاش‌های ناموفق زیاد بود. ۱۰ دقیقه بعد دوباره امتحان کنید.</b>",
                            reply_markup=get_back_keyboard())
        return
    if not msg_id:
        m = send_message_safe(chat_id, "⏳")
        msg_id = m.message_id if m else None
    user_states[uid] = "agency_wait_user"
    user_temp_data[uid] = {"last_msg_id": msg_id}
    update_message_safe(chat_id, msg_id, get_setting("agency_user_text") or "نام کاربری پنل را بفرستید:",
                        reply_markup=cancel_kb("back_to_main"),
                        entities=None)


def verify_agency_login(username, password):
    """روی همهٔ سرورهای ثبت‌شده بررسی می‌کند این نام کاربری و رمز پنل وجود دارد یا نه.
    خروجی: (server_row | None, url_base, token, is_sudo)"""
    for sp in get_all_server_panels():
        sid, sname, surl, suser, spass, _grp = sp
        url_base = clean_panel_url(surl or "")
        if not url_base:
            continue
        try:
            res = requests.post(f"{url_base}/api/admin/token", data={"username": username, "password": password},
                                headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=12)
        except Exception:
            continue
        if res.status_code != 200:
            continue
        try:
            tok = res.json().get("access_token")
        except Exception:
            tok = None
        if not tok:
            continue
        sudo = get_admin_sudo_status(url_base, tok)
        if str(username).strip().lower() == str(suser or "").strip().lower():
            sudo = True  # ادمین اصلی خود ربات هیچ‌وقت وصل نمی‌شود
        return sp, url_base, tok, bool(sudo)
    return None, "", "", False


def finish_agency_link(chat_id, msg_id, uid, tg_user, username, password):
    user_states.pop(uid, None)
    user_temp_data.pop(uid, None)
    if _agency_blocked(uid):
        update_message_safe(chat_id, msg_id, "<b>تعداد تلاش‌های ناموفق زیاد بود. ۱۰ دقیقه بعد دوباره امتحان کنید.</b>",
                            reply_markup=get_back_keyboard())
        return
    update_message_safe(chat_id, msg_id, "<b>⏳ در حال بررسی پنل روی سرورها...</b>")
    retry_kb = types.InlineKeyboardMarkup()
    retry_kb.add(make_inline_button(text="🔁 تلاش دوباره", callback_data="btn_agency", style="none", keep_emoji=True))
    retry_kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main",
                                    btn_id="back_to_main"))
    sp, url_base, tok, is_sudo = verify_agency_login(username, password)
    if not sp:
        _agency_fail.setdefault(uid, []).append(time.time())
        update_message_safe(chat_id, msg_id,
                            "<b>❌ پنلی با این نام کاربری و رمز پیدا نشد.</b>\nاطلاعات را بررسی و دوباره تلاش کنید.",
                            reply_markup=retry_kb)
        return
    if is_sudo:
        _agency_fail.setdefault(uid, []).append(time.time())
        update_message_safe(chat_id, msg_id, "<b>⛔ این حساب قابل اتصال به نمایندگی نیست.</b>", reply_markup=retry_kb)
        send_report(f"⚠️ <b>تلاش برای اتصال حساب سودو به نمایندگی</b>\n👤 کاربر: <a href=\"tg://user?id={uid}\">{uid}</a>\n"
                    f"🔑 نام کاربری پنل: <code>{html.escape(username)}</code>",
                    user_id=uid, username=getattr(tg_user, "username", None), topic="users")
        return
    sid, sname, _surl, _suser, _spass, _grp = sp
    exists = dbx("SELECT user_id FROM user_panels WHERE lower(username) = lower(?) AND panel_id = ? AND kind = 'admin'",
                 (username, sid), "one")
    if exists:
        if int(exists[0]) == int(uid):
            update_message_safe(chat_id, msg_id, "<b>این پنل قبلاً به حساب شما وصل شده است.</b>\n"
                                                 "از «پنل‌های من» مدیریتش کنید.", reply_markup=retry_kb)
        else:
            update_message_safe(chat_id, msg_id, "<b>⛔ این پنل قبلاً به حساب دیگری وصل شده است.</b>",
                                reply_markup=retry_kb)
            send_report(f"⚠️ <b>تلاش برای اتصال پنلِ متعلق به کاربر دیگر</b>\n"
                        f"👤 متقاضی: <a href=\"tg://user?id={uid}\">{uid}</a>\n"
                        f"👑 صاحب فعلی: <a href=\"tg://user?id={exists[0]}\">{exists[0]}</a>\n"
                        f"🔑 نام کاربری پنل: <code>{html.escape(username)}</code>",
                        user_id=uid, username=getattr(tg_user, "username", None), topic="users")
        return
    expire_ts = 0
    try:
        info, _why = _fetch_panel_admin(url_base, {"Authorization": f"Bearer {tok}"}, username)
        if info:
            expire_ts = _panel_timestamp(info.get("expire", info.get("expire_ts")))
    except Exception:
        pass
    real_name = username
    link = url_base + "/dashboard"
    save_user_panel(uid, real_name, username, password, link, False, sid, "admin", expire_ts)
    kb = types.InlineKeyboardMarkup()
    kb.add(make_inline_button(text=get_button_display("btn_my_panels"), callback_data="btn_my_panels",
                              btn_id="btn_my_panels"))
    kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main", btn_id="back_to_main"))
    update_message_safe(chat_id, msg_id,
                        f"<b>✅ نمایندگی با موفقیت وصل شد.</b>\n\n🌐 سرور: <b>{html.escape(str(sname))}</b>\n"
                        f"👤 نام کاربری: <code>{html.escape(username)}</code>\n"
                        f"⏳ وضعیت اعتبار: <b>{admin_expiry_text(expire_ts)}</b>\n\n"
                        "از بخش «پنل‌های من» می‌توانید پنل را فعال/غیرفعال، تمدید یا رمزش را عوض کنید.",
                        reply_markup=kb)
    send_report(f"🤝 <b>اتصال نمایندگی جدید</b>\n👤 کاربر: <a href=\"tg://user?id={uid}\">{uid}</a>\n"
                f"🌐 سرور: {html.escape(str(sname))}\n🔑 نام کاربری پنل: <code>{html.escape(username)}</code>",
                user_id=uid, username=getattr(tg_user, "username", None), topic="users")


# ---------- قفل/باز کردن واقعی پنل ادمین ----------
def lock_admin_panel(username, server_pid, pid=None):
    """پنل را واقعاً از دسترس خارج می‌کند: رمز با یک رمز تصادفی عوض می‌شود (ورود ممکن نیست)
    و پنل هم غیرفعال می‌شود. رمز اصلی در دیتابیس می‌ماند تا موقع فعال‌سازی برگردد."""
    lock_pw = generate_strong_password_32(32)
    ok, err = _modify_pasarguard_admin(username, {"password": lock_pw}, server_pid)
    if not ok:
        return False, err
    try:
        _modify_pasarguard_admin(username, {"is_disabled": True}, server_pid)
    except Exception:
        pass
    if pid:
        dbx("UPDATE user_panels SET locked_pw = ?, is_disabled = 1 WHERE id = ?", (lock_pw, pid))
    else:
        dbx("UPDATE user_panels SET locked_pw = ?, is_disabled = 1 WHERE username = ? AND panel_id = ?",
            (lock_pw, username, server_pid))
    return True, None


def unlock_admin_panel(username, server_pid, real_password, pid=None):
    """رمز اصلی را برمی‌گرداند و پنل را فعال می‌کند."""
    fields = {"is_disabled": False}
    if real_password:
        fields["password"] = real_password
    ok, err = _modify_pasarguard_admin(username, fields, server_pid)
    if not ok and real_password:
        ok, err = _modify_pasarguard_admin(username, {"password": real_password}, server_pid)
    if not ok:
        return False, err
    if pid:
        dbx("UPDATE user_panels SET locked_pw = '', is_disabled = 0 WHERE id = ?", (pid,))
    else:
        dbx("UPDATE user_panels SET locked_pw = '', is_disabled = 0 WHERE username = ? AND panel_id = ?",
            (username, server_pid))
    return True, None


# ---------- چینش دکمه‌های «پنل‌های من» ----------
PNL_BTN_DEFAULT = ["pnl_open", "pnl_pw", "pnl_chpw", "pnl_rev", "pnl_renew", "pnl_toggle", "pnl_refresh",
                   "pnl_delete", "pnl_back"]
PNL_BTN_LABELS = {"pnl_open": "ورود به پنل", "pnl_pw": "نمایش/مخفی کردن رمز", "pnl_chpw": "تغییر رمز پنل",
                  "pnl_rev": "تغییر لینک اشتراک", "pnl_renew": "تمدید سرویس", "pnl_toggle": "فعال/غیرفعال کردن",
                  "pnl_refresh": "بروزرسانی وضعیت", "pnl_delete": "حذف سرویس", "pnl_back": "بازگشت به لیست"}


def get_pnl_layout():
    try:
        order = [b for b in json.loads(get_setting("pnl_order") or "[]") if b in PNL_BTN_DEFAULT]
    except Exception:
        order = []
    for b in PNL_BTN_DEFAULT:
        if b not in order:
            order.append(b)
    try:
        hidden = set(b for b in json.loads(get_setting("pnl_hidden") or "[]") if b in PNL_BTN_DEFAULT)
    except Exception:
        hidden = set()
    cols = 1 if get_setting("pnl_cols") == "1" else 2
    return order, hidden, cols


def build_pnl_keyboard(buttons: dict):
    """buttons: شناسه -> دکمه ساخته‌شده (فقط دکمه‌هایی که برای این پنل کاربرد دارند)"""
    order, hidden, cols = get_pnl_layout()
    kb = types.InlineKeyboardMarkup()
    row = []
    for b in order:
        if b in hidden or b not in buttons:
            continue
        if cols == 1 or b == "pnl_back":
            if row:
                kb.row(*row)
                row = []
            kb.add(buttons[b])
            continue
        row.append(buttons[b])
        if len(row) == 2:
            kb.row(*row)
            row = []
    if row:
        kb.row(*row)
    return kb


def show_pnl_layout_editor(chat_id, msg_id):
    order, hidden, cols = get_pnl_layout()
    kb = types.InlineKeyboardMarkup()
    for i, b in enumerate(order):
        label = PNL_BTN_LABELS.get(b, b)
        kb.row(make_inline_button(text=f"{'🚫' if b in hidden else '✅'} {label}", callback_data=f"adm_pnl_hide:{b}",
                                  style="none", keep_emoji=True),
               make_inline_button(text="🔼", callback_data=f"adm_pnl_mv:{b}:up", style="none", keep_emoji=True),
               make_inline_button(text="🔽", callback_data=f"adm_pnl_mv:{b}:down", style="none", keep_emoji=True))
    kb.add(make_inline_button(text=f"چیدمان: {'دو‌تایی در هر ردیف' if cols == 2 else 'یکی در هر ردیف'} ◂ تغییر",
                              callback_data="adm_pnl_cols", style="none", keep_emoji=True))
    kb.add(make_inline_button(text="♻️ برگشت به حالت پیش‌فرض", callback_data="adm_pnl_reset", style="none",
                              keep_emoji=True))
    kb.add(make_inline_button(text="بازگشت", callback_data="adm_edit_texts", style="none"))
    update_message_safe(chat_id, msg_id,
                        "<b>📂 چینش دکمه‌های پنل‌های من</b>\n\n"
                        "با 🔼 و 🔽 جای دکمه را عوض کنید و با زدن روی اسم دکمه، آن را مخفی (🚫) یا نمایان (✅) کنید.\n"
                        "دکمه‌ای که به پنل مربوط نباشد (مثلاً ورود به پنل برای سرویس‌های ساده) خودکار نمایش داده نمی‌شود.",
                        reply_markup=kb)


def refresh_panel_status(uid, pid):
    """وضعیت واقعی سرویس را از سرور می‌خواند و در دیتابیس به‌روز می‌کند (برای پنل‌های ادمین/نمایندگی)."""
    row = dbx("SELECT username, panel_id, kind FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
    if not row or row[2] != "admin":
        return True, None
    uname, spid, _kind = row
    try:
        pi = _panel_info_dict(spid)
        if not pi:
            return False, "پنل سرور پیدا نشد."
        url_base = pi["url"].rstrip("/")
        token = get_pasarguard_token(pi)
        if not token:
            return False, "اتصال به پنل برقرار نشد."
        info, why = _fetch_panel_admin(url_base, {"Authorization": f"Bearer {token}"}, uname)
        if not info:
            return False, why or "پنل در سرور پیدا نشد."
        exp = _panel_timestamp(info.get("expire", info.get("expire_ts")))
        dis = 1 if info.get("is_disabled") else 0
        dbx("UPDATE user_panels SET expire_ts = ?, is_disabled = ? WHERE id = ?", (exp, dis, pid))
        return True, None
    except Exception as e:
        return False, str(e)[:120]


def show_user_panel_detail(chat_id, msg_id, uid, pid, reveal=False, stamp=False):
    row = dbx("SELECT name, username, password, url, is_test, panel_id, kind, expire_ts, is_disabled "
              "FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
    if not row:
        update_message_safe(chat_id, msg_id, "سرویس یافت نشد.", reply_markup=get_back_keyboard())
        return
    pname, uname, pwd, url, is_t, server_pid, kind, exp_ts, disabled = row
    disabled = bool(disabled)
    btns = {}
    state_txt = "⛔ غیرفعال" if disabled else "✅ فعال"

    if kind == "admin":
        pw_show = f"<code>{html.escape(str(pwd))}</code>" if reveal else "<code>••••••••</code> (مخفی)"
        txt = (f"<b>مشخصات پنل ادمین: {html.escape(str(pname))}</b>\n\n"
               f"👤 <b>نام کاربری:</b> <code>{html.escape(str(uname))}</code>\n"
               f"🔑 <b>رمز عبور:</b> {pw_show}\n"
               f"🌐 <b>لینک ورود به پنل:</b>\n<code>{html.escape(str(url))}</code>\n\n"
               f"📌 <b>وضعیت پنل:</b> <b>{state_txt}</b>\n"
               f"⏳ <b>وضعیت اعتبار:</b> <b>{admin_expiry_text(exp_ts)}</b>")
        if disabled:
            txt += "\n\n<i>پنل غیرفعال است؛ برای ورود ابتدا آن را فعال کنید.</i>"
        elif is_valid_url(url):
            btns["pnl_open"] = make_inline_button(text="ورود به پنل", url=url, btn_id="pnl_open")
        if reveal:
            btns["pnl_pw"] = make_inline_button(text="🙈 مخفی کردن رمز", callback_data=f"pnl_pw:{pid}:0",
                                                btn_id="pnl_pw", keep_emoji=True)
        else:
            btns["pnl_pw"] = make_inline_button(text="👁 نمایان کردن رمز", callback_data=f"pnl_pw:{pid}:1",
                                                btn_id="pnl_pw", keep_emoji=True)
        btns["pnl_chpw"] = make_inline_button(text="🔐 تغییر رمز پنل", callback_data=f"pnl_chpw:{pid}",
                                              btn_id="pnl_chpw", keep_emoji=True)
    else:
        ok, info = get_pasarguard_user_info(uname, server_pid)
        if ok:
            txt = (f"<b>مشخصات سرویس: {html.escape(str(pname))}</b>\n\n"
                   f"👤 <b>نام کاربری:</b> <code>{html.escape(str(uname))}</code>\n"
                   f"🌐 <b>لینک اتصال (ساب):</b>\n<code>{html.escape(str(url))}</code>\n\n"
                   f"📌 <b>وضعیت سرویس:</b> <b>{state_txt}</b>\n"
                   f"⚡️ <b>وضعیت اتصال:</b> <b>{info['status']}</b>\n"
                   f"📊 <b>حجم مصرف شده:</b> <code>{info['used']}</code>\n"
                   f"📈 <b>حجم باقی‌مانده:</b> <code>{info['remaining']}</code>\n"
                   f"📦 <b>کل ترافیک:</b> <code>{info['total']}</code>\n"
                   f"⏳ <b>زمان انقضا:</b> <code>{info['expire']}</code>")
        else:
            txt = (f"<b>مشخصات سرویس: {html.escape(str(pname))}</b>\n\n"
                   f"👤 <b>نام کاربری:</b> <code>{html.escape(str(uname))}</code>\n"
                   f"🌐 <b>لینک اتصال:</b>\n<code>{html.escape(str(url))}</code>\n\n"
                   f"📌 <b>وضعیت سرویس:</b> <b>{state_txt}</b>\n"
                   f"⚠️ <i>امکان دریافت وضعیت آنلاین سرویس از سرور وجود ندارد.</i>")
        btns["pnl_rev"] = make_inline_button(text="🔗 تغییر لینک اشتراک", callback_data=f"pnl_rev_ask:{pid}",
                                             btn_id="pnl_rev", keep_emoji=True)

    btns["pnl_renew"] = make_inline_button(text="🔄 تمدید سرویس", callback_data=f"renew_panel:{pid}",
                                           btn_id="pnl_renew", style="none", keep_emoji=True)
    btns["pnl_toggle"] = make_inline_button(text="▶️ فعال کردن" if disabled else "⏸ غیرفعال کردن",
                                            callback_data=f"pnl_toggle:{pid}", btn_id="pnl_toggle", keep_emoji=True)
    btns["pnl_refresh"] = make_inline_button(text="🔄 بروزرسانی وضعیت", callback_data=f"pnl_refresh:{pid}",
                                             btn_id="pnl_refresh", keep_emoji=True)
    btns["pnl_delete"] = make_inline_button(text="🗑 حذف سرویس", callback_data=f"pnl_del_ask:{pid}",
                                            btn_id="pnl_delete", style="danger", keep_emoji=True)
    btns["pnl_back"] = make_inline_button(text="بازگشت به لیست", callback_data="btn_my_panels", btn_id="pnl_back")
    kb = build_pnl_keyboard(btns)
    if stamp:
        txt += f"\n\n🕒 <i>آخرین بروزرسانی: {datetime.now().strftime('%H:%M:%S')}</i>"
    update_message_safe(chat_id, msg_id, txt, reply_markup=kb)


def apply_panel_password(chat_id, msg_id, uid, pid, new_pw):
    row = dbx("SELECT username, panel_id, kind, is_disabled FROM user_panels WHERE id = ? AND user_id = ?",
              (pid, uid), "one")
    user_states.pop(uid, None)
    if not row or row[2] != "admin":
        update_message_safe(chat_id, msg_id, "سرویس یافت نشد.", reply_markup=get_back_keyboard())
        return
    if row[3]:
        # پنل غیرفعال است؛ رمز جدید فقط ذخیره می‌شود و موقع فعال‌سازی روی پنل اعمال می‌شود
        dbx("UPDATE user_panels SET password = ? WHERE id = ?", (new_pw, pid))
        show_user_panel_detail(chat_id, msg_id, uid, pid, reveal=True)
        send_message_safe(chat_id, "✅ <b>رمز جدید ذخیره شد و موقع فعال کردن پنل اعمال می‌شود.</b>")
        return
    update_message_safe(chat_id, msg_id, "<b>در حال تغییر رمز عبور پنل...</b>")
    ok, err = _modify_pasarguard_admin(row[0], {"password": new_pw}, row[1])
    if not ok:
        update_message_safe(chat_id, msg_id, f"<b>خطا در تغییر رمز:</b>\n<code>{html.escape(str(err))}</code>",
                            reply_markup=cancel_kb(f"show_panel:{pid}"))
        return
    dbx("UPDATE user_panels SET password = ? WHERE id = ?", (new_pw, pid))
    show_user_panel_detail(chat_id, msg_id, uid, pid, reveal=True)
    send_message_safe(chat_id, "✅ <b>رمز عبور پنل با موفقیت تغییر کرد.</b>")


# ==================== دستور استارت ====================
# ==================== پیام همگانی نسخه ۲ (ویدیو، تأیید، دکمه، پین، کانال) ====================
MAIN_BTNS = ["btn_buy", "btn_my_panels", "btn_test", "btn_wallet", "btn_support", "btn_channel", "btn_miniapp",
             "btn_agency"]
CONTENT = ["text", "photo", "video", "document", "animation", "audio", "voice", "video_note", "sticker"]


def register(G):
    bot = G["bot"]
    S = {}  # uid -> state

    def g(name):
        return G[name]

    G["dbx"]("CREATE TABLE IF NOT EXISTS bc2_pins (id INTEGER PRIMARY KEY AUTOINCREMENT, "
             "chat_id TEXT, message_id INTEGER)")

    def allowed(uid):
        try:
            return g("admin_can")(uid, "broadcast")
        except Exception:
            return g("is_admin")(uid)

    def btn(text, cb, style="none"):
        return g("make_inline_button")(text=text, callback_data=cb, style=style, keep_emoji=True)

    def channels():
        raw = g("get_setting")("bc2_channels") or ""
        return [c.strip() for c in raw.split(",") if c.strip()]

    def target_items():
        """(کلید، متن) همه مقصدهای قابل انتخاب: دکمه‌های منو، گروه‌های پلن و پلن‌ها"""
        items = [(b, g("get_button_display")(b)) for b in MAIN_BTNS]
        for gr in g("get_plan_groups")():
            items.append((f"g:{gr[0]}", f"📁 {gr[1]}"))
            for pl in g("get_plans_in_group")(gr[0]):
                items.append((f"p:{pl[0]}", f"   └ {pl[1]}"))
        for pl in g("get_plans_in_group")(0):
            items.append((f"p:{pl[0]}", f"🏷 {pl[1]}"))
        return items

    def build_markup(selected, for_channel=False):
        if not selected:
            return None
        kb = types.InlineKeyboardMarkup()
        link = g("get_setting")("channel_link")
        if not g("is_valid_url")(link):
            link = "https://t.me/telegram"
        for key, label in target_items():
            if key not in selected:
                continue
            label = g("strip_button_emoji")(label.replace("└", "").replace("📁", "").replace("🏷", "")).strip() or label
            if key == "btn_channel":
                kb.add(g("make_inline_button")(text=label, url=link, btn_id=key))
                continue
            if for_channel:
                url = g("deeplink_for")(key)
                if not url:
                    continue
                kb.add(g("make_inline_button")(text=label, url=url, btn_id=key if key in MAIN_BTNS else None))
            else:
                if key.startswith("g:"):
                    cb = "ugrp:" + key[2:]
                elif key.startswith("p:"):
                    cb = "buy_plan_sel:" + key[2:]
                else:
                    cb = key
                kb.add(g("make_inline_button")(text=label, callback_data=cb, btn_id=key if key in MAIN_BTNS else None))
        return kb

    def show(uid, text, kb):
        st = S.get(uid, {})
        m = g("update_message_safe")(uid, st.get("menu_id"), text, reply_markup=kb)
        if m is not None and hasattr(m, "message_id"):
            st["menu_id"] = m.message_id

    # ---------- منوها ----------
    def screen_start(uid):
        kb = types.InlineKeyboardMarkup()
        kb.add(btn("📨 ارسال پیام همگانی جدید", "bc2_new", "success"))
        kb.add(btn("📌 برداشتن همه پین‌ها", "bc2_unpin", "danger"))
        kb.add(btn("📣 تنظیم کانال‌ها", "bc2_chans", "primary"))
        kb.add(btn("بازگشت به پنل مدیریت", "admin_panel"))
        chs = channels()
        show(uid, "<b>📨 پیام همگانی</b>\n\nکانال‌های ثبت‌شده: "
                  f"<b>{len(chs)}</b>", kb)

    def screen_dest(uid):
        st = S[uid]
        kb = types.InlineKeyboardMarkup()
        kb.add(btn(("✅ " if st["users"] else "⬜ ") + "همه کاربران ربات", "bc2_d_users"))
        kb.add(btn(("✅ " if st["chan"] else "⬜ ") + f"کانال‌ها ({len(channels())})", "bc2_d_chan"))
        kb.add(btn("ادامه ⬅️", "bc2_dnext", "success"))
        kb.add(btn("انصراف", "bc2_cancel"))
        show(uid, "<b>مقصد ارسال را انتخاب کنید:</b>", kb)

    def screen_buttons(uid):
        st = S[uid]
        kb = types.InlineKeyboardMarkup()
        for b, label in target_items():
            mark = "✅ " if b in st["btns"] else "⬜ "
            kb.add(btn(mark + label, f"bc2_b:{b}"))
        kb.add(btn("🚫 بدون دکمه", "bc2_bnone", "danger"))
        kb.add(btn("ادامه ⬅️", "bc2_bnext", "success"))
        kb.add(btn("انصراف", "bc2_cancel"))
        show(uid, "<b>دکمه‌های شیشه‌ای زیر پیام را انتخاب کنید</b>\n"
                  "می‌توانید دکمه منو، گروه پلن یا یک پلن مشخص بگذارید. در کانال، دکمه‌ها کاربر را وارد ربات می‌کنند "
                  "و ربات همان بخش را خودش باز می‌کند.\n(یا «بدون دکمه» را بزنید):", kb)

    def screen_opts(uid):
        st = S[uid]
        kb = types.InlineKeyboardMarkup()
        kb.add(btn(("✅ " if st["pin"] else "⬜ ") + "پین کردن پیام", "bc2_pin"))
        kb.add(btn("پیش‌نمایش ⬅️", "bc2_prev", "success"))
        kb.add(btn("انصراف", "bc2_cancel"))
        show(uid, "<b>تنظیمات ارسال</b>", kb)

    def screen_confirm(uid):
        st = S[uid]
        dests = []
        if st["users"]:
            dests.append("همه کاربران")
        if st["chan"]:
            dests.append(f"{len(channels())} کانال")
        names = [lbl.replace("└", "").strip() for b, lbl in target_items() if b in st["btns"]]
        try:
            bot.copy_message(uid, st["src"][0], st["src"][1], reply_markup=build_markup(st["btns"]))
        except Exception as e:
            bot.send_message(uid, f"❌ پیش‌نمایش ساخته نشد: {html.escape(str(e)[:150])}")
        kb = types.InlineKeyboardMarkup()
        kb.row(btn("✅ تأیید و ارسال", "bc2_yes", "success"), btn("❌ ارسال نشود", "bc2_no", "danger"))
        st["menu_id"] = None
        show(uid, "<b>آیا ارسال شود؟</b>\n\n"
                  f"🎯 مقصد: <b>{' + '.join(dests)}</b>\n"
                  f"🔘 دکمه‌ها: <b>{('، '.join(names)) if names else 'بدون دکمه'}</b>\n"
                  f"📌 پین: <b>{'بله' if st['pin'] else 'خیر'}</b>", kb)

    # ---------- ارسال ----------
    def send_one(target, st, kb):
        for attempt in range(2):
            try:
                m = bot.copy_message(target, st["src"][0], st["src"][1], reply_markup=kb, timeout=25)
                if st["pin"]:
                    try:
                        bot.pin_chat_message(target, m.message_id, disable_notification=True)
                        G["dbx"]("INSERT INTO bc2_pins (chat_id, message_id) VALUES (?, ?)",
                                 (str(target), m.message_id))
                    except Exception:
                        pass
                return True, ""
            except Exception as e:
                reason = g("_short_err")(e)
                wait = g("_retry_after")(e)
                if wait and attempt == 0:
                    time.sleep(min(wait, 30))
                    continue
                return False, reason
        return False, "unknown"

    def run_send(uid, st):
        kb = build_markup(st["btns"])
        kb_chan = build_markup(st["btns"], for_channel=True)
        out = []
        if st["users"]:
            rows = G["dbx"]("SELECT user_id FROM users WHERE is_blocked = 0", (), "all")
            ok, reasons = 0, {}
            for (u,) in rows:
                good, why = send_one(u, st, kb)
                if good:
                    ok += 1
                else:
                    reasons[why] = reasons.get(why, 0) + 1
                time.sleep(0.05)
            out.append("<b>👥 کاربران</b>\n" + g("_broadcast_summary")(ok, reasons))
        if st["chan"]:
            ok, reasons = 0, {}
            for ch in channels():
                good, why = send_one(ch, st, kb_chan)
                if good:
                    ok += 1
                else:
                    reasons[f"{ch}: {why}"] = 1
            out.append("<b>📣 کانال‌ها</b>\n" + g("_broadcast_summary")(ok, reasons))
        bot.send_message(uid, "\n\n".join(out), parse_mode="HTML")

    def run_unpin(uid):
        rows = G["dbx"]("SELECT id, chat_id, message_id FROM bc2_pins", (), "all")
        ok = fail = 0
        for rid, cid, mid in rows:
            try:
                bot.unpin_chat_message(cid, mid)
                ok += 1
            except Exception:
                fail += 1
            G["dbx"]("DELETE FROM bc2_pins WHERE id = ?", (rid,))
            time.sleep(0.05)
        bot.send_message(uid, f"📌 پین‌ها برداشته شد.\n✅ موفق: <b>{ok}</b>\n❌ ناموفق: <b>{fail}</b>",
                         parse_mode="HTML")

    # ---------- هندلرها ----------
    @bot.message_handler(commands=["broadcast"], chat_types=["private"])
    def _cmd(m):
        if not allowed(m.from_user.id):
            return
        S[m.from_user.id] = {"menu_id": None}
        screen_start(m.from_user.id)

    @bot.callback_query_handler(func=lambda c: (c.data or "").startswith("bc2_"))
    def _cb(c):
        uid, d = c.from_user.id, c.data
        try:
            bot.answer_callback_query(c.id)
        except Exception:
            pass
        if not allowed(uid):
            return
        st = S.setdefault(uid, {"menu_id": None})
        st["menu_id"] = c.message.message_id
        if d == "bc2_start":
            S[uid] = {"menu_id": c.message.message_id}
            screen_start(uid)
        elif d == "bc2_cancel":
            S.pop(uid, None)
            _kb = types.InlineKeyboardMarkup()
            _kb.add(btn("بازگشت به پنل مدیریت", "admin_panel"))
            g("update_message_safe")(uid, c.message.message_id, "لغو شد. چیزی ارسال نشد.", reply_markup=_kb)
        elif d == "bc2_new":
            S[uid] = {"menu_id": c.message.message_id, "users": True, "chan": False,
                      "btns": set(), "pin": False, "step": ""}
            screen_dest(uid)
        elif d in ("bc2_d_users", "bc2_d_chan") and "users" in st:
            k = "users" if d == "bc2_d_users" else "chan"
            st[k] = not st[k]
            screen_dest(uid)
        elif d == "bc2_dnext" and "users" in st:
            if not (st["users"] or st["chan"]):
                return bot.answer_callback_query(c.id, "حداقل یک مقصد انتخاب کنید", show_alert=True)
            if st["chan"] and not channels():
                return bot.answer_callback_query(c.id, "اول کانال‌ها را ثبت کنید", show_alert=True)
            st["step"] = "await_msg"
            kb = types.InlineKeyboardMarkup()
            kb.add(btn("انصراف", "bc2_cancel"))
            show(uid, "پیام موردنظر را بفرستید (متن، ویدیو، عکس، فایل و...).\n"
                      "فرمت و ایموجی پریمیوم حفظ می‌شود.", kb)
        elif d.startswith("bc2_b:") and "btns" in st:
            b = d.split(":", 1)[1]
            st["btns"] ^= {b}
            screen_buttons(uid)
        elif d == "bc2_bnone" and "btns" in st:
            st["btns"] = set()
            screen_opts(uid)
        elif d == "bc2_bnext" and "btns" in st:
            screen_opts(uid)
        elif d == "bc2_pin" and "pin" in st:
            st["pin"] = not st["pin"]
            screen_opts(uid)
        elif d == "bc2_prev" and "src" in st:
            screen_confirm(uid)
        elif d == "bc2_yes" and "src" in st:
            S.pop(uid, None)
            g("update_message_safe")(uid, c.message.message_id, "⏳ تأیید شد؛ در حال ارسال...")
            threading.Thread(target=run_send, args=(uid, st), daemon=True).start()
        elif d == "bc2_no":
            S.pop(uid, None)
            _kb = types.InlineKeyboardMarkup()
            _kb.add(btn("بازگشت به پنل مدیریت", "admin_panel"))
            g("update_message_safe")(uid, c.message.message_id, "❌ تأیید نشد؛ چیزی ارسال نشد.", reply_markup=_kb)
        elif d == "bc2_unpin":
            n = G["dbx"]("SELECT COUNT(*) FROM bc2_pins", (), "one")[0]
            kb = types.InlineKeyboardMarkup()
            kb.row(btn("✅ بله، بردار", "bc2_unpin_yes", "danger"), btn("انصراف", "bc2_start"))
            show(uid, f"پین <b>{n}</b> پیامِ همگانی (کاربران و کانال‌ها) برداشته شود؟", kb)
        elif d == "bc2_unpin_yes":
            g("update_message_safe")(uid, c.message.message_id, "⏳ در حال برداشتن پین‌ها...")
            threading.Thread(target=run_unpin, args=(uid,), daemon=True).start()
        elif d == "bc2_chans":
            st["step"] = "await_chan"
            kb = types.InlineKeyboardMarkup()
            kb.add(btn("انصراف", "bc2_start"))
            cur = "\n".join(channels()) or "—"
            show(uid, "آیدی کانال‌ها را بفرستید، هر کدام در یک خط (مثل <code>@mychannel</code> یا "
                      "<code>-100123...</code>).\nربات باید ادمین کانال باشد.\n\n"
                      f"فعلی:\n<code>{html.escape(cur)}</code>", kb)

    def _waiting(m):
        st = S.get(m.from_user.id)
        return bool(st and st.get("step") in ("await_msg", "await_chan")) and allowed(m.from_user.id)

    @bot.message_handler(func=_waiting, content_types=CONTENT, chat_types=["private"])
    def _msg(m):
        uid = m.from_user.id
        st = S[uid]
        if m.content_type == "text" and m.text.startswith("/"):
            S.pop(uid, None)
            return bot.send_message(uid, "لغو شد.")
        if st["step"] == "await_chan":
            lines = [x.strip() for x in (m.text or "").replace(",", "\n").split("\n") if x.strip()]
            g("set_setting")("bc2_channels", ",".join(lines))
            st["step"] = ""
            st["menu_id"] = None
            return screen_start(uid)
        st["src"] = (m.chat.id, m.message_id)
        st["step"] = ""
        st["menu_id"] = None
        screen_buttons(uid)


register(globals())


@bot.message_handler(commands=['start'])
def cmd_start(message):
    if message.chat.type != "private":
        return
    uid = message.from_user.id

    # ثبت کاربر و ارسال گزارش عضو جدید (فقط بار اول)
    start_parts = (message.text or "").strip().split(maxsplit=1)
    referral_id = None
    go_code = None
    if len(start_parts) > 1:
        referral_match = re.fullmatch(r"ref_(\d+)", start_parts[1].strip())
        if referral_match:
            referral_id = int(referral_match.group(1))
        go_code = parse_deeplink(start_parts[1])
    add_user_if_new(message.from_user, referral_id)

    user_states.pop(uid, None)
    user_temp_data.pop(uid, None)
    clear_draft(uid)
    last_mid = get_user_last_menu(uid)
    if last_mid:
        del_msg(message.chat.id, last_mid)
    _reaction = get_setting("start_reaction").strip()
    if _reaction:
        set_reaction(message.chat.id, message.message_id, _reaction)
    else:
        del_msg(message.chat.id, message.message_id)
    if kb_mode() != "reply":
        try:  # حذف کیبورد قدیمی (Reply) اگر قبلاً برای کاربر فعال بوده
            _m = bot.send_message(message.chat.id, "⏳", reply_markup=types.ReplyKeyboardRemove())
            del_msg(message.chat.id, _m.message_id)
        except Exception:
            pass

    if is_user_blocked(uid):
        send_message_safe(message.chat.id, "<b>حساب کاربری شما مسدود است.</b>")
        return
    if bot_is_off_for(uid):
        send_message_safe(message.chat.id, get_setting("bot_off_text"),
                          entities=parse_db_entities(get_setting("bot_off_entities")), parse_mode="HTML")
        return
    missing = get_missing_channels(uid)
    if missing:
        if go_code:
            pending_target[uid] = go_code
        send_force_join_prompt(message.chat.id, None, missing)
        return
    if rules_pending(uid):
        if go_code:
            pending_target[uid] = go_code
        sent = send_rules(message.chat.id, None, uid)
        return
    if go_code and kb_mode() != "reply":
        open_target(message.chat.id, uid, go_code)
        return
    s_text, s_ents = render_user_text(get_setting("start_text"), parse_db_entities(get_setting("start_entities")),
                                      get_user_display_name(uid, message.from_user))
    sent = send_message_safe(message.chat.id, s_text, reply_markup=main_menu_markup(uid), entities=s_ents)
    if sent:
        set_user_last_menu(uid, sent.message_id)
    if go_code:
        open_target(message.chat.id, uid, go_code)


def safe_answer(call, text=None, show_alert=False):
    try:
        bot.answer_callback_query(call.id, text, show_alert=show_alert)
    except Exception:
        pass


ADMIN_ONLY_PREFIXES = (
    "adm_", "pg_", "toggle_grp", "save_groups_sel", "manual_type_group", "shop_", "plan_set_panel",
    "del_plan", "edit_plan", "set_", "apply_", "usr_", "fj_toggle", "fj_add_channel", "fj_del",
    "add_new_admin", "del_admin_menu", "remove_adm", "perm_", "edit_text", "admin_panel",
    "edit_command", "toggle_visible_btn", "adm_button_visibility", "adm_commands", "adm_report_settings",
    "set_report_chat", "clear_report_chat", "admin_perms", "toggle_test_type",
)


def admin_feature_for_callback(data):
    if data.startswith(("adm_pem", "adm_faq", "adm_rules")):
        return "texts"
    if data.startswith(("adm_errors", "adm_changelog", "adm_su_")):
        return "system"
    if data.startswith("edit_text:shop_header_text"):
        return "shop"
    if data.startswith("edit_text:fj_"):
        return "force_join"
    if data in ("admin_panel",) or data.startswith("adm_cat:"):
        return "panel"
    if data.startswith(("adm_order_", "adm_pending_orders")):
        return "orders"
    if data.startswith(("adm_manage_admins", "add_new_admin", "del_admin_menu", "remove_adm",
                        "perm_", "perm_menu", "admin_perms")):
        return "admins"
    if data.startswith(("adm_pasarguard", "pg_", "toggle_grp", "save_groups_sel",
                        "manual_type_group")):
        return "servers"
    if data.startswith(("adm_test_plans", "set_plan:")):
        return "tests"
    if data.startswith("toggle_test_type:"):
        return "tests"
    if data.startswith(("adm_card_settings", "set_card_")):
        return "payment"
    if data.startswith(("adm_manage_users", "usr_")):
        return "users"
    if data.startswith(("shop_", "del_plan", "edit_plan", "plan_set_panel", "edit_plan_panel")):
        return "shop"
    if data.startswith(("adm_layout", "adm_pnl_", "set_pos_menu", "apply_pos", "adm_button_visibility",
                        "toggle_visible_btn", "adm_commands", "edit_command")):
        return "buttons"
    if data.startswith(("adm_edit_texts", "adm_start_reaction", "adm_edit_emojis", "adm_edit_colors", "edit_text",
                        "set_emoji", "set_color_menu", "apply_color")):
        return "texts"
    if data.startswith(("adm_edit_links", "adm_report_settings", "adm_report_topics", "adm_report_test",
                        "set_report_chat", "clear_report_chat",
                        "set_support_user", "set_channel_link")):
        return "links"
    if data.startswith("adm_force_join") or data.startswith(("fj_toggle", "fj_add_channel", "fj_del")):
        return "force_join"
    if data.startswith("adm_broadcast"):
        return "broadcast"
    if data.startswith(("adm_toggle_bot_power", "adm_toggle_mode", "set_role_id", "set_expiry_cleanup",
                        "adm_stats", "adm_db_")):
        return "system"
    return None


@bot.callback_query_handler(func=lambda call: True)
def callback_router(call):
    try:
        _callback_router_impl(call)
    except Exception as e:
        traceback.print_exc()
        log_bot_error(f"callback {call.data}", e)
        safe_answer(call)
        try:
            detail = f"\n<code>{html.escape(str(e))[:300]}</code>" if is_admin(call.from_user.id) else ""
            bot.send_message(call.message.chat.id, f"⚠️ خطای غیرمنتظره رخ داد.{detail}", parse_mode="HTML",
                             reply_markup=get_back_keyboard())
        except Exception:
            pass


def _callback_router_impl(call):
    uid = call.from_user.id
    data = call.data or ""
    chat_id = call.message.chat.id
    msg_id = call.message.message_id
    set_user_last_menu(uid, msg_id)

    if is_user_blocked(uid):
        safe_answer(call, "دسترسی شما مسدود است.", True)
        return
    if bot_is_off_for(uid):
        safe_answer(call, get_setting("bot_off_text"), True)
        return

    if data.startswith(ADMIN_ONLY_PREFIXES):
        if not is_admin(uid):
            safe_answer(call, "دسترسی ندارید.", True)
            return
        feature = admin_feature_for_callback(data)
        if feature == "panel":
            perms = get_admin_perms(uid)
            allowed = (uid == OWNER_ID or not perms["explicit"] or bool(perms["features"]))
        else:
            allowed = admin_can(uid, feature) if feature else False
        if not allowed:
            safe_answer(call, "دسترسی ندارید.", True)
            return

    if data != "fj_check":
        missing = get_missing_channels(uid)
        if missing:
            safe_answer(call)
            send_force_join_prompt(chat_id, msg_id, missing)
            return

    if data not in ("fj_check", "rules_accept") and not data.startswith(ADMIN_ONLY_PREFIXES) and rules_pending(uid):
        safe_answer(call)
        send_rules(chat_id, msg_id, uid)
        return

    user_states.pop(uid, None)
    td = user_temp_data.setdefault(uid, {})
    td["_uid"] = uid

    def ask_state(state, prompt, back_cb, extra=None):
        user_states[uid] = state
        user_temp_data[uid] = {"last_msg_id": msg_id}
        if extra:
            user_temp_data[uid].update(extra)
        update_message_safe(chat_id, msg_id, prompt, reply_markup=cancel_kb(back_cb))

    if data == "fj_check":
        if get_missing_channels(uid):
            safe_answer(call, "هنوز در همه کانال‌ها عضو نشده‌اید!", True)
            return
        safe_answer(call, "عضویت تأیید شد ✅")
        if rules_pending(uid):
            send_rules(chat_id, msg_id, uid)
            return
        _go = pending_target.pop(uid, None)
        if _go and kb_mode() != "reply":
            open_target(chat_id, uid, _go, msg_id)
        else:
            show_main_menu(chat_id, msg_id, uid)
            if _go:
                open_target(chat_id, uid, _go)
        return

    if data == "rules_accept":
        dbx("UPDATE users SET rules_ok = 1 WHERE user_id = ?", (uid,))
        safe_answer(call, "✅")
        _go = pending_target.pop(uid, None)
        if _go and kb_mode() != "reply":
            open_target(chat_id, uid, _go, msg_id)
        else:
            show_main_menu(chat_id, msg_id, uid)
            if _go:
                open_target(chat_id, uid, _go)
        return

    if data == "back_to_main":
        user_states.pop(uid, None)
        user_temp_data.pop(uid, None)
        clear_draft(uid)
        show_main_menu(chat_id, msg_id, uid)
        return

    if data == "admin_panel":
        show_admin_panel(chat_id, msg_id, uid)
        return

    if data.startswith("adm_cat:"):
        show_admin_panel(chat_id, msg_id, uid, data.split(":", 1)[1])
        return

    if data.startswith("adm_pem"):
        pem_callback(call, uid, chat_id, msg_id, data)
        return

    if data.startswith(("adm_faq", "adm_errors", "adm_changelog")):
        new_panel_callback(call, uid, chat_id, msg_id, data)
        return

    # ---------- پنل‌های پاسارگاد ----------
    if data == "adm_pasarguard":
        show_pasarguard_menu(chat_id, msg_id)

    elif data.startswith("pg_view:"):
        show_single_panel_details(chat_id, msg_id, int(data.split(":")[1]))

    elif data == "pg_wizard_start":
        ask_state("step_pg_name", "<b>مرحله ۱ از ۴:</b>\nیک عنوان یا نام دلخواه برای این پنل وارد کنید:\n"
                                  "مثال: <code>سرور آلمان ۱</code>", "adm_pasarguard")

    elif data.startswith("pg_check_conn:"):
        pid = int(data.split(":")[1])
        p = get_server_panel_by_id(pid)
        if not p:
            return
        safe_answer(call, "در حال بررسی وضعیت...")
        ok, res = test_pasarguard_connection({"url": p[2], "username": p[3], "password": p[4]})
        kb = cancel_kb(f"pg_view:{pid}")
        if ok:
            text = (f"<b>وضعیت اتصال {html.escape(str(p[1]))}: موفقیت‌آمیز ✅</b>\n\nآدرس: <code>{html.escape(str(p[2]))}</code>\n"
                    f"یوزرنیم: <code>{html.escape(str(p[3]))}</code>\n\n<i>{html.escape(res)}</i>")
        else:
            text = f"<b>وضعیت اتصال {html.escape(str(p[1]))}: ناموفق ❌</b>\n\nعلت خطا:\n<code>{html.escape(str(res))}</code>"
        update_message_safe(chat_id, msg_id, text, reply_markup=kb)

    elif data.startswith("pg_debug_groups:"):
        pid = int(data.split(":")[1])
        p = get_server_panel_by_id(pid)
        if not p:
            return
        safe_answer(call, "در حال بررسی API...")
        token = get_pasarguard_token({"url": p[2], "username": p[3], "password": p[4]})
        if not token:
            update_message_safe(chat_id, msg_id, "خطا در دریافت توکن پنل.", reply_markup=cancel_kb(f"pg_view:{pid}"))
            return
        headers = {"Authorization": f"Bearer {token}"}
        debug_text = ""
        for ep in ("/api/groups", "/api/inbounds"):
            try:
                res = requests.get(f"{p[2].rstrip('/')}{ep}", headers=headers, timeout=10)
                debug_text += (f"<b>Endpoint:</b> <code>{ep}</code>\nStatus: {res.status_code}\n"
                               f"<pre>{html.escape(res.text[:600])}</pre>\n\n")
            except Exception as e:
                debug_text += f"<b>Endpoint:</b> <code>{ep}</code>\nError: {html.escape(str(e))}\n\n"
        update_message_safe(chat_id, msg_id, f"<b>دیباگ API پنل {html.escape(str(p[1]))}:</b>\n\n{debug_text}",
                            reply_markup=cancel_kb(f"pg_view:{pid}"))

    elif data.startswith("pg_choose_groups:"):
        td.pop("selected_groups", None)
        td.pop("is_all", None)
        show_group_selection_menu(chat_id, msg_id, uid, int(data.split(":")[1]))

    elif data.startswith("toggle_grp_all:"):
        pid = int(data.split(":")[1])
        groups = td.get("grp_list") or []
        if td.get("is_all", False):
            td["is_all"] = False
            td["selected_groups"] = []
        else:
            td["is_all"] = True
            td["selected_groups"] = list(groups)
        show_group_selection_menu(chat_id, msg_id, uid, pid)

    elif data.startswith("toggle_grp:"):
        _, pid_str, idx_str = data.split(":", 2)
        groups = td.get("grp_list") or []
        idx = int(idx_str)
        if 0 <= idx < len(groups):
            name = groups[idx]
            sel = td.setdefault("selected_groups", [])
            if td.get("is_all"):
                sel = list(groups)
                td["selected_groups"] = sel
            td["is_all"] = False
            if name in sel:
                sel.remove(name)
            else:
                sel.append(name)
        show_group_selection_menu(chat_id, msg_id, uid, int(pid_str))

    elif data.startswith("save_groups_sel:"):
        pid = int(data.split(":")[1])
        is_all = td.get("is_all", False)
        sel = td.get("selected_groups", [])
        final_grp = "ALL" if (is_all or not sel) else ",".join(sel)
        dbx("UPDATE server_panels SET group_name = ? WHERE id = ?", (final_grp, pid))
        td.pop("selected_groups", None)
        safe_answer(call, "گروه ذخیره شد ✅")
        show_single_panel_details(chat_id, msg_id, pid)

    elif data.startswith("manual_type_group:"):
        pid = int(data.split(":")[1])
        ask_state("waiting_manual_group", "ID عددی گروه را وارد کنید (مثلاً: <code>1</code> یا <code>1,2</code>):",
                  f"pg_view:{pid}", {"panel_id": pid})

    elif data.startswith("pg_edit_url:"):
        pid = int(data.split(":")[1])
        ask_state("waiting_edit_pg_url", "آدرس جدید پنل را وارد کنید:", f"pg_view:{pid}", {"panel_id": pid})

    elif data.startswith("pg_edit_user:"):
        pid = int(data.split(":")[1])
        ask_state("waiting_edit_pg_user", "یوزرنیم جدید ادمین را وارد کنید:", f"pg_view:{pid}", {"panel_id": pid})

    elif data.startswith("pg_edit_pass:"):
        pid = int(data.split(":")[1])
        ask_state("waiting_edit_pg_pass", "رمز عبور جدید ادمین را وارد کنید:", f"pg_view:{pid}", {"panel_id": pid})

    elif data.startswith("pg_del_confirm:"):
        pid = int(data.split(":")[1])
        p = get_server_panel_by_id(pid)
        if not p:
            return
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="بله، حذف شود", callback_data=f"pg_del_do:{pid}", style="danger"),
               make_inline_button(text="انصراف", callback_data="adm_pasarguard"))
        update_message_safe(chat_id, msg_id, f"آیا از حذف پنل «<b>{html.escape(str(p[1]))}</b>» اطمینان دارید؟",
                            reply_markup=kb)

    elif data.startswith("pg_del_do:"):
        dbx("DELETE FROM server_panels WHERE id = ?", (int(data.split(":")[1]),))
        safe_answer(call, "پنل حذف شد.")
        show_pasarguard_menu(chat_id, msg_id)

    # ---------- آمار و دیتابیس ----------
    elif data == "adm_stats":
        safe_answer(call, "در حال محاسبه...")
        show_bot_stats(chat_id, msg_id)

    elif data == "adm_db_menu":
        show_db_menu(chat_id, msg_id)

    elif data == "adm_db_interval":
        ask_state("waiting_backup_hours",
                  f"<b>⏱ زمان ارسال بکاپ</b>\n\nمقدار فعلی: هر <b>{get_backup_interval_hours()}</b> ساعت\n\n"
                  "هر چند ساعت یک‌بار دیتابیس به گروه گزارشات فرستاده شود؟ عددی بین ۱ تا ۷۲۰ بفرستید:",
                  "adm_db_menu")

    elif data == "adm_start_reaction":
        ask_state("waiting_start_reaction",
                  f"<b>😀 ری‌اکشن پیام استارت</b>\n\nفعلی: <b>{reaction_label(get_setting('start_reaction'))}</b>\n\n"
                  "یک ایموجی بفرستید (مثل 👍 ❤️ 🔥 🎉 🤩 👏 😁 🙏 👌 🕊 🥰). وقتی کاربر /start بزند ربات "
                  "روی پیامش همین ری‌اکشن را می‌گذارد.\n✨ ایموجی پریمیوم (متحرک) هم می‌توانید بفرستید.\nبرای خاموش کردن عدد <code>0</code> را بفرستید.",
                  "adm_cat:3")

    elif data == "adm_db_download":
        safe_answer(call, "در حال آماده‌سازی بکاپ...")
        try:
            send_db_backup(target_chat=chat_id, title="بکاپ دستی دیتابیس")
        except Exception as e:
            traceback.print_exc()
            send_message_safe(chat_id, f"❌ خطا در ساخت بکاپ: <code>{html.escape(str(e)[:200])}</code>",
                              parse_mode="HTML")

    elif data == "adm_db_upload":
        ask_state("waiting_db_upload",
                  "📥 فایل دیتابیس (‎.db) را به صورت <b>فایل</b> ارسال کنید.\n\n"
                  "⚠️ دیتابیس فعلی با فایل ارسالی جایگزین می‌شود (قبلش یک بکاپ برای شما ارسال می‌شود).",
                  "adm_db_menu")

    elif data == "adm_db_delete":
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="✅ بله، حذف کن", callback_data="adm_db_delete_yes", style="danger",
                                  keep_emoji=True),
               make_inline_button(text="انصراف", callback_data="adm_db_menu"))
        update_message_safe(chat_id, msg_id,
                            "⚠️ <b>آیا مطمئن هستید؟</b>\n\nتمام کاربران، سفارش‌ها، پلن‌ها و تنظیمات پاک می‌شوند. "
                            "قبل از حذف یک بکاپ برای شما ارسال می‌شود.", reply_markup=kb)

    elif data == "adm_db_delete_yes":
        if uid != OWNER_ID:
            safe_answer(call, "فقط مالک ربات می‌تواند دیتابیس را حذف کند.", True)
            return
        safe_answer(call, "در حال حذف...")
        try:
            send_db_backup(target_chat=chat_id, title="بکاپ قبل از حذف دیتابیس")
        except Exception:
            traceback.print_exc()
        with _backup_lock:
            for suffix in ("", "-wal", "-shm", "-journal"):
                try:
                    os.remove(DB_FILE + suffix)
                except FileNotFoundError:
                    pass
            init_db()
        user_states.clear()
        user_temp_data.clear()
        send_message_safe(chat_id, "🗑 دیتابیس حذف شد و یک دیتابیس خالی جدید ساخته شد.")
        show_admin_panel(chat_id, msg_id, uid)

    # ---------- کد تخفیف (مدیریت) ----------
    elif data == "shop_discounts":
        show_discounts_menu(chat_id, msg_id)

    elif data == "shop_disc_noop":
        safe_answer(call)

    elif data == "shop_disc_toggle":
        set_setting("discount_enabled", "0" if discount_enabled() else "1")
        safe_answer(call, "کد تخفیف در صفحه خرید " + ("فعال شد" if discount_enabled() else "غیرفعال شد"))
        show_discounts_menu(chat_id, msg_id)

    elif data == "shop_disc_add":
        ask_state("shop_disc_code",
                  "🎟 متن کد تخفیف را بنویسید (حروف انگلیسی، عدد، - و _ ، حداکثر ۳۲ کاراکتر):\nمثال: <code>OFF50</code>",
                  "shop_discounts")

    elif data.startswith("shop_disc_scope:"):
        td["last_msg_id"] = msg_id
        if data.endswith(":all"):
            td["disc_picks"] = set()
            ask_disc_min_gb(uid, chat_id, msg_id, td)
        else:
            td["disc_picks"] = set()
            show_disc_pick_menu(chat_id, msg_id, td)

    elif data.startswith("shop_disc_pick:"):
        key = data.split(":", 1)[1]
        picks = td.setdefault("disc_picks", set())
        picks.symmetric_difference_update({key})
        show_disc_pick_menu(chat_id, msg_id, td)

    elif data == "shop_disc_done":
        picks = td.get("disc_picks") or set()
        if not picks:
            safe_answer(call, "حداقل یک مورد را انتخاب کنید.", True)
            return
        if "c" in picks:
            ask_disc_min_gb(uid, chat_id, msg_id, td)
        else:
            td["disc_min_gb"] = 0
            td["last_msg_id"] = msg_id
            finalize_discount(uid, chat_id)

    elif data.startswith("shop_disc_del:"):
        dbx("DELETE FROM discount_codes WHERE id = ?", (int(data.split(":")[1]),))
        safe_answer(call, "کد تخفیف حذف شد.")
        show_discounts_menu(chat_id, msg_id)

    # ---------- کد تخفیف (کاربر) ----------
    elif data == "buy_disc_enter":
        if not discount_enabled() or not td.get("plan_id"):
            safe_answer(call, "امکان استفاده از کد تخفیف وجود ندارد.", True)
            return
        user_states[uid] = "buy_wait_discount"
        td["last_msg_id"] = msg_id
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text="بازگشت به صفحه پرداخت", callback_data="buy_disc_back"))
        update_message_safe(chat_id, msg_id, "🎟 کد تخفیف خود را ارسال کنید:", reply_markup=kb)

    elif data in ("buy_disc_remove", "buy_disc_back"):
        if not td.get("plan_id"):
            safe_answer(call, "سفارش شما منقضی شده است؛ دوباره اقدام کنید.", True)
            return
        if data == "buy_disc_remove":
            td.pop("discount", None)
        show_payment_and_receipt_step(chat_id, msg_id, uid)

    # ---------- فروشگاه ----------
    elif data == "shop_settings":
        show_shop_settings_menu(chat_id, msg_id)

    elif data == "shop_add_plan":
        if not get_all_server_panels():
            safe_answer(call, "ابتدا باید در «تنظیمات پنل پاسارگاد» حداقل یک پنل ثبت کنید!", True)
            return
        ask_state("shop_add_name", "<b>مرحله ۱ از ۹:</b>\nعنوان پلن را وارد کنید (مثال: <code>یک‌ماهه ۳۰ گیگ</code>):",
                  "shop_settings")

    elif data.startswith("plan_set_panel:"):
        td["new_panel_id"] = int(data.split(":")[1])
        groups = get_plan_groups()
        if groups:
            kb = types.InlineKeyboardMarkup()
            for g_ in groups:
                kb.add(make_inline_button(text=str(g_[1]), callback_data=f"shop_new_grp:{g_[0]}", style="none",
                                          keep_emoji=True))
            kb.add(make_inline_button(text="بدون گروه", callback_data="shop_new_grp:0", style="none"))
            kb.add(make_inline_button(text="انصراف", callback_data="shop_settings"))
            update_message_safe(chat_id, msg_id, "<b>مرحله ۷ از ۹:</b>\nپلن در کدام گروه نمایش داده شود؟",
                                reply_markup=kb)
        else:
            td["new_group_id"] = 0
            _ask_new_plan_color(chat_id, msg_id, td)

    elif data.startswith("shop_new_grp:"):
        td["new_group_id"] = int(data.split(":")[1])
        _ask_new_plan_color(chat_id, msg_id, td)

    elif data.startswith("shop_new_color:"):
        td["new_style"] = data.split(":")[1]
        _ask_new_plan_emoji(uid, chat_id, msg_id, td)

    elif data == "shop_list_plans":
        show_plans_list_admin(chat_id, msg_id)

    elif data.startswith("edit_plan:"):
        show_plan_edit_menu(chat_id, msg_id, int(data.split(":")[1]))

    elif data.startswith("edit_plan_field:"):
        _, plan_id_s, field = data.split(":", 2)
        plan_id = int(plan_id_s)
        if field == "panel":
            kb = types.InlineKeyboardMarkup()
            for panel in get_all_server_panels():
                kb.add(make_inline_button(text=str(panel[1]),
                                          callback_data=f"edit_plan_panel:{plan_id}:{panel[0]}",
                                          btn_id="server_title", style="none"))
            kb.add(make_inline_button(text="انصراف", callback_data=f"edit_plan:{plan_id}"))
            update_message_safe(chat_id, msg_id, "سرور جدید این پلن را انتخاب کنید:", reply_markup=kb)
        elif field in ("name", "gb", "days", "price", "description"):
            prompts = {
                "name": "نام جدید پلن را بفرستید:",
                "gb": "حجم جدید را به گیگابایت بفرستید (۰ یعنی نامحدود):",
                "days": "مدت جدید را به روز بفرستید (۰ یعنی نامحدود):",
                "price": "قیمت جدید را به تومان بفرستید:",
                "description": "توضیحات جدید را بفرستید؛ ایموجی پریمیوم و قالب‌بندی تلگرام پشتیبانی می‌شود. برای پاک کردن، ۰ را بفرستید:",
            }
            ask_state(f"waiting_plan_edit_{field}", prompts[field], f"edit_plan:{plan_id}",
                      {"edit_plan_id": plan_id})
        else:
            safe_answer(call, "این گزینه معتبر نیست.", True)

    elif data.startswith("edit_plan_panel:"):
        _, plan_id_s, panel_id_s = data.split(":")
        dbx("UPDATE shop_plans SET panel_id = ? WHERE id = ?", (int(panel_id_s), int(plan_id_s)))
        safe_answer(call, "سرور پلن به‌روزرسانی شد ✅")
        show_plan_edit_menu(chat_id, msg_id, int(plan_id_s))

    elif data.startswith("del_plan:"):
        dbx("DELETE FROM shop_plans WHERE id = ?", (int(data.split(":")[1]),))
        safe_answer(call, "پلن حذف شد.")
        show_plans_list_admin(chat_id, msg_id)

    elif data.startswith("noop_"):
        safe_answer(call)

    # ---------- خرید کاربر ----------
    elif data == "btn_buy":
        show_shop_user_menu(chat_id, msg_id, uid)

    elif data.startswith("ugrp:"):
        show_shop_user_menu(chat_id, msg_id, uid, parse_int(data.split(":", 1)[1]) or 0)

    elif data.startswith("buy_custom:"):
        gid = parse_int(data.split(":", 1)[1]) or 0
        grp = get_plan_group(gid)
        if not grp or not grp[3] or int(grp[4] or 0) <= 0:
            safe_answer(call, "حجم دلخواه برای این گروه فعال نیست.", True)
            return
        days_txt = f"{grp[7]} روز" if int(grp[7] or 0) > 0 else "نامحدود"
        ask_state("buy_custom_gb",
                  f"<b>✏️ حجم دلخواه</b>\n\n💰 قیمت هر گیگابایت: <b>{int(grp[4]):,} تومان</b>\n"
                  f"📉 حداقل: <b>{grp[5]}</b> گیگابایت\n📈 حداکثر: <b>{grp[6]}</b> گیگابایت\n"
                  f"⏳ مدت: <b>{days_txt}</b>\n\nحجم موردنظر را به گیگابایت (فقط عدد) بفرستید:",
                  f"ugrp:{gid}", {"custom_group": gid})

    # ---------- نمایندگی ----------
    elif data == "btn_agency":
        start_agency_link(chat_id, msg_id, uid)

    # ---------- کیف پول ----------
    elif data == "btn_wallet":
        show_wallet(chat_id, msg_id, uid)

    elif data == "wallet_charge":
        user_states[uid] = "wallet_charge_amount"
        user_temp_data[uid] = {"last_msg_id": msg_id}
        update_message_safe(chat_id, msg_id, get_setting("wallet_amount_text"),
                            reply_markup=cancel_kb("btn_wallet"),
                            entities=parse_db_entities(get_setting("wallet_amount_entities")))

    elif data.startswith(("adm_order_wc_ok:", "adm_order_wc_no:")):
        approve = data.startswith("adm_order_wc_ok:")
        cid = int(data.split(":")[1])
        row = dbx("SELECT user_id, amount, status FROM wallet_charges WHERE id = ?", (cid,), "one")
        if not row:
            safe_answer(call, "درخواست پیدا نشد.", True)
            return
        if row[2] != "pending":
            safe_answer(call, "این درخواست قبلاً بررسی شده است.", True)
            return
        if approve:
            ok, new_bal = apply_wallet_change(row[0], uid, row[1], "add", "شارژ با رسید")
            if not ok:
                safe_answer(call, "شارژ انجام نشد (کاربر پیدا نشد).", True)
                return
            dbx("UPDATE wallet_charges SET status = 'approved' WHERE id = ?", (cid,))
            send_message_safe(row[0], f"✅ شارژ کیف پول شما به مبلغ <b>{int(row[1]):,} تومان</b> تأیید شد.\n"
                                      f"موجودی فعلی: <b>{int(new_bal):,} تومان</b>")
        else:
            dbx("UPDATE wallet_charges SET status = 'rejected' WHERE id = ?", (cid,))
            send_message_safe(row[0], "❌ رسید شارژ کیف پول شما تأیید نشد. در صورت اشتباه با پشتیبانی تماس بگیرید.")
        safe_answer(call, "انجام شد ✅")
        cap = wallet_charge_caption(cid)
        try:
            bot.edit_message_caption(caption=cap, chat_id=chat_id, message_id=msg_id, parse_mode="HTML")
        except Exception:
            try:
                bot.edit_message_text(text=cap, chat_id=chat_id, message_id=msg_id, parse_mode="HTML")
            except Exception:
                pass

    # ---------- مدیریت گروه‌های پلن (ادمین) ----------
    elif data == "shop_groups":
        show_groups_admin(chat_id, msg_id)

    elif data == "shop_btn_price_toggle":
        set_setting("shop_btn_price", "0" if get_setting("shop_btn_price") == "1" else "1")
        show_shop_settings_menu(chat_id, msg_id)

    elif data == "shop_grp_add":
        ask_state("shop_grp_new", "نام گروه جدید را بفرستید (مثال: <code>پکیج‌های حجم ثابت</code>):", "shop_groups")

    elif data.startswith("shop_grp_edit:"):
        show_group_edit_menu(chat_id, msg_id, int(data.split(":")[1]))

    elif data.startswith("shop_grp_field:"):
        _, gid_s, field = data.split(":", 2)
        gid = int(gid_s)
        if field == "panel":
            kb = types.InlineKeyboardMarkup()
            kb.add(make_inline_button(text="اولین سرور ثبت‌شده", callback_data=f"shop_grp_panel:{gid}:0",
                                      style="none"))
            for panel in get_all_server_panels():
                kb.add(make_inline_button(text=str(panel[1]), callback_data=f"shop_grp_panel:{gid}:{panel[0]}",
                                          btn_id="server_title", style="none"))
            kb.add(make_inline_button(text="انصراف", callback_data=f"shop_grp_edit:{gid}"))
            update_message_safe(chat_id, msg_id, "حجم دلخواه روی کدام سرور ساخته شود؟", reply_markup=kb)
        else:
            prompts = {
                "name": "نام جدید گروه را بفرستید:",
                "header": "متن بالای صفحه این گروه را بفرستید (برای برگشت به متن پیش‌فرض ۰ بفرستید):",
                "emoji": "ایموجی پریمیوم را بفرستید (یا آیدی عددی آن). برای حذف ۰ بفرستید:",
                "price": "قیمت هر گیگابایت حجم دلخواه را به تومان بفرستید:",
                "days": "مدت اعتبار حجم دلخواه را به روز بفرستید (۰ یعنی نامحدود):",
                "min": "حداقل حجم قابل خرید را به گیگابایت بفرستید:",
                "max": "حداکثر حجم قابل خرید را به گیگابایت بفرستید:",
            }
            if field not in prompts:
                safe_answer(call, "این گزینه معتبر نیست.", True)
                return
            ask_state("shop_grp_edit_field", prompts[field], f"shop_grp_edit:{gid}",
                      {"grp_id": gid, "grp_field": field})

    elif data.startswith("shop_grp_panel:"):
        _, gid_s, pid_s = data.split(":")
        dbx("UPDATE plan_groups SET custom_panel_id = ? WHERE id = ?", (int(pid_s), int(gid_s)))
        safe_answer(call, "سرور ذخیره شد ✅")
        show_group_edit_menu(chat_id, msg_id, int(gid_s))

    elif data == "shop_noop":
        safe_answer(call)

    elif data.startswith("shop_grp_order:"):
        show_group_order_menu(chat_id, msg_id, int(data.split(":")[1]))

    elif data.startswith("shop_plan_move:"):
        _, _gid, _pid, _dir = data.split(":")
        move_in_order("shop_plans", [p[0] for p in get_plans_in_group(int(_gid))], int(_pid), _dir)
        show_group_order_menu(chat_id, msg_id, int(_gid))

    elif data.startswith("shop_grp_move:"):
        _, _gid, _dir = data.split(":")
        move_in_order("plan_groups", [g_[0] for g_ in get_plan_groups()], int(_gid), _dir)
        show_groups_admin(chat_id, msg_id)

    elif data.startswith("shop_grp_cols:"):
        _gid = int(data.split(":")[1])
        dbx("UPDATE plan_groups SET cols = ? WHERE id = ?", (1 if group_cols(_gid) == 2 else 2, _gid))
        show_group_edit_menu(chat_id, msg_id, _gid)

    elif data.startswith("shop_grp_toggle:"):
        gid = int(data.split(":")[1])
        grp = get_plan_group(gid)
        if not grp:
            show_groups_admin(chat_id, msg_id)
            return
        if not grp[3] and int(grp[4] or 0) <= 0:
            safe_answer(call, "اول قیمت هر گیگابایت را تنظیم کنید.", True)
            return
        dbx("UPDATE plan_groups SET custom_enabled = ? WHERE id = ?", (0 if grp[3] else 1, gid))
        show_group_edit_menu(chat_id, msg_id, gid)

    elif data.startswith("shop_grp_color:"):
        gid = int(data.split(":")[1])
        kb = types.InlineKeyboardMarkup()
        for val, name in BTN_STYLE_CHOICES:
            kb.add(make_inline_button(text=name, callback_data=f"shop_grp_setcolor:{gid}:{val}", keep_emoji=True))
        kb.add(make_inline_button(text="انصراف", callback_data=f"shop_grp_edit:{gid}"))
        update_message_safe(chat_id, msg_id, "رنگ دکمه این گروه را انتخاب کنید:", reply_markup=kb)

    elif data.startswith("shop_grp_setcolor:"):
        _, gid_s, val = data.split(":")
        if val in dict(BTN_STYLE_CHOICES):
            dbx("UPDATE plan_groups SET btn_style = ? WHERE id = ?", (val, int(gid_s)))
        safe_answer(call, "رنگ ذخیره شد ✅")
        show_group_edit_menu(chat_id, msg_id, int(gid_s))

    elif data.startswith("shop_grp_del:"):
        gid = int(data.split(":")[1])
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="بله، حذف شود", callback_data=f"shop_grp_del_do:{gid}", style="danger"),
               make_inline_button(text="انصراف", callback_data="shop_groups"))
        update_message_safe(chat_id, msg_id,
                            "این گروه حذف شود؟ پلن‌های داخلش حذف نمی‌شوند و «بدون گروه» می‌شوند.", reply_markup=kb)

    elif data.startswith("shop_grp_del_do:"):
        gid = int(data.split(":")[1])
        dbx("UPDATE shop_plans SET group_id = 0 WHERE COALESCE(group_id, 0) = ?", (gid,))
        dbx("DELETE FROM plan_groups WHERE id = ?", (gid,))
        set_setting(f"emoji_grp_{gid}", "")
        safe_answer(call, "گروه حذف شد.")
        show_groups_admin(chat_id, msg_id)

    # ---------- گروه/رنگ/ایموجی هر پلن (ادمین) ----------
    elif data.startswith("edit_plan_grp:"):
        pid = int(data.split(":")[1])
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text="بدون گروه", callback_data=f"edit_plan_setgrp:{pid}:0", style="none"))
        for g in get_plan_groups():
            kb.add(make_inline_button(text=f"📁 {g[1]}", callback_data=f"edit_plan_setgrp:{pid}:{g[0]}",
                                      style="none", keep_emoji=True))
        kb.add(make_inline_button(text="انصراف", callback_data=f"edit_plan:{pid}"))
        update_message_safe(chat_id, msg_id, "این پلن در کدام گروه نمایش داده شود؟", reply_markup=kb)

    elif data.startswith("edit_plan_setgrp:"):
        _, pid_s, gid_s = data.split(":")
        dbx("UPDATE shop_plans SET group_id = ? WHERE id = ?", (int(gid_s), int(pid_s)))
        safe_answer(call, "گروه پلن ذخیره شد ✅")
        show_plan_edit_menu(chat_id, msg_id, int(pid_s))

    elif data.startswith("edit_plan_color:"):
        pid = int(data.split(":")[1])
        kb = types.InlineKeyboardMarkup()
        for val, name in BTN_STYLE_CHOICES:
            kb.add(make_inline_button(text=name, callback_data=f"edit_plan_setcolor:{pid}:{val}", keep_emoji=True))
        kb.add(make_inline_button(text="انصراف", callback_data=f"edit_plan:{pid}"))
        update_message_safe(chat_id, msg_id, "رنگ دکمه این پلن را انتخاب کنید:", reply_markup=kb)

    elif data.startswith("edit_plan_setcolor:"):
        _, pid_s, val = data.split(":")
        if val in dict(BTN_STYLE_CHOICES):
            dbx("UPDATE shop_plans SET btn_style = ? WHERE id = ?", (val, int(pid_s)))
        safe_answer(call, "رنگ ذخیره شد ✅")
        show_plan_edit_menu(chat_id, msg_id, int(pid_s))

    elif data.startswith("edit_plan_emoji:"):
        pid = int(data.split(":")[1])
        ask_state("plan_emoji_edit", "ایموجی پریمیوم این پلن را بفرستید (یا آیدی عددی آن). برای حذف ۰ بفرستید:",
                  f"edit_plan:{pid}", {"edit_plan_id": pid})

    elif data.startswith("buy_wallet_pay:"):
        payment_token = data.split(":", 1)[1]
        if not re.fullmatch(r"[0-9a-f]{12}", payment_token):
            safe_answer(call, "درخواست پرداخت معتبر نیست.", True)
            return
        draft = dbx("SELECT plan_id, username, password, renew_panel_id FROM buy_drafts "
                    "WHERE user_id = ? AND payment_token = ?", (uid, payment_token), "one")
        if draft:
            user_temp_data.setdefault(uid, {}).update({
                "last_msg_id": msg_id, "plan_id": draft[0], "custom_username": draft[1],
                "custom_password": draft[2], "renew_panel_id": draft[3] or 0})
        safe_answer(call, "در حال انجام پرداخت با کیف پول...")
        update_message_safe(chat_id, msg_id, "<b>در حال پرداخت و آماده‌سازی سرویس...</b>")
        ok, result = process_wallet_payment(chat_id, msg_id, uid, payment_token)
        if not ok:
            draft_status = dbx("SELECT payment_status FROM buy_drafts "
                               "WHERE user_id = ? AND payment_token = ?", (uid, payment_token), "one")
            if draft_status and draft_status[0] == "ready":
                show_payment_and_receipt_step(chat_id, msg_id, uid)
            send_message_safe(chat_id, f"⚠️ {html.escape(str(result))}")

    elif data.startswith("buy_plan_sel:"):
        show_plan_detail(chat_id, msg_id, uid, int(data.split(":")[1]))

    elif data.startswith("renew_panel:"):
        show_renewal_plans(chat_id, msg_id, uid, int(data.split(":")[1]))

    elif data.startswith("renew_plan_sel:"):
        td.pop("discount", None)
        _, panel_id_s, plan_id_s = data.split(":")
        panel_id, plan_id = int(panel_id_s), int(plan_id_s)
        service = dbx("SELECT panel_id FROM user_panels WHERE id = ? AND user_id = ?", (panel_id, uid), "one")
        plan = get_shop_plan_by_id(plan_id)
        if not service or not plan or not renew_plan_allowed(plan, service[0]):
            safe_answer(call, "سرویس یا پلن تمدید معتبر نیست.", True)
            return
        p_name, gb, days, price = plan[1], plan[2], plan[3], plan[4]
        txt = (f"<b>تأیید تمدید سرویس</b>\n\n"
               f"پلن: <b>{html.escape(str(p_name))}</b>\n"
               f"حجم افزوده: <b>{gb if gb else 'نامحدود'} گیگابایت</b>\n"
               f"مدت افزوده: <b>{days if days else 'نامحدود'} روز</b>\n"
               f"هزینه: <b>{price_str(price)}</b>\n\n"
               "در صورت تأیید، پلن روی همان سرویس اعمال می‌شود.")
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text="تأیید تمدید", callback_data=f"renew_confirm:{panel_id}:{plan_id}",
                                  style="success"))
        kb.add(make_inline_button(text="بازگشت", callback_data=f"renew_panel:{panel_id}"))
        update_message_safe(chat_id, msg_id, txt, reply_markup=kb)

    elif data.startswith("renew_confirm:"):
        _, panel_id_s, plan_id_s = data.split(":")
        panel_id, plan_id = int(panel_id_s), int(plan_id_s)
        plan = get_shop_plan_by_id(plan_id)
        service = dbx("SELECT panel_id FROM user_panels WHERE id = ? AND user_id = ?", (panel_id, uid), "one")
        if not service or not plan or not renew_plan_allowed(plan, service[0]):
            safe_answer(call, "سرویس یا پلن تمدید معتبر نیست.", True)
            return
        if (plan[4] or 0) > 0:
            user_temp_data[uid] = {"last_msg_id": msg_id, "plan_id": plan_id,
                                   "renew_panel_id": panel_id}
            show_payment_and_receipt_step(chat_id, msg_id, uid)
        else:
            safe_answer(call, "در حال تمدید سرویس...")
            ok, result = fulfill_renewal(uid, panel_id, plan)
            if not ok:
                update_message_safe(chat_id, msg_id, f"<b>تمدید انجام نشد:</b>\n<code>{html.escape(str(result))}</code>",
                                    reply_markup=cancel_kb(f"renew_panel:{panel_id}"))
                return
            user_states.pop(uid, None)
            user_temp_data.pop(uid, None)
            update_message_safe(chat_id, msg_id, result, reply_markup=cancel_kb(f"show_panel:{panel_id}"))

    elif data.startswith("buy_confirm_plan:"):
        pid = int(data.split(":")[1])
        clear_draft(uid)
        _auto_u = build_shared_username(uid)
        if _auto_u:
            user_states.pop(uid, None)
            user_temp_data[uid] = {"last_msg_id": msg_id, "plan_id": pid, "custom_username": _auto_u}
            proceed_after_credentials(chat_id, msg_id, uid)
            return
        ask_state("buy_wait_username", "<b>مرحله ۱: انتخاب نام کاربری</b>\n\nیک نام کاربری دلخواه انگلیسی "
                                       "(حروف، عدد و _ ؛ ۳ تا ۳۲ کاراکتر) ارسال کنید:", "back_to_main",
                  {"plan_id": pid})

    elif data == "buy_use_suggested_pass":
        spass = td.get("suggested_pass") or generate_strong_password_32(32)
        td["custom_password"] = spass
        td["last_msg_id"] = msg_id
        proceed_after_credentials(chat_id, msg_id, uid)

    # تایید یا رد سفارش توسط مالک یا ادمین‌ها
    elif data.startswith("adm_order_approve:"):
        safe_answer(call, "در حال بررسی و ساخت اکانت...")
        process_order_approval(chat_id, msg_id, int(data.split(":")[1]), uid)

    elif data.startswith("adm_order_reject:"):
        order_id = int(data.split(":")[1])
        row = dbx("SELECT user_id, status FROM orders WHERE id = ?", (order_id,), "one")
        if row and row[1] == "pending":
            dbx("UPDATE orders SET status = 'rejected' WHERE id = ?", (order_id,))
            # متن و ایموجی پریمیوم تنظیم‌شده برای رد رسید
            rej_txt = get_setting("order_rejected_text")
            rej_entities = parse_db_entities(get_setting("order_rejected_entities"))
            send_message_safe(row[0], rej_txt, entities=rej_entities)
            safe_answer(call, "سفارش رد شد.")
            edit_admin_msg(chat_id, msg_id, f"سفارش شماره <code>#{order_id}</code> توسط شما رد شد ❌ و پنلی ساخته نشد.")
        else:
            safe_answer(call, "این سفارش قبلاً تعیین وضعیت شده است.", True)

    # ---------- تست ----------
    elif data == "btn_test":
        show_test_menu(chat_id, msg_id)

    elif data == "test_shared":
        if get_setting("test_shared_enabled") == "0":
            safe_answer(call, "تست اشتراکی در حال حاضر غیرفعال است.", True)
            show_test_menu(chat_id, msg_id)
            return
        if not can_get_test(uid) or not claim_test_slot(uid):
            safe_answer(call, "شما قبلاً تست خود را دریافت کرده‌اید!", True)
            return
        sh_mb = float(get_setting("test_sh_traffic_mb") or 512)
        sh_hr = float(get_setting("test_sh_time_hours") or 12)
        name = build_shared_username(uid) or ("sh_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=5)))
        update_message_safe(chat_id, msg_id, "<b>در حال ایجاد تست اشتراکی روی پنل سرور...</b>")
        ok, result = create_pasarguard_user(name, sh_mb, sh_hr)
        if not ok:
            release_test_slot(uid, False)
            update_message_safe(chat_id, msg_id, f"<b>خطا در ساخت تست اشتراکی:</b>\n<code>{html.escape(str(result))}</code>",
                                reply_markup=get_back_keyboard())
            return
        final_uname, sub_link, server_pid = result
        save_user_panel(uid, final_uname, final_uname, "", sub_link, True, server_pid, "user",
                        int(time.time() + sh_hr * 3600))
        release_test_slot(uid, True)
        txt = account_text("<b>سرویس تست اشتراکی ساخته شد:</b>",
                           [("حجم", format_mb_display(sh_mb)), ("اعتبار", f"{int(sh_hr)} ساعت")],
                           final_uname, "", sub_link, "user")
        update_message_safe(chat_id, msg_id, txt, reply_markup=my_panels_kb())
        user_row = dbx("SELECT username FROM users WHERE user_id = ?", (uid,), "one")
        tg_username = user_row[0] if user_row else ""
        send_report(
            f"🧪 <b>تست اشتراکی دریافت شد</b>\n\n"
            f"👤 <b>کاربر:</b> <a href=\"tg://user?id={uid}\">{uid}</a>\n"
            f"🔗 <b>یوزرنیم:</b> {('@' + html.escape(tg_username)) if tg_username else 'ندارد'}\n"
            f"🔢 <b>آیدی عددی:</b> <code>{uid}</code>\n"
            f"🖥 <b>نام سرویس:</b> {html.escape(str(final_uname))}",
            user_id=uid, username=tg_username, topic="tests")

    elif data == "test_panel_mode":
        if get_setting("test_panel_enabled") == "0":
            safe_answer(call, "تست پنل در حال حاضر غیرفعال است.", True)
            show_test_menu(chat_id, msg_id)
            return
        if not can_get_test(uid):
            safe_answer(call, "شما قبلاً سرویس تست خود را دریافت کرده‌اید!", True)
            return
        ask_state("waiting_panel_username", "<b>تست پنل:</b>\n\nیک نام کاربری انگلیسی دلخواه وارد کنید:", "back_to_main")

    elif data == "use_suggested_32_pass":
        spass = td.get("suggested_pass") or generate_strong_password_32(32)
        create_test_account(chat_id, msg_id, uid, td.get("username", "testuser"), spass)

    # ---------- پنل‌های من ----------
    elif data == "btn_my_panels":
        show_my_panels(chat_id, msg_id, uid)

    elif data.startswith("show_panel:"):
        show_user_panel_detail(chat_id, msg_id, uid, int(data.split(":")[1]), reveal=False)

    elif data.startswith("pnl_refresh:"):
        _pid = int(data.split(":")[1])
        ok, err = refresh_panel_status(uid, _pid)
        safe_answer(call, "بروزرسانی شد ✅" if ok else f"⚠️ بروزرسانی کامل نشد: {err}", not ok)
        show_user_panel_detail(chat_id, msg_id, uid, _pid, reveal=False, stamp=True)

    elif data.startswith("pnl_pw:"):
        _, pid_s, flag = data.split(":")
        show_user_panel_detail(chat_id, msg_id, uid, int(pid_s), reveal=(flag == "1"))

    elif data.startswith("pnl_toggle:"):
        pid = int(data.split(":")[1])
        row = dbx("SELECT username, panel_id, kind, is_disabled, password FROM user_panels "
                  "WHERE id = ? AND user_id = ?", (pid, uid), "one")
        if not row:
            safe_answer(call, "سرویس یافت نشد.", True)
            return
        uname_t, spid_t, kind_t, dis_t, real_pw = row
        want_disabled = not bool(dis_t)
        safe_answer(call, "در حال اعمال تغییر...")
        if kind_t == "admin":
            # غیرفعال‌سازی واقعی: رمز پنل با رمز تصادفی عوض می‌شود تا ورود ممکن نباشد
            if want_disabled:
                ok, err = lock_admin_panel(uname_t, spid_t, pid)
            else:
                ok, err = unlock_admin_panel(uname_t, spid_t, real_pw, pid)
        else:
            ok, err = set_pasarguard_user_status(uname_t, not want_disabled, spid_t)
            if ok:
                dbx("UPDATE user_panels SET is_disabled = ? WHERE id = ?", (1 if want_disabled else 0, pid))
        if not ok:
            update_message_safe(chat_id, msg_id, f"<b>خطا در تغییر وضعیت:</b>\n<code>{html.escape(str(err))}</code>",
                                reply_markup=cancel_kb(f"show_panel:{pid}"))
            return
        show_user_panel_detail(chat_id, msg_id, uid, pid)

    elif data.startswith("pnl_chpw_sug:"):
        pid = int(data.split(":")[1])
        spass = td.get("suggested_pass") or generate_strong_password_32(32)
        apply_panel_password(chat_id, msg_id, uid, pid, spass)

    elif data.startswith("pnl_chpw:"):
        pid = int(data.split(":")[1])
        row = dbx("SELECT kind FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
        if not row or row[0] != "admin":
            safe_answer(call, "این گزینه فقط برای پنل‌های ادمین است.", True)
            return
        spass = generate_strong_password_32(32)
        user_states[uid] = "pnl_wait_pw"
        user_temp_data[uid] = {"last_msg_id": msg_id, "pnl_id": pid, "suggested_pass": spass}
        update_message_safe(chat_id, msg_id,
                            "<b>تغییر رمز عبور پنل</b>\n\nرمز پیشنهادی قدرتمند:\n"
                            f"<code>{html.escape(spass)}</code>\n\n"
                            "می‌توانید دکمه زیر را بزنید یا یک رمز حداقل ۱۵ کاراکتری "
                            "(شامل حروف بزرگ، کوچک، عدد و علائم) تایپ کنید:",
                            reply_markup=pw_change_kb(pid))

    elif data.startswith("pnl_del_ask:"):
        pid = int(data.split(":")[1])
        row = dbx("SELECT name FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
        if not row:
            safe_answer(call, "سرویس یافت نشد.", True)
            return
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="✅ بله، حذف شود", callback_data=f"pnl_del_do:{pid}",
                                  btn_id="pnl_del_yes", style="danger", keep_emoji=True),
               make_inline_button(text="انصراف", callback_data=f"show_panel:{pid}", btn_id="pnl_back"))
        update_message_safe(chat_id, msg_id,
                            f"<b>حذف سرویس «{html.escape(str(row[0]))}»</b>\n\n"
                            "با حذف سرویس، <b>اکانت از سرور هم پاک می‌شود و قابل بازگشت نیست</b>.\n\n"
                            "مطمئن هستید؟", reply_markup=kb)

    elif data.startswith("pnl_del_do:"):
        pid = int(data.split(":")[1])
        row = dbx("SELECT username, panel_id, kind FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
        if not row:
            safe_answer(call, "سرویس یافت نشد.", True)
            return
        uname_d, spid_d, kind_d = row
        safe_answer(call, "در حال حذف سرویس...")
        if kind_d == "admin":
            ok_d = delete_pasarguard_admin(uname_d, spid_d)
        else:
            ok_d = delete_pasarguard_user(uname_d, spid_d)
        if not ok_d:
            update_message_safe(chat_id, msg_id,
                                "<b>خطا در حذف سرویس از سرور.</b>\nلطفاً کمی بعد دوباره تلاش کنید یا با پشتیبانی تماس بگیرید.",
                                reply_markup=cancel_kb(f"show_panel:{pid}"))
            return
        dbx("DELETE FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid))
        safe_answer(call, "سرویس حذف شد ✅")
        show_my_panels(chat_id, msg_id, uid)

    elif data.startswith("pnl_rev_ask:"):
        pid = int(data.split(":")[1])
        if not dbx("SELECT 1 FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one"):
            safe_answer(call, "سرویس یافت نشد.", True)
            return
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="بله، لینک جدید بساز", callback_data=f"pnl_rev_do:{pid}", style="danger"),
               make_inline_button(text="انصراف", callback_data=f"show_panel:{pid}"))
        update_message_safe(chat_id, msg_id,
                            "<b>تغییر لینک اشتراک</b>\n\nبا ساخت لینک جدید، <b>لینک قبلی بلافاصله از کار می‌افتد</b> "
                            "و باید لینک جدید را در برنامه خود وارد کنید.\n\nادامه می‌دهید؟", reply_markup=kb)

    elif data.startswith("pnl_rev_do:"):
        pid = int(data.split(":")[1])
        row = dbx("SELECT username, panel_id, kind FROM user_panels WHERE id = ? AND user_id = ?", (pid, uid), "one")
        if not row or row[2] == "admin":
            safe_answer(call, "سرویس یافت نشد.", True)
            return
        safe_answer(call, "در حال ساخت لینک جدید...")
        ok, res = revoke_pasarguard_sub(row[0], row[1])
        if not ok:
            update_message_safe(chat_id, msg_id, f"<b>خطا در تغییر لینک:</b>\n<code>{html.escape(str(res))}</code>",
                                reply_markup=cancel_kb(f"show_panel:{pid}"))
            return
        dbx("UPDATE user_panels SET url = ? WHERE id = ?", (res, pid))
        show_user_panel_detail(chat_id, msg_id, uid, pid)
        send_message_safe(chat_id, "✅ <b>لینک اشتراک جدید ساخته شد و لینک قبلی غیرفعال شد.</b>\n\n"
                                   f"<code>{html.escape(str(res))}</code>")

    elif data == "btn_support":
        show_support(chat_id, msg_id)

    # ---------- پنل مدیریت ----------
    elif data == "adm_toggle_bot_power":
        curr = get_setting("bot_status")
        new_val = "off" if curr != "off" else "on"
        set_setting("bot_status", new_val)
        safe_answer(call, f"ربات {'خاموش شد 🔴' if new_val == 'off' else 'روشن شد 🟢'}")
        show_admin_panel(chat_id, msg_id, uid)

    elif data == "adm_su_menu":
        show_shared_username_menu(chat_id, msg_id)

    elif data.startswith("adm_su_mode:"):
        m = data.split(":", 1)[1]
        if m in SU_MODES:
            set_setting("su_mode", m)
        show_shared_username_menu(chat_id, msg_id)

    elif data == "adm_su_prefix":
        ask_state("waiting_su_prefix", "پیشوند نام کاربری را بفرستید (فقط a-z، عدد و _ ).\nبرای حذف، <code>0</code> بفرستید:",
                  "adm_su_menu")

    elif data == "adm_su_len":
        ask_state("waiting_su_len", "طول بخش تصادفی را بفرستید (عدد بین ۳ تا ۱۶):", "adm_su_menu")

    elif data == "adm_su_pattern":
        ask_state("waiting_su_pattern",
                  "الگو را بفرستید. مثال: <code>{prefix}{username}_{rand}</code>\n"
                  "متغیرها: <code>{id}</code> <code>{username}</code> <code>{rand}</code> <code>{n}</code> <code>{prefix}</code>",
                  "adm_su_menu")

    elif data == "adm_toggle_mode":
        curr = get_account_mode()
        new_mode = "user" if curr == "admin" else "admin"
        set_setting("account_mode", new_mode)
        safe_answer(call, f"حالت ساخت اکانت به «{'پنل ادمین' if new_mode == 'admin' else 'اشتراک'}» تغییر کرد.")
        show_admin_panel(chat_id, msg_id, uid)

    elif data == "set_role_id":
        curr_role = get_setting("admin_role_id") or "تنظیم نشده (جستجوی خودکار)"
        ask_state("waiting_admin_role_id",
                  f"<b>تنظیم Role ID برای ساخت اکانت‌های ادمین</b>\n\n"
                  f"مقدار فعلی: <code>{curr_role}</code>\n\n"
                  "اگر پنل شما به role_id نیاز دارد، عدد آن را بفرستید.\n"
                  "برای حذف و برگشت به حالت خودکار، عدد <code>0</code> را ارسال کنید:", "admin_panel")

    elif data == "set_expiry_cleanup":
        ask_state("waiting_expiry_cleanup",
                  "چند دقیقه پس از اخطار، سرویس منقضی از پنل حذف شود؟ عددی بین ۱ تا ۱۴۴۰ وارد کنید:",
                  "admin_panel")

    elif data == "adm_pending_orders":
        send_pending_orders(chat_id)
        safe_answer(call)

    elif data == "adm_layout":
        show_layout_editor(chat_id, msg_id)

    elif data == "adm_layout_mode":
        set_setting("keyboard_mode", "inline" if kb_mode() == "reply" else "reply")
        safe_answer(call, "حالت دکمه‌ها تغییر کرد؛ کاربران با /start حالت جدید را می‌بینند ✅", True)
        show_layout_editor(chat_id, msg_id)

    elif data == "adm_button_visibility":
        show_button_visibility_menu(chat_id, msg_id)

    elif data.startswith("toggle_visible_btn:"):
        b_id = data.split(":", 1)[1]
        if b_id not in MAIN_BTN_IDS:
            safe_answer(call, "این دکمه معتبر نیست.", True)
            return
        try:
            visible = set(json.loads(get_setting("visible_main_buttons") or "[]"))
        except Exception:
            visible = set(MAIN_BTN_IDS)
        if b_id in visible:
            visible.remove(b_id)
        else:
            visible.add(b_id)
        set_setting("visible_main_buttons", json.dumps(sorted(visible)))
        safe_answer(call, "وضعیت نمایش دکمه تغییر کرد ✅")
        show_button_visibility_menu(chat_id, msg_id)

    elif data == "adm_commands":
        show_command_settings_menu(chat_id, msg_id)

    elif data == "adm_commands_miniapp":
        ask_state("waiting_miniapp_url",
                  "لینک https مینی‌اپ را بفرستید (مثلاً https://example.com/)؛ برای پاک کردن ۰ را بفرستید:",
                  "adm_commands")

    elif data.startswith("edit_command:"):
        b_id = data.split(":", 1)[1]
        if b_id not in [item[0] for item in COMMAND_BUTTONS]:
            safe_answer(call, "دکمه معتبر نیست.", True)
            return
        ask_state(f"waiting_command_{b_id}", f"نام کامند جدید برای «{get_button_display(b_id)}» را بدون / بفرستید؛ برای پاک کردن ۰ را بفرستید:",
                  "adm_commands", {"command_button": b_id})

    elif data.startswith("set_pos_menu:"):
        b_id = data.split(":")[1]
        kb = types.InlineKeyboardMarkup()
        for p_key, p_name in POS_NAMES.items():
            kb.add(make_inline_button(text=p_name, callback_data=f"apply_pos:{b_id}:{p_key}"))
        kb.add(make_inline_button(text="انصراف", callback_data="adm_layout"))
        update_message_safe(chat_id, msg_id, f"جایگاه جدید را برای <b>{get_button_display(b_id)}</b> انتخاب کنید:",
                            reply_markup=kb)

    elif data.startswith("apply_pos:"):
        _, b_id, p_key = data.split(":")
        if b_id not in MAIN_BTN_IDS or p_key not in POS_NAMES:
            safe_answer(call, "نامعتبر", True)
            return
        try:
            positions = json.loads(get_setting("btn_positions"))
        except Exception:
            positions = {}
        old_pos = positions.get(b_id)
        if p_key in PAIR_SLOTS:
            for k, v in list(positions.items()):
                if k != b_id and v == p_key:  # جابه‌جایی با دکمه‌ای که آنجاست
                    positions[k] = old_pos if old_pos and old_pos != p_key else "bot_single"
        positions.pop(b_id, None)
        positions[b_id] = p_key  # آخرِ همان جایگاه (برای تغییر ترتیب دکمه‌های تکی)
        set_setting("btn_positions", json.dumps(positions))
        safe_answer(call, "جایگاه تغییر یافت ✅")
        show_layout_editor(chat_id, msg_id)

    elif data == "adm_test_plans":
        show_test_plans_menu(chat_id, msg_id)

    elif data.startswith("toggle_test_type:"):
        test_type = data.split(":", 1)[1]
        setting_key = {"panel": "test_panel_enabled", "shared": "test_shared_enabled"}.get(test_type)
        if not setting_key:
            safe_answer(call, "نوع تست معتبر نیست.", True)
            return
        new_value = "0" if get_setting(setting_key) != "0" else "1"
        set_setting(setting_key, new_value)
        safe_answer(call, "نوع تست غیرفعال شد." if new_value == "0" else "نوع تست فعال شد.")
        show_test_plans_menu(chat_id, msg_id)

    elif data.startswith("set_plan:"):
        pkey = data.split(":")[1]
        ask_state(f"setting_{pkey}", f"مقدار عددی جدید را وارد کنید:\n(تنظیم: <code>{pkey}</code>)", "adm_test_plans")

    elif data == "adm_card_settings":
        show_card_settings_menu(chat_id, msg_id)

    elif data == "set_card_num":
        ask_state("waiting_card_num", "شماره کارت بانکی جدید (۱۶ رقم) را وارد کنید:", "adm_card_settings")

    elif data == "set_card_holder":
        ask_state("waiting_card_holder", "نام و نام خانوادگی صاحب حساب/کارت را وارد کنید:", "adm_card_settings")

    elif data == "adm_manage_users":
        show_users_management_menu(chat_id, msg_id)

    elif data.startswith("usr_list:"):
        show_users_management_menu(chat_id, msg_id, parse_int(data.split(":", 1)[1]) or 0)

    elif data == "usr_search":
        ask_state("waiting_usr_search", "آیدی عددی یا یوزرنیم کاربر را بدون @ وارد کنید:", "adm_manage_users")

    elif data.startswith(("usr_wallet_add:", "usr_wallet_sub:")):
        action, target_s = data.split(":", 1)
        target_uid = parse_int(target_s)
        if not target_uid or not dbx("SELECT 1 FROM users WHERE user_id = ?", (target_uid,), "one"):
            safe_answer(call, "کاربر پیدا نشد.", True)
            return
        operation = "add" if action == "usr_wallet_add" else "sub"
        prompt = ("مبلغ شارژ را به تومان و به‌صورت عدد مثبت وارد کنید:"
                  if operation == "add" else
                  "مبلغ کسر از کیف پول را به تومان و به‌صورت عدد مثبت وارد کنید:")
        ask_state("waiting_usr_wallet_amount", prompt, f"usr_profile:{target_uid}",
                  {"target_user_id": target_uid, "wallet_action": operation})
        safe_answer(call)

    elif data.startswith("usr_services:"):
        _, target_s, action = data.split(":", 2)
        target_uid = parse_int(target_s)
        if not target_uid or not dbx("SELECT 1 FROM users WHERE user_id = ?", (target_uid,), "one"):
            safe_answer(call, "کاربر پیدا نشد.", True)
            return
        show_admin_services(chat_id, msg_id, target_uid, action)
        safe_answer(call)

    elif data.startswith("usr_svc_pick:"):
        _, target_s, action, service_s = data.split(":", 3)
        target_uid, service_id = parse_int(target_s), parse_int(service_s)
        service = dbx("SELECT username, panel_id, kind, name, is_disabled FROM user_panels "
                      "WHERE id = ? AND user_id = ?", (service_id, target_uid), "one")
        if not target_uid or not service:
            safe_answer(call, "سرویس پیدا نشد.", True)
            return
        if action in ("days", "traffic"):
            unit_name = "روز" if action == "days" else "گیگابایت"
            example = "+30 برای افزودن یا -7 برای کاهش" if action == "days" else "+2.5 برای افزودن یا -1 برای کاهش"
            ask_state("waiting_usr_service_adjust",
                      f"برای سرویس <b>{html.escape(str(service[3]))}</b> مقدار تغییر را با علامت وارد کنید.\n"
                      f"واحد: <b>{unit_name}</b>\nنمونه: <code>{example}</code>",
                      f"usr_profile:{target_uid}",
                      {"target_user_id": target_uid, "service_id": service_id,
                       "service_unit": action})
            safe_answer(call)
        elif action == "status":
            active = bool(service[4])
            if service[2] == "admin":
                ok, err = _modify_pasarguard_admin(service[0], {"is_disabled": not active}, service[1])
            else:
                ok, err = set_pasarguard_user_status(service[0], active, service[1])
            if not ok:
                safe_answer(call, "تغییر وضعیت سرویس انجام نشد.", True)
                update_message_safe(chat_id, msg_id,
                                    f"<b>تغییر وضعیت سرویس انجام نشد:</b>\n<code>{html.escape(str(err))}</code>",
                                    reply_markup=cancel_kb(f"usr_profile:{target_uid}"))
                return
            dbx("UPDATE user_panels SET is_disabled = ? WHERE id = ? AND user_id = ?",
                (0 if active else 1, service_id, target_uid))
            safe_answer(call, "وضعیت سرویس تغییر کرد ✅")
            notify_user(target_uid, f"🔔 سرویس <b>{html.escape(str(service[3]))}</b> توسط مدیریت "
                                    f"<b>{'فعال' if active else 'غیرفعال'}</b> شد.")
            show_user_profile_admin(chat_id, msg_id, target_uid)
        elif action == "delete":
            kb = types.InlineKeyboardMarkup()
            kb.row(make_inline_button(text="بله، سرویس حذف شود", callback_data=f"usr_svc_del_do:{target_uid}:{service_id}",
                                      style="danger"),
                   make_inline_button(text="انصراف", callback_data=f"usr_profile:{target_uid}"))
            update_message_safe(
                chat_id, msg_id,
                f"<b>حذف سرویس «{html.escape(str(service[3]))}»؟</b>\n\n"
                "این کار اکانت را از سرور و فهرست ربات حذف می‌کند و قابل بازگشت نیست.",
                reply_markup=kb)
            safe_answer(call)
        else:
            safe_answer(call, "عملیات معتبر نیست.", True)

    elif data.startswith("usr_svc_del_do:"):
        _, target_s, service_s = data.split(":", 2)
        target_uid, service_id = parse_int(target_s), parse_int(service_s)
        service = dbx("SELECT username, panel_id, kind, name FROM user_panels WHERE id = ? AND user_id = ?",
                      (service_id, target_uid), "one")
        if not target_uid or not service:
            safe_answer(call, "سرویس پیدا نشد.", True)
            return
        safe_answer(call, "در حال حذف سرویس...")
        if service[2] == "admin":
            deleted = delete_pasarguard_admin(service[0], service[1])
        else:
            deleted = delete_pasarguard_user(service[0], service[1])
        if not deleted:
            update_message_safe(chat_id, msg_id,
                                "<b>حذف سرویس از پنل انجام نشد.</b>\n"
                                "سرویس در فهرست ربات باقی ماند تا وضعیت سرور با داده‌ها هماهنگ شود.",
                                reply_markup=cancel_kb(f"usr_profile:{target_uid}"))
            return
        dbx("DELETE FROM user_panels WHERE id = ? AND user_id = ?", (service_id, target_uid))
        notify_user(target_uid, f"🗑 سرویس <b>{html.escape(str(service[3]))}</b> توسط مدیریت حذف شد.")
        show_user_profile_admin(chat_id, msg_id, target_uid)

    elif data.startswith("usr_history:"):
        target_uid = parse_int(data.split(":", 1)[1])
        show_user_order_history(chat_id, msg_id, target_uid)
        safe_answer(call)

    elif data.startswith("usr_receipt:"):
        _, target_s, order_s = data.split(":", 2)
        target_uid, order_id = parse_int(target_s), parse_int(order_s)
        receipt = dbx("SELECT file_id, file_type, amount, status FROM orders WHERE id = ? AND user_id = ?",
                      (order_id, target_uid), "one")
        if not receipt:
            safe_answer(call, "سفارش پیدا نشد.", True)
            return
        file_id, file_type, amount, status = receipt
        if not file_id:
            safe_answer(call, f"این سفارش فایل رسید ندارد؛ وضعیت: {status}", True)
            return
        caption = f"رسید سفارش #{order_id} — مبلغ ثبت‌شده: {int(amount or 0):,} تومان — وضعیت: {html.escape(str(status))}"
        try:
            if file_type == "photo":
                bot.send_photo(chat_id, file_id, caption=caption, parse_mode="HTML")
            elif file_type == "document":
                bot.send_document(chat_id, file_id, caption=caption, parse_mode="HTML")
            else:
                safe_answer(call, "نوع فایل رسید پشتیبانی نمی‌شود.", True)
                return
            safe_answer(call, "رسید در همین چت ارسال شد.")
        except Exception as e:
            safe_answer(call, f"ارسال رسید ناموفق بود: {str(e)[:150]}", True)

    elif data.startswith("usr_message:"):
        target_uid = parse_int(data.split(":", 1)[1])
        if not target_uid or not dbx("SELECT 1 FROM users WHERE user_id = ?", (target_uid,), "one"):
            safe_answer(call, "کاربر پیدا نشد.", True)
            return
        ask_state("waiting_usr_message", "متن پیام اختصاصی را برای کاربر بفرستید:",
                  f"usr_profile:{target_uid}", {"target_user_id": target_uid})
        safe_answer(call)

    elif data.startswith("usr_referrals:"):
        target_uid = parse_int(data.split(":", 1)[1])
        show_user_referrals_admin(chat_id, msg_id, target_uid)
        safe_answer(call)

    elif data.startswith("usr_profile:"):
        target_uid = parse_int(data.split(":", 1)[1])
        show_user_profile_admin(chat_id, msg_id, target_uid)
        safe_answer(call)

    elif data == "usr_reset_tests_confirm":
        kb = types.InlineKeyboardMarkup()
        kb.row(make_inline_button(text="بله، ریست شوند", callback_data="usr_reset_tests_do", style="danger"),
               make_inline_button(text="انصراف", callback_data="adm_manage_users"))
        update_message_safe(chat_id, msg_id, "آیا اطمینان دارید که محدودیت دریافت تست برای <b>همه کاربران</b> صفر شود؟",
                            reply_markup=kb)

    elif data == "usr_reset_tests_do":
        dbx("UPDATE users SET has_used_test = 0")
        safe_answer(call, "تست تمام کاربران ریست شد ✅", True)
        show_users_management_menu(chat_id, msg_id)

    elif data.startswith("usr_block:"):
        t_uid = int(data.split(":")[1])
        dbx("UPDATE users SET is_blocked = 1 WHERE user_id = ?", (t_uid,))
        notify_user(t_uid, "🚫 <b>حساب کاربری شما توسط مدیریت مسدود شد.</b>")
        safe_answer(call, "کاربر مسدود شد.")
        show_user_profile_admin(chat_id, msg_id, t_uid)

    elif data.startswith("usr_unblock:"):
        t_uid = int(data.split(":")[1])
        dbx("UPDATE users SET is_blocked = 0 WHERE user_id = ?", (t_uid,))
        notify_user(t_uid, "✅ <b>مسدودیت حساب شما رفع شد.</b> دوباره می‌توانید از ربات استفاده کنید.")
        safe_answer(call, "مسدودیت کاربر رفع شد.")
        show_user_profile_admin(chat_id, msg_id, t_uid)

    elif data.startswith("usr_toggle_test_unlimited:"):
        t_uid = int(data.split(":")[1])
        row = dbx("SELECT test_unlimited FROM users WHERE user_id = ?", (t_uid,), "one")
        if not row:
            safe_answer(call, "کاربر پیدا نشد.", True)
            return
        new_value = 0 if row[0] else 1
        dbx("UPDATE users SET test_unlimited = ? WHERE user_id = ?", (new_value, t_uid))
        notify_user(t_uid, "🎁 <b>مدیریت دریافت تست نامحدود را برای شما فعال کرد.</b>" if new_value else
                    "ℹ️ محدودیت عادی دریافت تست دوباره برای شما برقرار شد.")
        safe_answer(call, "تست نامحدود فعال شد." if new_value else "محدودیت عادی تست برگشت.")
        show_user_profile_admin(chat_id, msg_id, t_uid)

    elif data.startswith("usr_reset_single_test:"):
        t_uid = int(data.split(":")[1])
        dbx("UPDATE users SET has_used_test = 0 WHERE user_id = ?", (t_uid,))
        notify_user(t_uid, "🎁 <b>امکان دریافت تست برای شما دوباره فعال شد.</b>")
        safe_answer(call, "تست این کاربر ریست شد.")
        show_user_profile_admin(chat_id, msg_id, t_uid)

    elif data == "adm_edit_colors":
        show_colors_menu(chat_id, msg_id)

    elif data == "adm_edit_colors_toggle":
        set_setting("colors_enabled", "0" if colors_on() else "1")
        show_colors_menu(chat_id, msg_id)

    elif data.startswith("set_color_menu:"):
        b_id = data.split(":")[1]
        kb = types.InlineKeyboardMarkup()
        for c_key, c_name in STYLE_NAMES.items():
            val = c_key or "none"
            kb.add(make_inline_button(text=c_name, callback_data=f"apply_color:{b_id}:{val}", keep_emoji=True))
        kb.add(make_inline_button(text="انصراف", callback_data="adm_edit_colors"))
        update_message_safe(chat_id, msg_id, "رنگ مورد نظر برای دکمه را انتخاب کنید:", reply_markup=kb)

    elif data.startswith("apply_color:"):
        _, b_id, c_val = data.split(":")
        set_setting(f"style_{b_id}", c_val)
        safe_answer(call, "رنگ ذخیره شد ✅")
        show_colors_menu(chat_id, msg_id)

    elif data == "adm_edit_emojis":
        show_emojis_menu(chat_id, msg_id)

    elif data.startswith("set_emoji:"):
        b_id = data.split(":")[1]
        ask_state(f"waiting_emoji_{b_id}",
                  "خود ایموجی پرمیوم را بفرستید یا شناسه عددی (Custom Emoji ID) آن را ارسال کنید.\n"
                  "برای حذف، عدد <code>0</code> را ارسال نمایید:", "adm_edit_emojis")

    elif data == "adm_edit_texts":
        show_edit_texts_menu(chat_id, msg_id)

    elif data == "adm_pnl_layout":
        show_pnl_layout_editor(chat_id, msg_id)

    elif data.startswith("adm_pnl_hide:"):
        b = data.split(":", 1)[1]
        order, hidden, cols = get_pnl_layout()
        if b in PNL_BTN_DEFAULT:
            hidden.symmetric_difference_update({b})
            set_setting("pnl_hidden", json.dumps(sorted(hidden)))
        show_pnl_layout_editor(chat_id, msg_id)

    elif data.startswith("adm_pnl_mv:"):
        _, b, direction = data.split(":")
        order, hidden, cols = get_pnl_layout()
        if b in order:
            i = order.index(b)
            j = i - 1 if direction == "up" else i + 1
            if 0 <= j < len(order):
                order[i], order[j] = order[j], order[i]
                set_setting("pnl_order", json.dumps(order))
        show_pnl_layout_editor(chat_id, msg_id)

    elif data == "adm_pnl_cols":
        order, hidden, cols = get_pnl_layout()
        set_setting("pnl_cols", "1" if cols == 2 else "2")
        show_pnl_layout_editor(chat_id, msg_id)

    elif data == "adm_pnl_reset":
        for k in ("pnl_order", "pnl_hidden", "pnl_cols"):
            set_setting(k, "")
        safe_answer(call, "به حالت پیش‌فرض برگشت ✅")
        show_pnl_layout_editor(chat_id, msg_id)

    elif data == "adm_rules_clear":
        set_setting("rules_text", "")
        set_setting("rules_entities", "[]")
        show_text_section(chat_id, msg_id, "rules")

    elif data.startswith("adm_edit_texts_sec:"):
        show_text_section(chat_id, msg_id, data.split(":", 1)[1])

    elif data.startswith("edit_text:"):
        key = data.split(":", 1)[1]
        back_target = ("adm_force_join" if key.startswith("fj_") else
                       "shop_settings" if key == "shop_header_text" else
                       f"adm_edit_texts_sec:{TEXT_KEY_SECTION.get(key, '')}" if key in TEXT_KEY_SECTION
                       else "adm_edit_texts")
        name_hint = ("\n\n💡 برای نمایش اسم کاربر در متن، <code>{name}</code> را بنویسید. "
                     "مثال: سلام {name} عزیز خوش اومدی") if key == "start_text" else ""
        ask_state(f"waiting_text_{key}",
                  f"متن جدید را ارسال فرمایید (برای دکمه‌ها می‌توانید ایموجی پریمیوم کنار نام بگذارید؛ به‌صورت آیکن دکمه نمایش داده می‌شود):\n(کلید: <code>{key}</code>){name_hint}",
                  back_target)

    elif data == "adm_edit_links":
        show_links_menu(chat_id, msg_id)

    elif data == "adm_report_settings":
        show_report_settings_menu(chat_id, msg_id)

    elif data == "set_report_chat":
        ask_state("waiting_report_chat_id",
                  "آیدی عددی گروه گزارشات یا @یوزرنیم گروه را بفرستید.\n"
                  "ربات باید عضو گروه باشد و اجازه ارسال پیام داشته باشد.",
                  "adm_report_settings")

    elif data == "adm_report_topics":
        rc = get_setting("report_chat_id").strip()
        if not rc:
            safe_answer(call, "ابتدا گروه گزارشات را تنظیم کنید.", True)
        else:
            safe_answer(call, "در حال ساخت تاپیک‌ها...")
            try:
                _ok, _msg = setup_report_topics(rc)
            except Exception as e:
                traceback.print_exc()
                _msg = f"❌ خطا: <code>{html.escape(str(e)[:200])}</code>"
            send_message_safe(chat_id, _msg)
            show_report_settings_menu(chat_id, msg_id)

    elif data == "adm_report_test":
        rc = get_setting("report_chat_id").strip()
        if not rc:
            safe_answer(call, "ابتدا گروه گزارشات را تنظیم کنید.", True)
        else:
            safe_answer(call, "در حال تست اتصال...")
            try:
                _msg = test_report_connection(rc)
            except Exception as e:
                traceback.print_exc()
                _msg = f"❌ خطا: <code>{html.escape(str(e)[:200])}</code>"
            send_message_safe(chat_id, _msg)
            show_report_settings_menu(chat_id, msg_id)

    elif data == "clear_report_chat":
        set_setting("report_chat_id", "")
        clear_report_topics()
        safe_answer(call, "گروه گزارشات حذف شد؛ گزارش‌ها به ادمین‌ها می‌روند.")
        show_report_settings_menu(chat_id, msg_id)

    elif data == "set_support_user":
        ask_state("waiting_support_user", "یوزرنیم جدید پشتیبانی را بدون @ وارد کنید:", "adm_edit_links")

    elif data == "set_channel_link":
        ask_state("waiting_channel_link", "لینک کامل کانال (همراه با https://t.me/...) را وارد کنید:", "adm_edit_links")

    elif data == "adm_broadcast":
        ask_state("waiting_broadcast", "پیام همگانی خود را بفرستید (متن، عکس، فوروارد و...):", "admin_panel")

    elif data == "adm_manage_admins":
        show_admin_list(chat_id, msg_id)

    elif data == "add_new_admin":
        ask_state("waiting_new_admin_id", "آیدی عددی کاربر مورد نظر جهت ارتقا به ادمین را ارسال کنید:", "adm_manage_admins")

    elif data.startswith("admin_perms:"):
        show_admin_permission_menu(chat_id, msg_id, int(data.split(":")[1]))

    elif data == "del_admin_menu":
        show_delete_admin_menu(chat_id, msg_id)

    elif data.startswith("remove_adm:"):
        a_id = int(data.split(":")[1])
        dbx("DELETE FROM admins WHERE user_id = ?", (a_id,))
        safe_answer(call, "ادمین حذف شد.")
        show_admin_list(chat_id, msg_id)

    elif data.startswith("perm_toggle:"):
        _, a_id_s, feature = data.split(":", 2)
        a_id = int(a_id_s)
        if feature not in ADMIN_FEATURES:
            safe_answer(call, "این دسترسی معتبر نیست.", True)
            return
        row = dbx("SELECT permissions FROM admins WHERE user_id = ?", (a_id,), "one")
        if not row:
            safe_answer(call, "ادمین پیدا نشد.", True)
            return
        try:
            selected = set(json.loads(row[0] or "[]"))
        except Exception:
            selected = set()
        if feature in selected:
            selected.remove(feature)
        else:
            selected.add(feature)
        dbx("UPDATE admins SET permissions = ?, can_settings = 0, can_admins = 0 WHERE user_id = ?",
            (json.dumps(sorted(selected)), a_id))
        safe_answer(call, "دسترسی به‌روزرسانی شد.")
        show_admin_permission_menu(chat_id, msg_id, a_id)

    elif data.startswith("perm_done:"):
        safe_answer(call, "دسترسی‌ها ذخیره شد ✅")
        show_admin_list(chat_id, msg_id)

    elif data.startswith("perm_menu:"):
        a_id = int(data.split(":")[1])
        dbx("INSERT OR IGNORE INTO admins (user_id, can_settings, can_admins, permissions) VALUES (?, 0, 0, '[]')",
            (a_id,))
        dbx("UPDATE admins SET permissions = '[]', can_settings = 0, can_admins = 0 WHERE user_id = ?", (a_id,))
        show_admin_permission_menu(chat_id, msg_id, a_id)

    elif data.startswith("perm_"):
        _, ptype, a_id = data.split(":")
        if ptype == "full":
            selected = sorted(ADMIN_FEATURES)
        elif ptype == "orders":
            selected = ["orders"]
        else:
            selected = []
        dbx("INSERT OR REPLACE INTO admins (user_id, can_settings, can_admins, permissions) VALUES (?, 0, 0, ?)",
            (int(a_id), json.dumps(selected)))
        safe_answer(call, "سطح دسترسی تنظیم شد.")
        show_admin_list(chat_id, msg_id)

    elif data == "adm_force_join":
        show_force_join_menu(chat_id, msg_id)

    elif data == "fj_toggle":
        curr = get_setting("force_join_enabled")
        set_setting("force_join_enabled", "0" if curr == "1" else "1")
        show_force_join_menu(chat_id, msg_id)

    elif data == "fj_add_channel":
        ask_state("waiting_fj_chat_id", "آیدی عددی کانال (با -100 شروع شود) یا یوزرنیم عمومی کانال را با @ بفرستید:\n"
                                        "<b>ربات باید از قبل در کانال ادمین باشد.</b>", "adm_force_join")

    elif data.startswith("fj_del:"):
        cid = data.split(":", 1)[1]
        dbx("DELETE FROM force_join_channels WHERE chat_id = ?", (cid,))
        safe_answer(call, "کانال حذف شد.")
        show_force_join_menu(chat_id, msg_id)


# ==================== دریافت پیام‌های متنی و فایل‌ها ====================
def _handle_start_reaction_state(message, uid, chat_id, td, text):
    val = (text or "").strip()
    if val == "0":
        set_setting("start_reaction", "")
        user_states.pop(uid, None)
        send_message_safe(chat_id, "ری‌اکشن پیام استارت خاموش شد.")
        show_admin_panel(chat_id, td.get("last_msg_id"), uid, "3")
        return
    ce = next((e for e in (message.entities or []) if e.type == "custom_emoji"), None)
    if ce:
        fb = val[:2].strip() if val else ""
        try:
            st = bot.get_custom_emoji_stickers([ce.custom_emoji_id])
            if st and st[0].emoji:
                fb = st[0].emoji
        except Exception:
            pass
        val = f"ce:{ce.custom_emoji_id}|{fb}"
        set_reaction(chat_id, message.message_id, val)
    elif not val or len(val) > 8 or not set_reaction(chat_id, message.message_id, val):
        step_reply(uid, chat_id, "این ایموجی را تلگرام به‌عنوان ری‌اکشن قبول نکرد. یک ایموجی دیگر بفرستید "
                                 "(مثل 👍 ❤️ 🔥 🎉):\n\n<b>علت از طرف تلگرام:</b>\n<code>"
                                 + html.escape(LAST_REACTION_ERR or "ایموجی نامعتبر") + "</code>",
                   cancel_kb("adm_cat:3"))
        return
    set_setting("start_reaction", val)
    user_states.pop(uid, None)
    send_message_safe(chat_id, f"✅ ری‌اکشن پیام استارت روی {reaction_label(val)} تنظیم شد.")
    show_admin_panel(chat_id, td.get("last_msg_id"), uid, "3")


def dispatch_configured_command(message):
    raw = (message.text or "").strip()
    match = re.match(r"^/([A-Za-z0-9_]+)(?:@([A-Za-z0-9_]+))?(?:\s+.*)?$", raw)
    if not match:
        return False
    command, requested_bot = match.group(1).lower(), match.group(2)
    if requested_bot:
        try:
            if requested_bot.lower() != (bot.get_me().username or "").lower():
                return False
        except Exception:
            return False
    button_id = next((key for key, _label in COMMAND_BUTTONS
                      if (get_setting(f"cmd_{key}") or "").lower() == command), None)
    if not button_id:
        return False
    uid, chat_id = message.from_user.id, message.chat.id
    add_user_if_new(message.from_user)
    if button_id == "btn_admin_panel":
        if is_admin(uid):
            user_states.pop(uid, None)
            show_admin_panel(chat_id, None, uid)
            return True
        return False
    if is_user_blocked(uid):
        send_message_safe(chat_id, "<b>حساب کاربری شما مسدود است.</b>")
        return True
    if bot_is_off_for(uid):
        send_message_safe(chat_id, get_setting("bot_off_text"),
                          entities=parse_db_entities(get_setting("bot_off_entities")))
        return True
    missing = get_missing_channels(uid)
    if missing:
        send_force_join_prompt(chat_id, None, missing)
        return True
    if rules_pending(uid):
        send_rules(chat_id, None, uid)
        return True
    if button_id == "btn_buy":
        show_shop_user_menu(chat_id, None, uid)
    elif button_id == "btn_my_panels":
        show_my_panels(chat_id, None, uid)
    elif button_id == "btn_wallet":
        show_wallet(chat_id, None, uid)
    elif button_id == "btn_test":
        show_test_menu(chat_id, None)
    elif button_id == "btn_support":
        show_support(chat_id, None)
    elif button_id == "btn_channel":
        link = get_setting("channel_link")
        if is_valid_url(link):
            send_message_safe(chat_id, f'<a href="{html.escape(link, quote=True)}">ورود به کانال</a>')
        else:
            show_main_menu(chat_id, None, uid)
    elif button_id == "btn_agency":
        start_agency_link(chat_id, None, uid)
    elif button_id == "btn_miniapp":
        send_miniapp_open(chat_id)
    return True


@bot.message_handler(content_types=['text', 'photo', 'document'])
def handle_all_messages(message):
    if message.chat.type != "private":
        return
    uid = message.from_user.id
    state = user_states.get(uid)
    text = (message.text or message.caption or "").strip()
    chat_id = message.chat.id
    td = user_temp_data.setdefault(uid, {})
    if not state and message.content_type == "text" and dispatch_configured_command(message):
        del_msg(chat_id, message.message_id)
        return
    if not state and message.content_type == "text" and dispatch_reply_button(message):
        del_msg(chat_id, message.message_id)
        return
    if state != "waiting_start_reaction":  # در این حالت پیام باید برای ری‌اکشن بماند؛ بعداً پاک می‌شود
        del_msg(chat_id, message.message_id)

    if not state:
        return

    if pem_handle_state(uid, chat_id, state, message, td):
        return

    # ثبت پنل پاسارگاد (ویزارد)
    if state == "step_pg_name":
        td["pg_name"] = text
        user_states[uid] = "step_pg_url"
        step_reply(uid, chat_id, "<b>مرحله ۲ از ۴:</b>\nآدرس کامل پنل را بدون <code>/dashboard</code> ارسال کنید:\n"
                                 "مثال: <code>https://sub.domain.com:8443</code>", cancel_kb("adm_pasarguard"))

    elif state == "step_pg_url":
        clean = clean_panel_url(text)
        if not clean.startswith("http"):
            step_reply(uid, chat_id, "آدرس باید با http:// یا https:// شروع شود. مجدداً ارسال کنید:", cancel_kb("adm_pasarguard"))
            return
        td["pg_url"] = clean
        user_states[uid] = "step_pg_user"
        step_reply(uid, chat_id, "<b>مرحله ۳ از ۴:</b>\nنام کاربری (Username) ادمین پنل را وارد کنید:", cancel_kb("adm_pasarguard"))

    elif state == "step_pg_user":
        td["pg_user"] = text
        user_states[uid] = "step_pg_pass"
        step_reply(uid, chat_id, "<b>مرحله ۴ از ۴:</b>\nرمز عبور یا API Key پنل را وارد نمایید:", cancel_kb("adm_pasarguard"))

    elif state == "step_pg_pass":
        p_name = td.get("pg_name", "پنل سرور")
        p_url = td.get("pg_url")
        p_user = td.get("pg_user")
        p_pass = text
        step_reply(uid, chat_id, "در حال بررسی ارتباط با سرور پاسارگاد...")
        ok, res = test_pasarguard_connection({"url": p_url, "username": p_user, "password": p_pass})
        if not ok:
            step_reply(uid, chat_id, f"❌ خطا در اتصال به سرور:\n<code>{html.escape(str(res))}</code>\n\n"
                                     "اطلاعات اشتباه است. لطفاً از ابتدا اقدام کنید.", cancel_kb("adm_pasarguard"))
            user_states.pop(uid, None)
            user_temp_data.pop(uid, None)
            return
        dbx("INSERT INTO server_panels (name, url, username, password, group_name) VALUES (?, ?, ?, ?, 'ALL')",
            (p_name, p_url, p_user, p_pass))
        user_states.pop(uid, None)
        user_temp_data.pop(uid, None)
        lm = td.get("last_msg_id")
        show_pasarguard_menu(chat_id, lm)

    # ویرایش مشخصات پنل پاسارگاد
    elif state == "waiting_edit_pg_url":
        pid = td.get("panel_id")
        clean = clean_panel_url(text)
        dbx("UPDATE server_panels SET url = ? WHERE id = ?", (clean, pid))
        user_states.pop(uid, None)
        show_single_panel_details(chat_id, td.get("last_msg_id"), pid)

    elif state == "waiting_edit_pg_user":
        pid = td.get("panel_id")
        dbx("UPDATE server_panels SET username = ? WHERE id = ?", (text, pid))
        user_states.pop(uid, None)
        show_single_panel_details(chat_id, td.get("last_msg_id"), pid)

    elif state == "waiting_edit_pg_pass":
        pid = td.get("panel_id")
        dbx("UPDATE server_panels SET password = ? WHERE id = ?", (text, pid))
        user_states.pop(uid, None)
        show_single_panel_details(chat_id, td.get("last_msg_id"), pid)

    elif state == "waiting_manual_group":
        pid = td.get("panel_id")
        dbx("UPDATE server_panels SET group_name = ? WHERE id = ?", (text.strip(), pid))
        user_states.pop(uid, None)
        show_single_panel_details(chat_id, td.get("last_msg_id"), pid)

    elif state == "shop_add_emoji":
        ok_e, val_e = capture_emoji_value(message, text)
        if not ok_e:
            step_reply(uid, chat_id, "⚠️ یک ایموجی پریمیوم بفرستید یا عدد 0 برای رد شدن:", cancel_kb("shop_settings"))
            return
        td["new_emoji"] = val_e
        finish_new_plan(uid, chat_id, td.get("last_msg_id"))

    # شارژ کیف پول: مبلغ ← اطلاعات پرداخت ← رسید
    elif state == "wallet_charge_amount":
        amount = parse_int(text) if message.content_type == "text" else None
        if amount is None or amount <= 0:
            step_reply(uid, chat_id, "⚠️ مبلغ را فقط به‌صورت عدد (تومان) بفرستید:", cancel_kb("btn_wallet"))
            return
        td["wc_amount"] = amount
        user_states[uid] = "wallet_charge_receipt"
        pay_text, pay_ents = wallet_pay_message(amount)
        step_reply(uid, chat_id, pay_text, cancel_kb("btn_wallet"), entities=pay_ents)

    elif state == "wallet_charge_receipt":
        amount = int(td.get("wc_amount") or 0)
        if message.content_type not in ("photo", "document") or amount <= 0:
            step_reply(uid, chat_id, "⚠️ لطفاً تصویر رسید پرداخت را به‌صورت عکس یا فایل بفرستید:",
                       cancel_kb("btn_wallet"))
            return
        file_id = message.photo[-1].file_id if message.content_type == "photo" else message.document.file_id
        cid = dbx("INSERT INTO wallet_charges (user_id, amount, file_id, file_type) VALUES (?, ?, ?, ?)",
                  (uid, amount, file_id, message.content_type), "id")
        user_states.pop(uid, None)
        sent_n = notify_admins_wallet_charge(cid)
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text=get_setting("btn_back_main"), callback_data="back_to_main",
                                  btn_id="back_to_main"))
        step_reply(uid, chat_id, "✅ رسید شما ثبت و برای مدیریت ارسال شد؛ بعد از تأیید، کیف پول شارژ می‌شود."
                   if sent_n else "⚠️ رسید ثبت شد اما ارسال به مدیریت ناموفق بود؛ با پشتیبانی تماس بگیرید.", kb)

    # ساخت پلن فروشگاه
    elif state == "shop_add_name":
        td["name"] = text
        user_states[uid] = "shop_add_gb"
        step_reply(uid, chat_id, "<b>مرحله ۲ از ۹:</b>\nحجم پلن را به <b>گیگابایت</b> وارد کنید (عدد):\n(برای نامحدود عدد 0 بفرستید)", cancel_kb("shop_settings"))

    elif state == "shop_add_gb":
        val = parse_int(text)
        if val is None or val < 0:
            step_reply(uid, chat_id, "لطفاً فقط عدد صحیح انگلیسی وارد کنید:", cancel_kb("shop_settings"))
            return
        td["gb"] = val
        user_states[uid] = "shop_add_days"
        step_reply(uid, chat_id, "<b>مرحله ۳ از ۹:</b>\nمدت زمان اعتبار پلن را به <b>روز</b> وارد کنید:\n(برای نامحدود عدد 0 بفرستید)", cancel_kb("shop_settings"))

    elif state == "shop_add_days":
        val = parse_int(text)
        if val is None or val < 0:
            step_reply(uid, chat_id, "لطفاً فقط عدد معتبر روز وارد کنید:", cancel_kb("shop_settings"))
            return
        td["days"] = val
        user_states[uid] = "shop_add_price"
        step_reply(uid, chat_id, "<b>مرحله ۴ از ۹:</b>\nقیمت پلن را به <b>تومان</b> وارد کنید (مثال: 50000):\n(برای رایگان عدد 0 بفرستید)", cancel_kb("shop_settings"))

    elif state == "shop_add_price":
        val = parse_int(text)
        if val is None or val < 0:
            step_reply(uid, chat_id, "لطفاً قیمت را به صورت عددی وارد کنید:", cancel_kb("shop_settings"))
            return
        td["price"] = val
        user_states[uid] = "shop_add_desc"
        step_reply(uid, chat_id, "<b>مرحله ۵ از ۹:</b>\nتوضیحات پلن را بنویسید (یا عدد 0 برای رد شدن):\n💡 برای جدول، هر ردیف را در یک خط با | جدا کنید. مثال: <code>| حجم | قیمت |</code>", cancel_kb("shop_settings"))

    elif state == "shop_add_desc":
        raw_desc = message.text or message.caption or ""
        td["desc"] = "" if text == "0" else raw_desc
        td["desc_entities"] = "[]" if text == "0" else entities_to_json(message.entities or message.caption_entities)
        user_states.pop(uid, None)
        panels = get_all_server_panels()
        kb = types.InlineKeyboardMarkup()
        for p in panels:
            kb.add(make_inline_button(text=str(p[1]), callback_data=f"plan_set_panel:{p[0]}",
                                      btn_id="server_title", style="none"))
        kb.add(make_inline_button(text="انصراف", callback_data="shop_settings"))
        step_reply(uid, chat_id, "<b>مرحله ۶ از ۹:</b>\nاین پلن روی کدام پنل سرور تعریف شود؟", kb)

    elif state.startswith("waiting_plan_edit_"):
        field = state[len("waiting_plan_edit_"):]
        plan_id = int(td.get("edit_plan_id") or 0)
        if field in ("gb", "days", "price"):
            value = parse_int(text)
            if value is None or value < 0:
                step_reply(uid, chat_id, "لطفاً یک عدد صحیح و غیرمنفی وارد کنید:",
                           cancel_kb(f"edit_plan:{plan_id}"))
                return
            column = {"gb": "traffic_gb", "days": "days", "price": "price"}[field]
            dbx(f"UPDATE shop_plans SET {column} = ? WHERE id = ?", (value, plan_id))
        elif field == "name":
            if not text:
                step_reply(uid, chat_id, "نام پلن نمی‌تواند خالی باشد:", cancel_kb(f"edit_plan:{plan_id}"))
                return
            dbx("UPDATE shop_plans SET name = ? WHERE id = ?", (text, plan_id))
        elif field == "description":
            raw_desc = message.text or message.caption or ""
            if text == "0":
                raw_desc, raw_entities = "", "[]"
            else:
                raw_entities = entities_to_json(message.entities or message.caption_entities)
            dbx("UPDATE shop_plans SET description = ?, description_entities = ? WHERE id = ?",
                (raw_desc, raw_entities, plan_id))
        user_states.pop(uid, None)
        show_plan_edit_menu(chat_id, td.get("last_msg_id"), plan_id)

    elif state == "shop_grp_new":
        if not text.strip():
            step_reply(uid, chat_id, "نام گروه نمی‌تواند خالی باشد:", cancel_kb("shop_groups"))
            return
        new_gid = dbx("INSERT INTO plan_groups (name) VALUES (?)", (text.strip()[:60],), "id")
        user_states.pop(uid, None)
        show_group_edit_menu(chat_id, td.get("last_msg_id"), new_gid)

    elif state == "shop_grp_edit_field":
        gid = int(td.get("grp_id") or 0)
        field = td.get("grp_field")
        grp = get_plan_group(gid)
        back = cancel_kb(f"shop_grp_edit:{gid}")
        if not grp:
            user_states.pop(uid, None)
            show_groups_admin(chat_id, td.get("last_msg_id"))
            return
        if field == "name":
            if not text.strip():
                step_reply(uid, chat_id, "نام گروه نمی‌تواند خالی باشد:", back)
                return
            dbx("UPDATE plan_groups SET name = ? WHERE id = ?", (text.strip()[:60], gid))
        elif field == "header":
            dbx("UPDATE plan_groups SET header = ? WHERE id = ?", ("" if text.strip() == "0" else text.strip()[:600], gid))
        elif field == "emoji":
            ok_e, val = capture_emoji_value(message, text)
            if not ok_e:
                step_reply(uid, chat_id, "ایموجی پریمیوم یا آیدی عددی معتبر بفرستید (۰ برای حذف):", back)
                return
            set_setting(f"emoji_grp_{gid}", val)
        elif field in ("price", "days", "min", "max"):
            value = parse_int(text)
            if value is None or value < 0 or (field in ("min", "max") and value < 1):
                step_reply(uid, chat_id, "لطفاً یک عدد صحیح معتبر وارد کنید:", back)
                return
            if field == "min" and value > int(grp[6] or 0):
                step_reply(uid, chat_id, f"حداقل نمی‌تواند از حداکثر ({grp[6]}) بیشتر باشد:", back)
                return
            if field == "max" and value < int(grp[5] or 0):
                step_reply(uid, chat_id, f"حداکثر نمی‌تواند از حداقل ({grp[5]}) کمتر باشد:", back)
                return
            col = {"price": "custom_price", "days": "custom_days", "min": "custom_min", "max": "custom_max"}[field]
            dbx(f"UPDATE plan_groups SET {col} = ? WHERE id = ?", (value, gid))
            if field == "price" and value == 0:
                dbx("UPDATE plan_groups SET custom_enabled = 0 WHERE id = ?", (gid,))
        user_states.pop(uid, None)
        show_group_edit_menu(chat_id, td.get("last_msg_id"), gid)

    elif state == "plan_emoji_edit":
        plan_id = int(td.get("edit_plan_id") or 0)
        ok_e, val = capture_emoji_value(message, text)
        if not ok_e:
            step_reply(uid, chat_id, "ایموجی پریمیوم یا آیدی عددی معتبر بفرستید (۰ برای حذف):",
                       cancel_kb(f"edit_plan:{plan_id}"))
            return
        set_setting(f"emoji_plan_{plan_id}", val)
        user_states.pop(uid, None)
        show_plan_edit_menu(chat_id, td.get("last_msg_id"), plan_id)

    elif state == "buy_custom_gb":
        gid = int(td.get("custom_group") or 0)
        grp = get_plan_group(gid)
        if not grp or not grp[3] or int(grp[4] or 0) <= 0:
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "حجم دلخواه دیگر فعال نیست.", cancel_kb("btn_buy"))
            return
        gb = parse_int(text)
        mn, mx = int(grp[5] or 1), int(grp[6] or 1)
        if gb is None or gb < mn or gb > mx:
            step_reply(uid, chat_id, f"⚠️ یک عدد معتبر بین <b>{mn}</b> تا <b>{mx}</b> گیگابایت بفرستید:",
                       cancel_kb(f"ugrp:{gid}"))
            return
        plan_id = get_or_create_custom_plan(grp, gb)
        user_states.pop(uid, None)
        show_plan_detail(chat_id, td.get("last_msg_id"), uid, plan_id)

    # مراحل خرید توسط کاربر
    elif state == "buy_wait_username":
        u = text.strip().lower()
        if not re.match(r'^[a-z0-9_]{3,32}$', u):
            step_reply(uid, chat_id, "⚠️ نام کاربری باید بین ۳ تا ۳۲ کاراکتر و فقط شامل حروف انگلیسی، اعداد یا _ باشد.\nمجدداً ارسال کنید:", cancel_kb("btn_buy"))
            return
        td["custom_username"] = u
        if get_account_mode() == "admin":
            user_states[uid] = "buy_wait_password"
            spass = generate_strong_password_32(32)
            td["suggested_pass"] = spass
            kb = types.InlineKeyboardMarkup()
            kb.add(make_inline_button(text="استفاده از رمز عبور پیشنهادی", callback_data="buy_use_suggested_pass"))
            kb.add(make_inline_button(text="انصراف", callback_data="btn_buy"))
            txt = (f"<b>مرحله ۲: رمز عبور پنل</b>\n\n"
                   f"رمز عبور پیشنهادی قدرتمند:\n<code>{html.escape(spass)}</code>\n\n"
                   "می‌توانید روی دکمه زیر بزنید یا رمزی با «۲ حرف بزرگ، ۲ حرف کوچک، ۲ عدد و ۱ علامت» (حداقل ۱۵ کاراکتر) تایپ کنید:")
            step_reply(uid, chat_id, txt, kb)
        else:
            proceed_after_credentials(chat_id, td.get("last_msg_id"), uid)

    elif state == "buy_wait_password":
        if not is_strong_password_32(text):
            step_reply(uid, chat_id, "⚠️ رمز عبور باید " + PASSWORD_RULE_TEXT + " باشد.\nیا از دکمه بالا استفاده کنید:", cancel_kb("btn_buy"))
            return
        td["custom_password"] = text
        proceed_after_credentials(chat_id, td.get("last_msg_id"), uid)

    # دریافت رسید
    elif state == "buy_wait_receipt":
        file_id = None
        file_type = None
        if message.content_type == "photo":
            file_id = message.photo[-1].file_id
            file_type = "photo"
        elif message.content_type == "document":
            file_id = message.document.file_id
            file_type = "document"
        else:
            step_reply(uid, chat_id, "لطفاً عکس فیش واریزی خود را ارسال کنید.", cancel_kb("back_to_main"))
            return

        draft = load_draft(uid)
        if not draft:
            step_reply(uid, chat_id, "سفارش شما یافت نشد یا منقضی شده است. لطفاً از اول سفارش دهید.", get_back_keyboard())
            user_states.pop(uid, None)
            return

        plan_id, c_user, c_pass, renew_panel_id = draft
        _dcode = get_draft_discount_code(uid)
        order_id = dbx(
            "INSERT INTO orders (user_id, plan_id, custom_username, custom_password, file_id, file_type, "
            "status, renew_panel_id, amount, discount_code) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)",
            (uid, plan_id, c_user, c_pass, file_id, file_type, int(renew_panel_id or 0),
             get_draft_quoted_amount(uid), _dcode), "id"
        )
        mark_discount_used(_dcode)
        user_states.pop(uid, None)
        user_temp_data.pop(uid, None)
        clear_draft(uid)

        # ارسال به مالک و ادمین‌ها
        notify_admins_order(order_id)

        step_reply(uid, chat_id, "✅ <b>رسید شما با موفقیت دریافت شد و برای مدیریت ارسال گردید.</b>\n"
                                 "پس از بررسی، در صورت تأیید، اکانت شما بلافاصله ساخته و تحویل داده خواهد شد.", get_back_keyboard())

    # تست پنل اختصاصی
    elif state == "waiting_panel_username":
        if get_setting("test_panel_enabled") == "0":
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "تست پنل غیرفعال شده است.", get_back_keyboard())
            return
        u = text.strip().lower()
        if not re.match(r'^[a-z0-9_]{3,32}$', u):
            step_reply(uid, chat_id, "نام کاربری باید ۳ تا ۳۲ کاراکتر انگلیسی باشد:", cancel_kb("btn_test"))
            return
        td["username"] = u
        if get_account_mode() == "admin":
            user_states[uid] = "waiting_panel_pass"
            spass = generate_strong_password_32(32)
            td["suggested_pass"] = spass
            kb = types.InlineKeyboardMarkup()
            kb.add(make_inline_button(text="استفاده از رمز پیشنهادی", callback_data="use_suggested_32_pass"))
            kb.add(make_inline_button(text="انصراف", callback_data="btn_test"))
            step_reply(uid, chat_id, f"رمز پیشنهادی:\n<code>{html.escape(spass)}</code>\n\nرمز دلخواه را بفرستید یا دکمه را بزنید:", kb)
        else:
            create_test_account(chat_id, td.get("last_msg_id"), uid, u, "")

    elif state == "waiting_panel_pass":
        if get_setting("test_panel_enabled") == "0":
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "تست پنل غیرفعال شده است.", get_back_keyboard())
            return
        if not is_strong_password_32(text):
            step_reply(uid, chat_id, "رمز باید " + PASSWORD_RULE_TEXT + " باشد:", cancel_kb("btn_test"))
            return
        create_test_account(chat_id, td.get("last_msg_id"), uid, td.get("username", "testuser"), text)

    elif state == "agency_wait_user":
        del_msg(chat_id, message.message_id)
        uname_in = (text or "").strip()
        if not re.fullmatch(r"[A-Za-z0-9_.\-@]{2,64}", uname_in):
            step_reply(uid, chat_id, "⚠️ نام کاربری معتبر نیست. دوباره بفرستید:", cancel_kb("back_to_main"))
            return
        td["agency_user"] = uname_in
        user_states[uid] = "agency_wait_pass"
        step_reply(uid, chat_id, get_setting("agency_pass_text") or "رمز پنل را بفرستید:", cancel_kb("back_to_main"))

    elif state == "agency_wait_pass":
        del_msg(chat_id, message.message_id)
        if not text:
            step_reply(uid, chat_id, "⚠️ رمز را به‌صورت متن بفرستید:", cancel_kb("back_to_main"))
            return
        finish_agency_link(chat_id, td.get("last_msg_id"), uid, message.from_user, td.get("agency_user", ""), text)

    # مدیریت تنظیمات
    elif state == "pnl_wait_pw":
        pid = td.get("pnl_id")
        if not is_strong_password_32(text):
            step_reply(uid, chat_id, "⚠️ رمز باید " + PASSWORD_RULE_TEXT + " باشد.\n"
                                     "یا از دکمه رمز پیشنهادی استفاده کنید:", pw_change_kb(pid))
            return
        apply_panel_password(chat_id, td.get("last_msg_id"), uid, pid, text)

    elif state == "waiting_su_prefix":
        set_setting("su_prefix", "" if text.strip() == "0" else _su_clean(text)[:12])
        user_states.pop(uid, None)
        show_shared_username_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_su_len":
        n = parse_int(text)
        if n is None or not (3 <= n <= 16):
            step_reply(uid, chat_id, "⚠️ عدد بین ۳ تا ۱۶ بفرستید:", cancel_kb("adm_su_menu"))
            return
        set_setting("su_rand_len", str(n))
        user_states.pop(uid, None)
        show_shared_username_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_su_pattern":
        if not any(v in text for v in ("{id}", "{username}", "{rand}", "{n}")):
            step_reply(uid, chat_id, "⚠️ الگو باید حداقل یکی از <code>{id}</code> <code>{username}</code> "
                                     "<code>{rand}</code> <code>{n}</code> را داشته باشد:", cancel_kb("adm_su_menu"))
            return
        set_setting("su_pattern", text.strip()[:60])
        set_setting("su_mode", "pattern")
        user_states.pop(uid, None)
        show_shared_username_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_admin_role_id":
        val = parse_int(text, default=0)
        set_setting("admin_role_id", "" if val == 0 else str(val))
        user_states.pop(uid, None)
        send_message_safe(chat_id, f"Role ID ادمین {'به حالت خودکار برگشت' if val == 0 else f'روی {val} تنظیم شد'}.")
        show_admin_panel(chat_id, td.get("last_msg_id"), uid)

    elif state == "waiting_backup_hours":
        hours = parse_int(text)
        if hours is None or not 1 <= hours <= 720:
            step_reply(uid, chat_id, "عدد باید بین ۱ تا ۷۲۰ ساعت باشد:", cancel_kb("adm_db_menu"))
            return
        set_setting("backup_interval_hours", str(hours))
        set_setting("last_db_backup_ts", str(int(time.time())))
        user_states.pop(uid, None)
        send_message_safe(chat_id, f"✅ از این به بعد هر {hours} ساعت یک‌بار بکاپ به گروه گزارشات فرستاده می‌شود.")
        show_db_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_start_reaction":
        try:
            _handle_start_reaction_state(message, uid, chat_id, td, text)
        finally:
            del_msg(chat_id, message.message_id)

    elif state == "waiting_expiry_cleanup":
        minutes = parse_int(text)
        if minutes is None or not 1 <= minutes <= 1440:
            step_reply(uid, chat_id, "عدد باید بین ۱ تا ۱۴۴۰ دقیقه باشد:", cancel_kb("admin_panel"))
            return
        set_setting("expiry_cleanup_minutes", str(minutes))
        user_states.pop(uid, None)
        show_admin_panel(chat_id, td.get("last_msg_id"), uid)

    elif state.startswith("setting_"):
        pkey = state.replace("setting_", "")
        val = parse_int(text)
        if val is not None and val >= 0:
            set_setting(pkey, str(val))
            user_states.pop(uid, None)
            show_test_plans_menu(chat_id, td.get("last_msg_id"))
        else:
            step_reply(uid, chat_id, "مقدار باید عددی باشد:", cancel_kb("adm_test_plans"))

    elif state == "waiting_card_num":
        clean = to_en_digits(text).replace(" ", "").replace("-", "")
        set_setting("card_number", clean)
        user_states.pop(uid, None)
        show_card_settings_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_card_holder":
        set_setting("card_holder", text)
        user_states.pop(uid, None)
        show_card_settings_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_usr_search":
        q = to_en_digits(text).strip().lstrip("@")
        row = dbx("SELECT user_id FROM users WHERE user_id = ? OR LOWER(username) = LOWER(?)", (q, q), "one")
        user_states.pop(uid, None)
        if row:
            show_user_profile_admin(chat_id, td.get("last_msg_id"), row[0])
        else:
            step_reply(uid, chat_id, "کاربری با این مشخصات یافت نشد.", cancel_kb("adm_manage_users"))

    elif state in ("waiting_usr_wallet_amount", "waiting_usr_service_adjust", "waiting_usr_message"):
        if not is_admin(uid) or not admin_can(uid, "users"):
            user_states.pop(uid, None)
            user_temp_data.pop(uid, None)
            send_message_safe(chat_id, "دسترسی مدیریت کاربران ندارید.")
            return
        target_uid = parse_int(td.get("target_user_id"))
        if not target_uid or not dbx("SELECT 1 FROM users WHERE user_id = ?", (target_uid,), "one"):
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "کاربر پیدا نشد.", cancel_kb("adm_manage_users"))
            return

        if state == "waiting_usr_wallet_amount":
            amount = parse_int(text)
            if amount is None or amount <= 0:
                step_reply(uid, chat_id, "مبلغ باید یک عدد صحیح و بزرگ‌تر از صفر باشد؛ دوباره وارد کنید:",
                           cancel_kb(f"usr_profile:{target_uid}"))
                return
            operation = td.get("wallet_action")
            try:
                ok, result = apply_wallet_change(target_uid, uid, amount, operation)
            except Exception:
                traceback.print_exc()
                step_reply(uid, chat_id, "ثبت تراکنش کیف پول ناموفق بود؛ لطفاً دوباره تلاش کنید:",
                           cancel_kb(f"usr_profile:{target_uid}"))
                return
            if not ok:
                if result is None:
                    step_reply(uid, chat_id, "کاربر پیدا نشد.", cancel_kb("adm_manage_users"))
                else:
                    step_reply(uid, chat_id,
                               f"موجودی کافی نیست. موجودی فعلی: <b>{int(result):,} تومان</b>\n"
                               "مبلغ کمتری وارد کنید:",
                               cancel_kb(f"usr_profile:{target_uid}"))
                return
            user_states.pop(uid, None)
            safe_action = "شارژ شد" if operation == "add" else "کسر شد"
            send_message_safe(chat_id, f"✅ کیف پول کاربر {safe_action}. موجودی جدید: <b>{int(result):,} تومان</b>")
            if operation == "add":
                notify_user(target_uid, f"💰 <b>کیف پول شما شارژ شد</b>\n\n➕ مبلغ: <b>{amount:,} تومان</b>\n"
                                        f"👛 موجودی جدید: <b>{int(result):,} تومان</b>")
            else:
                notify_user(target_uid, f"💰 <b>از کیف پول شما مبلغی کسر شد</b>\n\n➖ مبلغ: <b>{amount:,} تومان</b>\n"
                                        f"👛 موجودی جدید: <b>{int(result):,} تومان</b>")
            show_user_profile_admin(chat_id, td.get("last_msg_id"), target_uid)

        elif state == "waiting_usr_service_adjust":
            unit = td.get("service_unit")
            if unit == "days":
                adjustment = parse_int(text)
                unit_label = "روز"
            else:
                adjustment = parse_signed_number(text)
                unit_label = "گیگابایت"
            if adjustment is None or adjustment == 0:
                step_reply(uid, chat_id, "عدد معتبر و غیرصفر وارد کنید؛ علامت مثبت برای افزودن و منفی برای کاهش است:",
                           cancel_kb(f"usr_profile:{target_uid}"))
                return
            service_id = parse_int(td.get("service_id"))
            service = dbx("SELECT username, panel_id, kind, name FROM user_panels "
                          "WHERE id = ? AND user_id = ?", (service_id, target_uid), "one")
            if not service:
                user_states.pop(uid, None)
                show_user_profile_admin(chat_id, td.get("last_msg_id"), target_uid)
                return
            ok, err = adjust_pasarguard_service(service[0], service[1], service[2], adjustment, unit)
            if not ok:
                step_reply(uid, chat_id,
                           f"<b>تغییر انجام نشد:</b> {html.escape(str(err))}\n"
                           "مقدار دیگری وارد کنید:",
                           cancel_kb(f"usr_profile:{target_uid}"))
                return
            user_states.pop(uid, None)
            direction = "افزوده شد" if adjustment > 0 else "کاهش یافت"
            amount_text = f"{abs(adjustment):g}"
            send_message_safe(chat_id,
                              f"✅ {amount_text} {unit_label} برای سرویس "
                              f"<b>{html.escape(str(service[3]))}</b> {direction}.")
            notify_user(target_uid,
                        f"🛠 <b>سرویس شما توسط مدیریت تغییر کرد</b>\n\n📦 سرویس: <b>{html.escape(str(service[3]))}</b>\n"
                        f"{'📊 حجم' if unit != 'days' else '⏳ زمان'}: <b>{amount_text} {unit_label}</b> {direction}.")
            show_user_profile_admin(chat_id, td.get("last_msg_id"), target_uid)

        else:
            if not text:
                step_reply(uid, chat_id, "پیام خالی است؛ متن پیام را وارد کنید:",
                           cancel_kb(f"usr_profile:{target_uid}"))
                return
            try:
                bot.send_message(target_uid, html.escape(text), parse_mode="HTML")
                delivery = "پیام با موفقیت ارسال شد ✅"
            except Exception as e:
                delivery = f"ارسال پیام ناموفق بود: {html.escape(str(e)[:180])}"
            user_states.pop(uid, None)
            send_message_safe(chat_id, delivery)
            show_user_profile_admin(chat_id, td.get("last_msg_id"), target_uid)

    elif state.startswith("waiting_emoji_"):
        b_id = state.replace("waiting_emoji_", "")
        _ents = message.entities or message.caption_entities or []
        _ce = [e for e in _ents if getattr(e, "type", None) == "custom_emoji" and getattr(e, "custom_emoji_id", None)]
        cid = str(_ce[0].custom_emoji_id) if _ce else to_en_digits(text).strip()
        if cid != "0" and not re.fullmatch(r"\d{5,25}", cid):
            step_reply(uid, chat_id, "❌ ایموجی پرمیوم تشخیص داده نشد.\n"
                                     "خود ایموجی پرمیوم یا آیدی عددی آن را بفرستید:", cancel_kb("adm_edit_emojis"))
            return
        set_setting(f"emoji_{b_id}", "" if cid == "0" else cid)
        user_states.pop(uid, None)
        show_emojis_menu(chat_id, td.get("last_msg_id"))

    # ---------- تغییر متن‌ها ----------
    elif state.startswith("waiting_text_"):
        key = state[len("waiting_text_"):]
        raw_text = message.text or message.caption or ""
        entities = message.entities or message.caption_entities

        if key not in TEXT_KEYS_SET:
            user_states.pop(uid, None)
            show_edit_texts_menu(chat_id, td.get("last_msg_id"))
            return

        if key in BTN_ICON_KEYS:
            # نام دکمه: ایموجی پریمیوم از متن جدا و به‌عنوان آیکن دکمه ذخیره می‌شود
            clean_text, emoji_id = extract_custom_emoji(raw_text, entities)
            clean_text = clean_text.strip()
            if not clean_text:
                step_reply(uid, chat_id, "⚠️ نام دکمه نمی‌تواند فقط ایموجی باشد. یک نام همراه با متن ارسال کنید:",
                           cancel_kb("adm_edit_texts"))
                return
            set_setting(key, clean_text)
            if emoji_id:
                set_setting(f"emoji_{BTN_ICON_KEYS[key]}", str(emoji_id))
        else:
            msg_text = raw_text.strip()
            if not msg_text:
                step_reply(uid, chat_id, "⚠️ متن خالی است. لطفاً یک متن معتبر ارسال کنید:", cancel_kb("adm_edit_texts"))
                return
            if key == "rules_text" and re.sub(r"[\s«»\"'`]", "", msg_text) in (
                    "-", "0", "ـ", "–", "—", "−", "‐", "‑", "－", "۰", "٠", "حذف"):
                msg_text, entities = "", None
            set_setting(key, msg_text)
            if key.endswith("_text"):
                ent_key = key[:-len("_text")] + "_entities"
                set_setting(ent_key, entities_to_json(entities) if entities else "[]")

        user_states.pop(uid, None)
        if key.startswith("fj_"):
            show_force_join_menu(chat_id, td.get("last_msg_id"))
        elif key == "shop_header_text":
            show_shop_settings_menu(chat_id, td.get("last_msg_id"))
        else:
            show_text_section(chat_id, td.get("last_msg_id"), TEXT_KEY_SECTION.get(key, ""))

    elif state == "waiting_support_user":
        set_setting("support_user", text.lstrip("@"))
        user_states.pop(uid, None)
        show_links_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_channel_link":
        set_setting("channel_link", text)
        user_states.pop(uid, None)
        show_links_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_miniapp_url":
        val = "" if text == "0" else text.strip()
        if val and not (val.lower().startswith("https://") and is_valid_url(val)):
            step_reply(uid, chat_id, "لینک باید با https:// شروع شود و معتبر باشد؛ دوباره بفرستید:",
                       cancel_kb("adm_commands"))
            return
        set_setting("miniapp_url", val)
        user_states.pop(uid, None)
        show_command_settings_menu(chat_id, td.get("last_msg_id"))

    elif state.startswith("waiting_command_"):
        button_id = state[len("waiting_command_"):]
        if button_id not in [item[0] for item in COMMAND_BUTTONS]:
            user_states.pop(uid, None)
            show_command_settings_menu(chat_id, td.get("last_msg_id"))
            return
        command = "" if text == "0" else text.lstrip("/").strip().lower()
        if command and not re.fullmatch(r"[a-z0-9_]{1,32}", command):
            step_reply(uid, chat_id, "کامند باید فقط شامل حروف انگلیسی، عدد یا _ باشد؛ دوباره بفرستید:",
                       cancel_kb("adm_commands"))
            return
        if command and any(
                other_key != button_id and (get_setting(f"cmd_{other_key}") or "").lower() == command
                for other_key, _label in COMMAND_BUTTONS):
            step_reply(uid, chat_id, "این کامند برای دکمه‌ی دیگری ثبت شده است؛ یک کامند متفاوت بفرستید:",
                       cancel_kb("adm_commands"))
            return
        set_setting(f"cmd_{button_id}", command)
        user_states.pop(uid, None)
        show_command_settings_menu(chat_id, td.get("last_msg_id"))

    elif state == "waiting_db_upload":
        if message.content_type != "document":
            step_reply(uid, chat_id, "لطفاً فایل دیتابیس را به صورت فایل (Document) ارسال کنید.",
                       cancel_kb("adm_db_menu"))
            return
        tmp_path = os.path.join(BASE_DIR, f"upload_{uid}_{int(time.time())}.db")
        try:
            finfo = bot.get_file(message.document.file_id)
            with open(tmp_path, "wb") as f:
                f.write(bot.download_file(finfo.file_path))
            if not is_valid_sqlite_file(tmp_path):
                raise ValueError("فایل ارسالی یک دیتابیس SQLite سالم نیست.")
            try:
                send_db_backup(target_chat=chat_id, title="بکاپ دیتابیس قبلی (قبل از آپلود)")
            except Exception:
                traceback.print_exc()
            with _backup_lock:
                for suffix in ("-wal", "-shm", "-journal"):
                    try:
                        os.remove(DB_FILE + suffix)
                    except FileNotFoundError:
                        pass
                os.replace(tmp_path, DB_FILE)
                init_db()
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "✅ دیتابیس جدید با موفقیت جایگزین شد.")
            show_db_menu(chat_id, td.get("last_msg_id"))
        except Exception as e:
            traceback.print_exc()
            step_reply(uid, chat_id, f"❌ آپلود ناموفق بود: {html.escape(str(e)[:200])}\nدوباره فایل را بفرستید:",
                       cancel_kb("adm_db_menu"))
        finally:
            if os.path.exists(tmp_path):
                try:
                    os.remove(tmp_path)
                except Exception:
                    pass

    elif state == "shop_disc_code":
        code = to_en_digits(text).strip()
        if not re.fullmatch(r"[A-Za-z0-9_\-]{2,32}", code):
            step_reply(uid, chat_id, "کد نامعتبر است. فقط حروف انگلیسی، عدد، - و _ (۲ تا ۳۲ کاراکتر):",
                       cancel_kb("shop_discounts"))
            return
        if dbx("SELECT 1 FROM discount_codes WHERE code = ? COLLATE NOCASE", (code,), "one"):
            step_reply(uid, chat_id, "این کد قبلاً ساخته شده؛ کد دیگری بنویسید:", cancel_kb("shop_discounts"))
            return
        td["disc_code"] = code
        user_states[uid] = "shop_disc_amount"
        step_reply(uid, chat_id, f"کد: <code>{html.escape(code)}</code>\n\n💰 مبلغ تخفیف را به <b>تومان</b> وارد کنید:",
                   cancel_kb("shop_discounts"))

    elif state == "shop_disc_amount":
        amount = parse_int(to_en_digits(text).replace(",", ""))
        if not amount or amount <= 0:
            step_reply(uid, chat_id, "مبلغ نامعتبر است. یک عدد مثبت به تومان وارد کنید:", cancel_kb("shop_discounts"))
            return
        td["disc_amount"] = amount
        user_states[uid] = "shop_disc_hours"
        step_reply(uid, chat_id, f"مبلغ: <b>{amount:,} تومان</b>\n\n⏳ مدت اعتبار کد را به <b>ساعت</b> وارد کنید (مثلاً 24):",
                   cancel_kb("shop_discounts"))

    elif state == "shop_disc_hours":
        hours = parse_int(to_en_digits(text))
        if not hours or hours <= 0:
            step_reply(uid, chat_id, "تعداد ساعت نامعتبر است. یک عدد مثبت وارد کنید:", cancel_kb("shop_discounts"))
            return
        code, amount = td.get("disc_code"), td.get("disc_amount")
        if not code or not amount:
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "اطلاعات ناقص بود؛ دوباره از ابتدا بسازید.", cancel_kb("shop_discounts"))
            return
        td["disc_hours"] = hours
        td["disc_picks"] = set()
        user_states.pop(uid, None)
        kb = types.InlineKeyboardMarkup()
        kb.add(make_inline_button(text="🌐 همه پلن‌ها", callback_data="shop_disc_scope:all", style="none",
                                  keep_emoji=True))
        kb.add(make_inline_button(text="🎯 فقط پلن‌های انتخابی", callback_data="shop_disc_scope:pick", style="none",
                                  keep_emoji=True))
        kb.add(make_inline_button(text="انصراف", callback_data="shop_discounts"))
        step_reply(uid, chat_id, "این کد برای کدام پلن‌ها کار کند؟", kb)

    elif state == "shop_disc_mingb":
        mg = parse_int(to_en_digits(text))
        if mg is None or mg < 0:
            step_reply(uid, chat_id, "عدد نامعتبر است. حداقل حجم را به گیگ بنویسید (یا 0 برای بدون محدودیت):",
                       cancel_kb("shop_discounts"))
            return
        td["disc_min_gb"] = mg
        finalize_discount(uid, chat_id)

    elif state == "buy_wait_discount":
        plan_id = td.get("plan_id")
        pl = get_shop_plan_by_id(plan_id) if plan_id else None
        back = types.InlineKeyboardMarkup()
        back.add(make_inline_button(text="بازگشت به صفحه پرداخت", callback_data="buy_disc_back"))
        if not pl or not discount_enabled():
            user_states.pop(uid, None)
            step_reply(uid, chat_id, "امکان استفاده از کد تخفیف وجود ندارد.", get_back_keyboard())
            return
        d = get_valid_discount(to_en_digits(text))
        if not d:
            step_reply(uid, chat_id, "❌ کد تخفیف نامعتبر یا منقضی شده است. دوباره وارد کنید:", back)
            return
        if int(d[2]) >= int(pl[4] or 0):
            step_reply(uid, chat_id, "❌ این کد برای این پلن قابل استفاده نیست.", back)
            return
        _ok_d, _why_d = discount_applies(d, plan_id)
        if not _ok_d:
            step_reply(uid, chat_id, "❌ " + _why_d, back)
            return
        td["discount"] = {"code": d[1], "plan": plan_id}
        user_states.pop(uid, None)
        step_reply(uid, chat_id, f"✅ کد تخفیف اعمال شد: {int(d[2]):,} تومان تخفیف")
        show_payment_and_receipt_step(chat_id, td.get("last_msg_id"), uid)

    elif state == "waiting_report_chat_id":
        candidate = text.strip()
        try:
            chat_obj = bot.get_chat(candidate)
            if chat_obj.type not in ("group", "supergroup", "channel"):
                raise ValueError("گفت‌وگوی انتخاب‌شده گروه یا کانال نیست.")
            bot_member = bot.get_chat_member(chat_obj.id, bot.get_me().id)
            if bot_member.status not in ("administrator", "creator", "member"):
                raise ValueError("ربات هنوز عضو گروه نیست.")
            if get_setting("report_chat_id").strip() != str(chat_obj.id):
                clear_report_topics()
            set_setting("report_chat_id", str(chat_obj.id))
            user_states.pop(uid, None)
            step_reply(uid, chat_id, f"گروه گزارشات روی «{html.escape(chat_obj.title or candidate)}» تنظیم شد.")
            try:
                if getattr(chat_obj, "is_forum", False):
                    _ok, _msg = setup_report_topics(chat_obj.id)
                    send_message_safe(chat_id, _msg)
                else:
                    send_message_safe(
                        chat_id,
                        "ℹ️ Topics در این گروه روشن نیست؛ گزارش‌ها در چت اصلی گروه می‌آیند.\n"
                        "اگر هر گزارش تاپیک جدا می‌خواهید، Topics را روشن کنید و «ساخت تاپیک‌ها» را بزنید.")
            except Exception:
                traceback.print_exc()
            show_report_settings_menu(chat_id, td.get("last_msg_id"))
        except Exception as e:
            step_reply(uid, chat_id,
                       f"گروه ثبت نشد: {html.escape(str(e))}\n"
                       "ربات را به گروه اضافه کنید و آیدی گروه یا @یوزرنیم آن را دوباره بفرستید.",
                       cancel_kb("adm_report_settings"))

    elif state == "waiting_broadcast":
        users = dbx("SELECT user_id FROM users", (), "all")
        user_states.pop(uid, None)
        step_reply(uid, chat_id, f"در حال ارسال پیام به {len(users)} کاربر...")
        src_message = message

        def bcast_worker():
            ok_count, reasons = 0, {}
            try:
                for (u,) in users:
                    ok, why = _broadcast_send(u, chat_id, src_message)
                    if ok:
                        ok_count += 1
                    else:
                        reasons[why or "نامشخص"] = reasons.get(why or "نامشخص", 0) + 1
                    time.sleep(0.05)
            except Exception:
                traceback.print_exc()
            finally:
                send_message_safe(chat_id, _broadcast_summary(ok_count, reasons))

        threading.Thread(target=bcast_worker, daemon=True).start()

    elif state == "waiting_new_admin_id":
        val = parse_int(text)
        if val:
            kb = types.InlineKeyboardMarkup()
            kb.row(make_inline_button(text="دسترسی کامل (تنظیمات + سفارشات)", callback_data=f"perm_:full:{val}"),
                   make_inline_button(text="فقط مدیریت سفارشات", callback_data=f"perm_:orders:{val}"))
            kb.add(make_inline_button(text="انتخاب دسترسی دکمه‌به‌دکمه", callback_data=f"perm_menu:{val}"))
            kb.add(make_inline_button(text="انصراف", callback_data="adm_manage_admins"))
            user_states.pop(uid, None)
            step_reply(uid, chat_id, f"سطح دسترسی را برای ادمین <code>{val}</code> انتخاب کنید:", kb)
        else:
            step_reply(uid, chat_id, "لطفاً آیدی عددی معتبر ارسال کنید:", cancel_kb("adm_manage_admins"))

    elif state == "waiting_fj_chat_id":
        cid_input = to_en_digits(text).strip()
        try:
            chat_obj = bot.get_chat(cid_input)
            inv = chat_obj.invite_link
            if not inv:
                try:
                    inv = bot.export_chat_invite_link(chat_obj.id)
                except Exception:
                    inv = f"https://t.me/{chat_obj.username}" if chat_obj.username else ""
            dbx("INSERT OR REPLACE INTO force_join_channels (chat_id, title, invite_link, added_by) VALUES (?, ?, ?, ?)",
                (str(chat_obj.id), chat_obj.title or "کانال", inv, uid))
            user_states.pop(uid, None)
            step_reply(uid, chat_id, f"کانال «{chat_obj.title}» با موفقیت اضافه شد ✅")
            show_force_join_menu(chat_id, td.get("last_msg_id"))
        except Exception as e:
            step_reply(uid, chat_id, f"خطا: {str(e)}\nربات باید در کانال ادمین باشد. مجدداً آیدی را ارسال کنید:", cancel_kb("adm_force_join"))


# ==================== اجرای ربات ====================

# ==================== مینی‌اپ (وب‌سرور داخلی اختیاری) ====================
# اگر متغیر محیطی MINIAPP_PORT تنظیم شود، یک وب‌سرور ساده بالا می‌آید که صفحه مینی‌اپ را نشان می‌دهد.
# تلگرام فقط آدرس https قبول می‌کند؛ پس این پورت باید با دامنه/تونل https (مثل Cloudflare Tunnel) در دسترس باشد
# و همان آدرس عمومی در «تنظیم لینک مینی‌اپ» ثبت شود.
MINIAPP_HTML = r'''<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>مینی‌اپ</title>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;600;800&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root{--bg:var(--tg-theme-bg-color,#fff);--bg2:var(--tg-theme-secondary-bg-color,#f1f3f6);--tx:var(--tg-theme-text-color,#111);--hint:var(--tg-theme-hint-color,#7a808a);--ac:var(--tg-theme-button-color,#2f7cf6);--act:var(--tg-theme-button-text-color,#fff);--ok:#22a05a;--bad:#e5484d;--r:16px}
@media(prefers-color-scheme:dark){:root{--bg:var(--tg-theme-bg-color,#14181d);--bg2:var(--tg-theme-secondary-bg-color,#1d232b);--tx:var(--tg-theme-text-color,#eef1f4);--hint:var(--tg-theme-hint-color,#8b94a0)}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;background:var(--bg2);color:var(--tx);font-family:Vazirmatn,Tahoma,system-ui,sans-serif;font-size:15px}
body{padding:calc(env(safe-area-inset-top,0px) + 12px) 14px calc(env(safe-area-inset-bottom,0px) + 84px)}
h2{font-size:17px;margin:18px 4px 10px}
.card{background:var(--bg);border-radius:var(--r);padding:14px;margin-bottom:10px}
.row{display:flex;align-items:center;justify-content:space-between;gap:10px}
.hint{color:var(--hint);font-size:13px}
.btn{border:0;background:var(--ac);color:var(--act);border-radius:12px;padding:12px 16px;font:inherit;font-weight:600;width:100%;cursor:pointer}
.btn.sec{background:var(--bg2);color:var(--tx)}
.btn.sm{width:auto;padding:8px 14px;font-size:13px}
.btn:disabled{opacity:.5}
.hero{background:var(--ac);color:var(--act);border-radius:20px;padding:18px;margin-bottom:6px}
.hero b{font-size:24px;display:block;margin-top:4px}
.plan{cursor:pointer}
.plan:active{transform:scale(.99)}
.price{font-weight:800;color:var(--ac)}
.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
.chip{background:var(--bg2);border-radius:20px;padding:3px 10px;font-size:12px}
.bar{height:8px;background:var(--bg2);border-radius:8px;overflow:hidden;margin:10px 0 6px}
.bar i{display:block;height:100%;background:var(--ac);border-radius:8px}
.st{font-size:12px;padding:2px 10px;border-radius:20px;background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)}
.st.off{background:color-mix(in srgb,var(--bad) 18%,transparent);color:var(--bad)}
input[type=text],input[type=number]{width:100%;padding:12px;border-radius:12px;border:1px solid var(--bg2);background:var(--bg2);color:var(--tx);font:inherit;text-align:center;direction:ltr}
input[type=range]{width:100%;accent-color:var(--ac)}
nav{position:fixed;inset:auto 0 0 0;background:var(--bg);display:flex;padding:6px 6px calc(env(safe-area-inset-bottom,0px) + 6px);border-top:1px solid var(--bg2);z-index:5}
nav button{flex:1;border:0;background:none;color:var(--hint);font:inherit;font-size:12px;padding:6px 0;cursor:pointer}
nav button span{display:block;font-size:20px}
nav button.on{color:var(--ac);font-weight:600}
.sheet-bg{position:fixed;inset:0;background:#0008;z-index:9;display:none;align-items:flex-end}
.sheet-bg.show{display:flex}
.sheet{background:var(--bg);width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(env(safe-area-inset-bottom,0px) + 18px);max-height:85vh;overflow:auto}
.toast{position:fixed;top:calc(env(safe-area-inset-top,0px) + 10px);left:50%;transform:translateX(-50%);background:#222;color:#fff;padding:10px 18px;border-radius:12px;z-index:20;font-size:14px;opacity:0;transition:.25s;pointer-events:none}
.toast.show{opacity:1}
.empty{text-align:center;padding:30px 10px;color:var(--hint)}
.mono{direction:ltr;font-family:ui-monospace,monospace;letter-spacing:1px}
</style>
</head>
<body>
<div id="app"></div>
<nav id="nav"></nav>
<div class="sheet-bg" id="bg"><div class="sheet" id="sheet"></div></div>
<div class="toast" id="toast"></div>
<script>
// "." = همین سرور (وقتی صفحه از وب‌سرور داخلی ربات باز می‌شود). برای میزبانی جدا، آدرس کامل سرور ربات را بنویسید. خالی = حالت نمایشی
const API_BASE = ".";

const tg = window.Telegram && Telegram.WebApp;
if (tg) { tg.ready(); tg.expand(); }

const DEMO = {
  user: { name: "کاربر", balance: 250000 },
  groups: [
    { id: 1, name: "پلن‌های اقتصادی", plans: [
      { id: 11, name: "۲۰ گیگ / ۳۰ روزه", traffic_gb: 20, days: 30, price: 90000, description: "مناسب مصرف سبک" },
      { id: 12, name: "۵۰ گیگ / ۳۰ روزه", traffic_gb: 50, days: 30, price: 190000, description: "پرفروش‌ترین" } ],
      custom: { enabled: true, price: 3500, min: 5, max: 500, days: 30 } },
    { id: 2, name: "پلن‌های ویژه", plans: [
      { id: 21, name: "۱۰۰ گیگ / ۶۰ روزه", traffic_gb: 100, days: 60, price: 350000, description: "برای خانواده و تیم" } ],
      custom: { enabled: false } } ],
  services: [
    { id: 1, name: "سرویس اصلی", username: "user_a1b2", used_gb: 31.4, total_gb: 50, expire_ts: Math.floor(Date.now() / 1000) + 12 * 86400, is_disabled: 0, sub_url: "https://example.com/sub/abc123" },
    { id: 2, name: "سرویس تست", username: "test_77x", used_gb: 1, total_gb: 1, expire_ts: Math.floor(Date.now() / 1000) - 3600, is_disabled: 1, sub_url: "" } ],
  card: { number: "6037991234567890", holder: "نام صاحب کارت" },
  support: "support_id", channel: "https://t.me/telegram", test_enabled: true
};

DEMO.wallet_link = "";
DEMO.services = [
  { id: 1, name: "سرویس اصلی", username: "user_a1b2", sub_url: "https://example.com/sub/abc123", is_test: false,
    info: { status: "فعال", used: "31 GB", total: "50 GB", expire: "12 روز", is_ended: false },
    renew_plans: [{ id: 12, name: "۵۰ گیگ / ۳۰ روزه", traffic_gb: 50, days: 30, price: 190000 }] }];
let S = null, tab = "shop";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const money = n => Number(n || 0).toLocaleString("fa-IR") + " تومان";
const num = n => Number(n || 0).toLocaleString("fa-IR");

function toast(m) { const t = $("toast"); t.textContent = m; t.classList.add("show"); setTimeout(() => t.classList.remove("show"), 2200); }
function copy(txt) { navigator.clipboard?.writeText(txt).then(() => toast("کپی شد ✓"), () => toast("کپی نشد")); }

async function api(path, body) {
  if (!API_BASE) { await new Promise(r => setTimeout(r, 400)); return { ok: true, demo: true }; }
  const r = await fetch(API_BASE + path, {
    method: body ? "POST" : "GET",
    headers: { "Content-Type": "application/json", "X-Telegram-Init-Data": tg ? tg.initData : "" },
    body: body ? JSON.stringify(body) : undefined
  });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(j.error || "خطا در ارتباط با سرور");
  return j;
}

async function load() {
  try { S = API_BASE ? await api("/api/bootstrap") : DEMO; }
  catch (e) { $("app").innerHTML = `<div class="empty">${esc(e.message)}<br><br><button class="btn sm" onclick="load()">تلاش دوباره</button></div>`; return; }
  render();
}

function render() {
  const nav = [["shop", "🛒", "خرید"], ["mine", "📂", "سرویس‌ها"], ["wallet", "💰", "کیف پول"], ["help", "💬", "پشتیبانی"]];
  $("nav").innerHTML = nav.map(([k, i, t]) => `<button class="${tab === k ? "on" : ""}" onclick="go('${k}')"><span>${i}</span>${t}</button>`).join("");
  $("app").innerHTML = { shop, mine, wallet, help }[tab]();
}
function go(t) { tab = t; render(); scrollTo(0, 0); tg?.HapticFeedback?.selectionChanged(); }

function shop() {
  let h = `<div class="hero"><span class="hint" style="color:inherit;opacity:.8">موجودی کیف پول</span><b>${money(S.user.balance)}</b></div>`;
  if (S.test_enabled) h += `<button class="btn sec" onclick="getTest()">🎁 دریافت سرویس تست رایگان</button>`;
  S.groups.forEach(g => {
    h += `<h2>${esc(g.name)}</h2>`;
    g.plans.forEach(p => h += `<div class="card plan" onclick="openBuy(${g.id},${p.id})">
      <div class="row"><b>${esc(p.name)}</b><span class="price">${money(p.price)}</span></div>
      <div class="chips"><span class="chip">${num(p.traffic_gb)} گیگ</span><span class="chip">${num(p.days)} روز</span></div>
      ${p.description ? `<div class="hint" style="margin-top:8px">${esc(p.description)}</div>` : ""}</div>`);
    if (g.custom && g.custom.enabled) h += `<div class="card plan" onclick="openCustom(${g.id})"><div class="row"><b>✏️ حجم دلخواه</b><span class="hint">از ${num(g.custom.min)} تا ${num(g.custom.max)} گیگ</span></div></div>`;
  });
  return h;
}

function mine() {
  if (!S.services.length) return `<div class="empty">هنوز سرویسی ندارید.<br><br><button class="btn sm" onclick="go('shop')">خرید سرویس</button></div>`;
  return `<h2>سرویس‌های من</h2>` + S.services.map(s => {
    const i = s.info, off = !i || i.is_ended;
    return `<div class="card"><div class="row"><b>${esc(s.name || s.username)}</b><span class="st ${off ? "off" : ""}">${i ? (i.is_ended ? "غیرفعال" : "فعال") : "نامشخص"}</span></div>
      <div class="hint mono" style="margin-top:4px">${esc(s.username)}</div>
      ${i ? `<div class="row hint" style="margin-top:10px"><span>مصرف: ${esc(i.used)} از ${esc(i.total)}</span><span>${esc(i.expire)}</span></div>` : ""}
      <div class="row" style="margin-top:12px">${s.sub_url ? `<button class="btn sec sm" onclick="copy('${esc(s.sub_url)}')">📋 کپی لینک</button>` : "<span></span>"}
      ${s.is_test ? "<span></span>" : `<button class="btn sm" onclick="openRenew(${s.id})">🔄 تمدید</button>`}</div></div>`;
  }).join("");
}

function wallet() {
  return `<div class="hero"><span style="opacity:.8">موجودی فعلی</span><b>${money(S.user.balance)}</b></div>
  <h2>شارژ کیف پول</h2><div class="card">
    <div class="hint" style="margin-bottom:8px">مبلغ دلخواه (فقط برای یادآوری؛ مبلغ نهایی را در ربات تأیید می‌کنید):</div>
    <input type="number" id="amt" inputmode="numeric" placeholder="مثلاً 100000">
    <div class="chips" style="margin:10px 0">${[50000, 100000, 200000, 500000].map(v => `<span class="chip" onclick="amt.value=${v}" style="cursor:pointer">${num(v)}</span>`).join("")}</div>
    <div class="hint" style="margin-bottom:6px">کارت‌به‌کارت به این شماره:</div>
    <div class="card" style="background:var(--bg2);margin:0 0 10px;text-align:center" onclick="copy('${esc(S.card.number)}')">
      <div class="mono" style="font-size:18px">${esc(S.card.number).replace(/(\d{4})(?=\d)/g, "$1 ")}</div><div class="hint">${esc(S.card.holder)} · لمس برای کپی</div></div>
    <button class="btn" onclick="charge()">ادامه شارژ در ربات</button>
    <div class="hint" style="margin-top:8px">با زدن دکمه به چت ربات می‌روید؛ رسید را همان‌جا بفرستید تا تأیید شود.</div></div>`;
}

function help() {
  let h = `<h2>پشتیبانی</h2>`;
  if (S.support) h += `<div class="card"><button class="btn" onclick="open_('https://t.me/${esc(S.support)}')">💬 ارتباط با پشتیبانی</button></div>`;
  if (S.channel) h += `<div class="card"><button class="btn sec" onclick="open_('${esc(S.channel)}')">📢 کانال ما</button></div>`;
  return h + (S.support || S.channel ? "" : `<div class="empty">اطلاعات پشتیبانی ثبت نشده است.</div>`);
}
function open_(u) { tg ? tg.openTelegramLink(u) : window.open(u, "_blank"); }

function sheet(html) { $("sheet").innerHTML = html; $("bg").classList.add("show"); }
function closeSheet() { $("bg").classList.remove("show"); }
$("bg").addEventListener("click", e => { if (e.target.id === "bg") closeSheet(); });

let cur = null;
function openBuy(gid, pid) {
  const p = S.groups.find(g => g.id === gid).plans.find(x => x.id === pid);
  cur = { plan_id: pid, price: p.price };
  sheet(buySheet(p.name, `${num(p.traffic_gb)} گیگ · ${num(p.days)} روز`, ""));
}
function openCustom(gid) {
  const c = S.groups.find(g => g.id === gid).custom;
  cur = { group_id: gid, custom_gb: c.min, unit: c.price, price: c.min * c.price };
  sheet(buySheet("حجم دلخواه", `${num(c.days)} روز`, `<div class="row"><b id="gb">${num(c.min)} گیگ</b><span class="hint">${money(c.price)} / هر گیگ</span></div>
    <input type="range" min="${c.min}" max="${c.max}" value="${c.min}" oninput="cur.custom_gb=+this.value;cur.price=cur.custom_gb*cur.unit;gb.textContent=num(cur.custom_gb)+' گیگ';tot.textContent=money(cur.price)">`));
}
function buySheet(title, sub, extra) {
  return `<div class="row"><b style="font-size:17px">${title}</b><span class="hint">${sub}</span></div><div style="margin:14px 0">${extra}</div>
  <input type="text" id="dc" placeholder="کد تخفیف (اختیاری)" style="margin-bottom:12px">
  <div class="row" style="margin-bottom:12px"><span class="hint">مبلغ قابل پرداخت</span><b class="price" id="tot">${money(cur.price)}</b></div>
  <div class="hint" style="margin-bottom:6px">کد تخفیف هنگام پرداخت اعمال می‌شود.</div><div class="hint" style="margin-bottom:10px">پرداخت از موجودی کیف پول: ${money(S.user.balance)}</div>
  <button class="btn" id="pay" onclick="pay()">پرداخت و دریافت سرویس</button>`;
}
async function pay() {
  if (cur.price > S.user.balance) { toast("موجودی کافی نیست؛ ابتدا کیف پول را شارژ کنید"); closeSheet(); go("wallet"); return; }
  $("pay").disabled = true;
  try { await api("/api/buy", { ...cur, discount: $("dc").value.trim() }); closeSheet(); toast("خرید انجام شد ✓"); tg?.HapticFeedback?.notificationOccurred("success"); await load(); tab = "mine"; render(); }
  catch (e) { toast(e.message); $("pay").disabled = false; }
}
async function getTest() { try { await api("/api/test", {}); toast("سرویس تست ساخته شد ✓"); await load(); tab = "mine"; render(); } catch (e) { toast(e.message); } }
function openRenew(id) {
  const sv = S.services.find(x => x.id === id);
  if (!sv.renew_plans.length) return toast("برای این سرویس پلن تمدید ثبت نشده است");
  sheet(`<b style="font-size:17px">تمدید ${esc(sv.name || sv.username)}</b><div style="margin-top:12px">` + sv.renew_plans.map(p => `<div class="card plan" style="background:var(--bg2)" onclick="pickRenew(${id},${p.id})"><div class="row"><b>${esc(p.name)}</b><span class="price">${money(p.price)}</span></div><div class="chips"><span class="chip">${num(p.traffic_gb)} گیگ</span><span class="chip">${num(p.days)} روز</span></div></div>`).join("") + `</div>`);
}
function pickRenew(id, pid) {
  const p = S.services.find(x => x.id === id).renew_plans.find(x => x.id === pid);
  cur = { plan_id: pid, price: p.price, renew_panel_id: id };
  sheet(buySheet(p.name, `${num(p.traffic_gb)} گیگ · ${num(p.days)} روز`, ""));
}
function charge() {
  const v = $("amt").value; if (v && +v < 1000) return toast("مبلغ معتبر وارد کنید");
  open_(S.wallet_link); if (tg) setTimeout(() => tg.close(), 400);
}
load();
</script>
</body>
</html>
'''


def miniapp_menu_items():
    """دکمه‌های نمایان منوی اصلی به ترتیب چینش، همراه با لینک ورود به ربات."""
    try:
        positions = json.loads(get_setting("btn_positions") or "{}")
    except Exception:
        positions = {}
    try:
        visible = set(json.loads(get_setting("visible_main_buttons") or "[]"))
    except Exception:
        visible = set(MAIN_BTN_IDS)
    order = ["top_single", "mid_right", "mid_left", "bot_right", "bot_left", "bot_single"]
    ids = [b for b in MAIN_BTN_IDS if b in visible and b != "btn_miniapp"]
    ids.sort(key=lambda b: order.index(positions.get(b)) if positions.get(b) in order else len(order))
    items = []
    for b in ids:
        label = get_button_display(b) or b
        if b == "btn_channel":
            link = get_setting("channel_link")
            if is_valid_url(link):
                items.append({"label": label, "link": link, "close": False})
            continue
        link = deeplink_for(b)
        if link:
            items.append({"label": label, "link": link, "close": True})
    return items


class MiniErr(Exception):
    def __init__(self, msg, code=400):
        super().__init__(msg)
        self.code = code


def miniapp_auth(init_data):
    """اعتبارسنجی initData تلگرام (HMAC)؛ در صورت معتبر بودن اطلاعات کاربر را برمی‌گرداند."""
    import hmac
    import hashlib
    from urllib.parse import parse_qsl
    try:
        pairs = dict(parse_qsl(init_data or "", keep_blank_values=True))
        got = pairs.pop("hash", "")
        if not got or not TOKEN:
            return None
        check = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
        secret = hmac.new(b"WebAppData", TOKEN.encode(), hashlib.sha256).digest()
        calc = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
        if not hmac.compare_digest(calc, got):
            return None
        if time.time() - int(pairs.get("auth_date", "0") or 0) > 86400:
            return None
        u = json.loads(pairs.get("user") or "{}")
        return u if u.get("id") else None
    except Exception:
        return None


def miniapp_guard(uid, force_join=False):
    if is_user_blocked(uid):
        raise MiniErr("دسترسی شما مسدود شده است.", 403)
    if bot_is_off_for(uid):
        raise MiniErr("ربات موقتاً خاموش است.", 403)
    if force_join and get_missing_channels(uid):
        raise MiniErr("ابتدا در کانال‌های ربات عضو شوید و دوباره تلاش کنید.", 403)


def _mini_plan(p):
    return {"id": p[0], "name": p[1], "traffic_gb": int(p[2] or 0), "days": int(p[3] or 0),
            "price": int(p[4] or 0), "description": p[5] or ""}


def miniapp_bootstrap(u):
    uid = int(u["id"])
    miniapp_guard(uid)
    dbx("INSERT OR IGNORE INTO users (user_id) VALUES (?)", (uid,))
    row = dbx("SELECT wallet_balance FROM users WHERE user_id = ?", (uid,), "one")
    balance = int(row[0] or 0) if row else 0

    groups = []
    for g in get_plan_groups() or []:
        plans = [_mini_plan(p) for p in (get_plans_in_group(g[0]) or [])]
        custom = {"enabled": bool(g[3]) and int(g[4] or 0) > 0, "price": int(g[4] or 0),
                  "min": max(1, int(g[5] or 1)), "max": int(g[6] or 100), "days": int(g[7] or 0)}
        if plans or custom["enabled"]:
            groups.append({"id": g[0], "name": g[1], "plans": plans, "custom": custom})
    loose = [_mini_plan(p) for p in (get_plans_in_group(0) or [])]
    if loose:
        groups.append({"id": 0, "name": "پلن‌ها", "plans": loose, "custom": {"enabled": False}})

    rows = dbx("SELECT id, name, username, url, is_test, panel_id FROM user_panels "
               "WHERE user_id = ? ORDER BY id DESC LIMIT 20", (uid,), "all") or []
    all_plans = get_all_shop_plans() or []

    def _info(r):
        try:
            ok, d = get_pasarguard_user_info(r[2], r[5])
            return d if ok and isinstance(d, dict) else None
        except Exception:
            return None

    from concurrent.futures import ThreadPoolExecutor
    with ThreadPoolExecutor(max_workers=6) as ex:
        infos = list(ex.map(_info, rows))
    services = []
    for r, info in zip(rows, infos):
        services.append({
            "id": r[0], "name": r[1], "username": r[2], "sub_url": r[3] or "", "is_test": bool(r[4]),
            "info": info,
            "renew_plans": [_mini_plan(p) for p in renew_plans_for_server(r[5])]})

    supp = (get_setting("support_user") or "").replace("@", "").strip()
    ch = get_setting("channel_link")
    return {
        "user": {"name": u.get("first_name") or "کاربر", "balance": balance},
        "groups": groups, "services": services,
        "card": {"number": get_setting("card_number") or "", "holder": get_setting("card_holder") or ""},
        "support": "" if supp in ("", "None") else supp,
        "channel": ch if is_valid_url(ch) else "",
        "test_enabled": get_setting("test_panel_enabled") != "0" and can_get_test(uid),
        "wallet_link": deeplink_for("btn_wallet") or f"https://t.me/{get_bot_username()}",
    }


def miniapp_buy(u, body):
    uid = int(u["id"])
    miniapp_guard(uid, force_join=True)
    renew_id = int(body.get("renew_panel_id") or 0)
    svc = None
    if renew_id:
        svc = dbx("SELECT panel_id FROM user_panels WHERE id = ? AND user_id = ?", (renew_id, uid), "one")
        if not svc:
            raise MiniErr("سرویس پیدا نشد.")
    if body.get("custom_gb"):
        g = get_plan_group(int(body.get("group_id") or 0))
        gb = int(body.get("custom_gb") or 0)
        if (not g or not g[3] or int(g[4] or 0) <= 0
                or not (max(1, int(g[5] or 1)) <= gb <= int(g[6] or 100))):
            raise MiniErr("حجم دلخواه نامعتبر است.")
        plan_id = get_or_create_custom_plan(g, gb)
    else:
        plan_id = int(body.get("plan_id") or 0)
    plan = get_shop_plan_by_id(plan_id)
    if not plan:
        raise MiniErr("این پلن دیگر وجود ندارد.")
    if svc and not renew_plan_allowed(plan, svc[0]):
        raise MiniErr("این پلن برای سرور این سرویس نیست.")
    final, _off, code = discounted_price(int(plan[4] or 0), (body.get("discount") or "").strip(), plan_id)
    token = save_draft(uid, plan_id, build_shared_username(uid, "u" + secrets.token_hex(4)), "", renew_id, amount=final, discount_code=code)
    msg = bot.send_message(uid, "⏳ در حال انجام خرید از مینی‌اپ...")
    ok, text = process_wallet_payment(uid, msg.message_id, uid, token)
    if not ok:
        raise MiniErr(str(text), 400)
    return {"ok": True, "message": text}


def miniapp_test(u):
    uid = int(u["id"])
    miniapp_guard(uid, force_join=True)
    if get_setting("test_panel_enabled") == "0":
        raise MiniErr("تست پنل غیرفعال است.")
    if not can_get_test(uid):
        raise MiniErr("شما قبلاً تست خود را دریافت کرده‌اید.")
    q = "SELECT COUNT(*) FROM user_panels WHERE user_id = ? AND is_test = 1"
    before = int(dbx(q, (uid,), "one")[0])
    msg = bot.send_message(uid, "⏳ در حال ساخت تست از مینی‌اپ...")
    create_test_account(uid, msg.message_id, uid, "t" + secrets.token_hex(4), "")
    if int(dbx(q, (uid,), "one")[0]) <= before:
        raise MiniErr("ساخت تست انجام نشد؛ پیام ربات را ببینید.")
    return {"ok": True}


_app_ready = False


def run_cron_tick():
    """کارهای دوره‌ای (به‌جای ترد‌های پس‌زمینه). با قفل فایل از اجرای هم‌زمان جلوگیری می‌شود."""
    import fcntl
    lock = open(os.path.join(BASE_DIR, "cron.lock"), "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        return "busy"
    try:
        init_app()
        now = int(time.time())
        if now - int(get_setting("cron_checker_ts") or 0) >= 170:
            set_setting("cron_checker_ts", str(now))
            background_check_once()
        backup_check_once()
        if now - int(get_setting("cron_cleanup_ts") or 0) >= 3600:
            set_setting("cron_cleanup_ts", str(now))
            kv_cleanup()
            dbx("DELETE FROM processed_updates WHERE ts < ?", (now - 86400,))
        return "ok"
    finally:
        try:
            fcntl.flock(lock, fcntl.LOCK_UN)
            lock.close()
        except Exception:
            pass


def init_app():
    """یک‌بار در هر پروسه: ساخت/مهاجرت دیتابیس."""
    global _app_ready
    if _app_ready:
        return
    if not TOKEN:
        raise RuntimeError("BOT_TOKEN تنظیم نشده است (متغیر محیطی یا local_config.py).")
    init_db()
    _kv_init()
    dbx("CREATE TABLE IF NOT EXISTS processed_updates (update_id INTEGER PRIMARY KEY, ts INTEGER)")
    _app_ready = True
