<?php

declare(strict_types=1);

/**
 * CallbackHandler — processes inline-keyboard button presses.
 *
 * v2.5 callback_data vocabulary:
 *   Main menu: back_to_main_menu, game, withdrawals, tasks, reward, sub, profile, league, support
 *   Referral hub (T46): refhub, refview_toman, refview_token, refstats, league_ref
 *   Gender: gender_boy, gender_girl, change_gender
 *   Game mode: game_anon, game_friends
 *   Game type: gametype_anon_<type>, gametype_friends_<type>
 *   Game entry fee (anonymous only): gamefee_<mode>_<type>_<minus|plus|start>_<fee>
 *   Game match preference (anonymous only): gamematch_<mode>_<type>_<fee>_<pref>
 *         where pref: 0=any, 1=boy, 2=girl
 *   Game moves/joins: gmove_<sessionId>_<move>, gjoin_<sessionId>,
 *         gjoin_pending_<inviterId>
 *   Game cancel (waiting session): gcancel_<sessionId>
 *   Game viewer (browse waiting games):
 *         gameviewer_0              — show list of waiting games
 *         gameviewdetail_<sessionId> — show details of one waiting game
 *         gameviewjoin_<sessionId>   — join a waiting game from the viewer
 *   Game cancel-request flow (active session):
 *         gcancelreq_<sessionId>      — player A requests cancel
 *         gcancelaccept_<sessionId>   — player B taps "accept"
 *         gcancelconfirm_<sessionId>  — player B confirms cancel
 *         gcancelreject_<sessionId>   — player B rejects the cancel request
 *         gcancelback_<sessionId>     — player A takes back their request
 *   Withdrawal: withdraw_new, withdraw_charge, withdraw_stars, withdraw_card,
 *         withdraw_charge_<operator>, wdrawreply_<id>, wdrawclose_<id>, wdrawanswer_<id>
 *   NTX: ntx, ntx_convert, ntx_convert_yes, ntx_cancel
 *   Task: task_start_<id>, task_verify_<id>
 *   Support (preserved): support, supnew, supreply_<id>, supclose_<id>, support_answer_<id>
 *   noop: display-only button
 *
 * The handler dispatches based on the callback_data prefix. Each handler is
 * responsible for editing the source message (or sending a new one) and
 * answering the callback query.
 *
 * Gender gate: ALL callbacks (except gender_*, gmove_*, gjoin_*, gcancel*,
 * gjoin_pending_*, gameviewer_*, gameviewdetail_*, gameviewjoin_*) are
 * blocked until the user has selected a gender. Game callbacks bypass the
 * gate so an in-progress game can continue.
 */

class CallbackHandler
{
    private TelegramBot $bot;
    private array $config;
    private string $currentCallbackId = '';

    public function __construct(TelegramBot $bot, array $config)
    {
        $this->bot = $bot;
        $this->config = $config;
    }

    public function handle(array $update): void
    {
        $cq = $update['callback_query'] ?? null;
        if ($cq === null) return;

        // v2.9.6: Support inline messages. When a callback comes from an
        // inline message (sent via inline mode), $cq['message'] is absent
        // and $cq['inline_message_id'] is present instead.
        $inlineMessageId = isset($cq['inline_message_id']) ? (string)$cq['inline_message_id'] : '';
        $isInline = ($inlineMessageId !== '');

        $chatId = $isInline ? 0 : (int)$cq['message']['chat']['id'];
        $messageId = $isInline ? 0 : (int)$cq['message']['message_id'];
        $callbackId = (string)$cq['id'];
        $userId = (int)$cq['from']['id'];
        $data = (string)($cq['data'] ?? '');
        $chatType = $isInline ? 'inline' : ($cq['message']['chat']['type'] ?? 'private');

        $this->currentCallbackId = $callbackId;

        // vNEXT: Do NOT send the generic "processing" ack upfront anymore.
        // Telegram allows only ONE answerCallbackQuery per callback id, so an
        // early ack silently masked every later alert-style answer (error
        // alerts, join failures, NTX/withdrawal/ticket notices, ...). The whole
        // processing body now runs inside try/catch/finally below: specific
        // answers win, and if nothing answered, the finally sends the SAME
        // generic ack the removed code used to send.
        try {
            $this->processCallback($isInline, $inlineMessageId, $chatId, $messageId, $callbackId, $userId, $data, $chatType);
        } catch (\Throwable $e) {
            error_log('[CALLBACK] handle error: ' . $e->getMessage());
        } finally {
            if (!TelegramBot::hasAnsweredCallback($callbackId)) {
                $this->bot->answerCallback($callbackId, $this->msg('processing'));
            }
        }
    }

    /**
     * vNEXT: Main callback processing body — extracted from handle() so it
     * can run inside handle()'s try/catch/finally (generic-ack safety net).
     */
    private function processCallback(bool $isInline, string $inlineMessageId, int $chatId, int $messageId, string $callbackId, int $userId, string $data, string $chatType): void
    {
        // v2.9.32: Check if user is banned — show alert on any button press
        $userData = Database::loadUser($userId);
        if ($userData !== null && !empty($userData['banned'])) {
            $this->bot->answerCallback($callbackId, '🚫 شما از ربات بن شده‌اید. در صورت اشتباه با پشتیبانی تماس بگیرید.', true);
            return;
        }
        // ── Gender selection callbacks — handle BEFORE the gender gate.
        //    These are reached by new users tapping the boy/girl buttons on
        //    the gender-prompt keyboard we sent them after registration.
        //    (Inline messages can't have gender callbacks, so skip for inline)
        if (!$isInline && ($data === 'gender_boy' || $data === 'gender_girl')) {
            $gender = $data === 'gender_boy' ? 1 : 2;
            Database::setUserGender($userId, $gender);
            $this->bot->answerCallback($callbackId);
            $this->bot->editMessage($userId, $messageId, emoji(1) . ' ' . Settings::getMessage('gender_set', $this->config), mainMenuKeyboard());
            return;
        }

        // ── v2.9.22: Rules confirmation callback — enforce 15-second reading delay.
        //    Callback format: rules_confirm_<timestamp>
        //    If tapped before 15 seconds: show alert "must read first".
        //    If tapped after 15 seconds: edit the message to show gender buttons.
        if (!$isInline && preg_match('/^rules_confirm_(\d+)$/', $data, $m)) {
            $sentAt = (int)$m[1];
            $elapsed = time() - $sentAt;
            if ($elapsed < 15) {
                $remaining = 15 - $elapsed;
                $this->bot->answerCallback($callbackId, "⏳ لطفاً قوانین رو بخون! {$remaining} ثانیه دیگه صبر کن.", true);
                return;
            }
            // 15 seconds passed — edit the rules message to show gender buttons
            $this->bot->answerCallback($callbackId, '✅ ممنون که خوندی!');
            $this->bot->editMessage($userId, $messageId, emoji(40) . ' ' . Settings::getMessage('gender_prompt', $this->config), genderSelectionKeyboard());
            return;
        }

        // ── v2.9.6: Inline game configuration callbacks (from inline messages)
        //    These handle the fee/rounds steppers + start button on the inline
        //    game-setup message. They bypass the gender gate and user checks
        //    because the user is configuring a friends game.
        if ($isInline) {
            $this->handleInlineCallback($userId, $inlineMessageId, $callbackId, $data);
            return;
        }

        // ── Game-move callbacks: handle BEFORE the generic answerCallback so
        //    we can show a proper alert (show_alert=true) for errors like
        //    "not your turn". Telegram only allows ONE answerCallbackQuery
        //    per callback, so the generic "processing" answer would consume it.
        //    These also BYPASS the gender gate (the user is already in a game).
        if ($move = Game::parseMoveCallback($data)) {
            $this->handleGameMove($userId, $move['sessionId'], $move['move'], $chatId, $messageId);
            return;
        }
        if ($joinId = Game::parseJoinCallback($data)) {
            // vNEXT: no upfront ack — handleGameJoin answers its own error
            // alerts (ONE answer per callback); if it answers nothing,
            // handle()'s finally sends the generic ack.
            $this->handleGameJoin($userId, $joinId, $chatId, $messageId);
            return;
        }
        if (preg_match('/^gjoin_pending_(\d+)$/', $data, $m)) {
            $inviterId = (int)$m[1];
            $sessionId = (int)(Database::getSetting('pending_friends_session_' . $inviterId) ?? '0');
            if ($sessionId > 0) {
                // vNEXT: no upfront ack (see gjoin_ above) — handleGameJoin
                // answers its own error alerts.
                $this->handleGameJoin($userId, $sessionId, $chatId, $messageId);
            } else {
                $this->bot->answerCallback($callbackId, 'بازی پیدا نشد یا منقضی شده است.', true);
            }
            return;
        }

        // ── v2.9.35: Truth or Dare game action callbacks (bypass gender gate)
        //    tod_begin_<sid>  → start game (need ≥2 players)
        //    tod_join_<sid>   → join game
        //    tod_truth_<sid>  → pick truth
        //    tod_dare_<sid>   → pick dare
        //    tod_change_<sid> → change question
        //    tod_done_<sid>   → done, next player
        //    tod_end_<sid>    → end game
        // v2.9.39: Use handleTODBoardAction (not handleTODCallback) so that
        // after the action, ALL related messages are synced: the creator's PV
        // board, the shared inline message, AND the group message. The old
        // handleTODCallback only edited the SINGLE message the button was on,
        // which left the shared inline board stale for anonymous mode.

        // vNEXT (14-b/U6): TOD rules guide — tod_guide_<sessionId>. Public
        // info (no ownership check): answers the callback, then sends the
        // game rules as a NEW message to the chat the board lives in.
        if (preg_match('/^tod_guide_(\d+)$/', $data, $m)) {
            $this->handleTODGuide($userId, (int)$m[1], $chatId, $callbackId, false);
            return;
        }
        if (preg_match('/^tod_(begin|join|truth|dare|change|done|end)_(\d+)$/', $data, $m)) {
            $this->handleTODBoardAction($userId, $m[1], (int)$m[2], $messageId);
            return;
        }

        // ── v3.2 (T42-f) CRITICAL FIX: spy board actions were ONLY reachable
        //    through dispatchGroup() — whose entry is gated by the
        //    $isGroupGameCb whitelist at the group branch below, which never
        //    listed ANY spy_* pattern. Result: EVERY spy button was silently
        //    dead since T42-c — the group waiting board (join/begin/end/vote),
        //    the spy's private location-guess list, the PV ballots AND the
        //    «برگشت به کارت من» card navigation. Route ALL spy board taps
        //    here (works for both PV and group chats; bypasses the gender
        //    gate like every other game action — the engine's per-action
        //    gates still apply inside Game::handleSpyAction).
        if (preg_match('/^spy_(join|begin|end|vote)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, $m[1], (int)$m[2], 0, $callbackId);
            return;
        }
        if (preg_match('/^spy_guess_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'openguess', (int)$m[1], 0, $callbackId);
            return;
        }
        if (preg_match('/^spy_votec_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'cast', (int)$m[1], (int)$m[2], $callbackId);
            return;
        }
        if (preg_match('/^spy_loc_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'guess', (int)$m[1], (int)$m[2], $callbackId);
            return;
        }
        // spy_card_<sid> — back to my card (PV navigation; no engine action).
        if (preg_match('/^spy_card_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $impl = Game::load('spy');
            $this->bot->editMessage($chatId, $messageId, $impl->getStatusText($session, $userId, $this->config), $impl->renderKeyboard($session, $userId));
            $this->bot->answerCallback($callbackId);
            return;
        }

        // v2.9.22: Force-end a TOD game (from /close command confirmation)
        //   tod_force_end_<sid> → only the creator can force-end
        if (preg_match('/^tod_force_end_(\d+)$/', $data, $m)) {
            $sessionId = (int)$m[1];
            $session = Database::getGameSession($sessionId);
            if ($session === null) {
                $this->bot->answerCallback($callbackId, 'بازی پیدا نشد.', true);
                return;
            }
            // v3.3 (T45): EFFECTIVE owner — ownership transfer aware.
            if (Game::sessionOwnerId($session) !== $userId) {
                $this->bot->answerCallback($callbackId, 'فقط مالک بازی می‌تواند آن را پایان دهد.', true);
                return;
            }
            // vNEXT (G-B9): pre-check status — a finished/cancelled session must
            // NOT be flipped back to 'cancelled' by the unguarded update below.
            if (in_array((string)($session['status'] ?? ''), ['finished', 'cancelled'], true)) {
                $this->bot->answerCallback($callbackId, 'بازی پایان یافته است.', true);
                return;
            }
            // v2.9.45 (R6-52/LOW): route through handleTODAction 'end' — it
            // performs the guarded terminal claim, refreshes the boards AND
            // runs cleanupSessionKv (tod_force_end_ was the only terminal TOD
            // transition that leaked group/inline/joiner/cancel KVs).
            $endResult = Game::handleTODAction($sessionId, $userId, 'end', $this->bot, $this->config);
            if (!$endResult['success']) {
                $this->bot->answerCallback($callbackId, 'بازی پایان یافته است.', true);
                return;
            }
            $this->bot->answerCallback($callbackId, '✅ بازی پایان یافت.');
            // Edit the confirmation message
            // v2.9.45 (R6-100/MEDIUM): group /close sends the confirm keyboard to
            // the GROUP chat — editing (creator PV, group msg id) 400'd or, on id
            // collision, overwrote an unrelated private message. Target the chat
            // the button actually lives in (groups have $chatId < 0).
            if ($chatId < 0) {
                $this->bot->editMessage($chatId, $messageId, emoji(32) . ' بازی جرات یا حقیقت پایان یافت.');
            } else {
                $this->bot->editMessage($userId, $messageId, emoji(32) . ' بازی جرات یا حقیقت پایان یافت.');
            }
            return;
        }

        // ── Game cancel-request flow (request/accept/confirm/reject/back).
        //    These operate on ACTIVE games and must bypass the gender gate.
        //    Each is wrapped in try-catch so a Telegram API hiccup doesn't
        //    crash the webhook — the game state is still saved to the DB.
        if (preg_match('/^gcancelreq_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            try {
                Game::requestCancel((int)$m[1], $userId, $this->bot, $this->config);
            } catch (\Throwable $e) {
                error_log('[gcancelreq] exception sid=' . ($m[1] ?? '?') . ' user=' . $userId . ' — ' . $e->getMessage());
            }
            return;
        }
        if (preg_match('/^gcancelaccept_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            try {
                Game::acceptCancelRequest((int)$m[1], $userId, $this->bot, $this->config);
            } catch (\Throwable $e) {
                error_log('[gcancelaccept] exception sid=' . ($m[1] ?? '?') . ' user=' . $userId . ' — ' . $e->getMessage());
            }
            return;
        }
        if (preg_match('/^gcancelconfirm_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            try {
                Game::confirmCancel((int)$m[1], $userId, $this->bot, $this->config);
            } catch (\Throwable $e) {
                error_log('[gcancelconfirm] exception sid=' . ($m[1] ?? '?') . ' user=' . $userId . ' — ' . $e->getMessage());
            }
            return;
        }
        if (preg_match('/^gcancelreject_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            try {
                Game::rejectCancel((int)$m[1], $userId, $this->bot, $this->config);
            } catch (\Throwable $e) {
                error_log('[gcancelreject] exception sid=' . ($m[1] ?? '?') . ' user=' . $userId . ' — ' . $e->getMessage());
            }
            return;
        }
        if (preg_match('/^gcancelback_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            try {
                Game::cancelCancelRequest((int)$m[1], $userId, $this->bot, $this->config);
            } catch (\Throwable $e) {
                error_log('[gcancelback] exception sid=' . ($m[1] ?? '?') . ' user=' . $userId . ' — ' . $e->getMessage());
            }
            return;
        }
        // gcancel_<sessionId> — cancelling a WAITING session (before the game starts)
        if ($cancelId = Game::parseCancelCallback($data)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            $this->handleGameCancel($userId, $cancelId, $chatId, $messageId);
            return;
        }

        // ── Game viewer (browse/join waiting games). These are game-related
        //    callbacks and bypass the gender gate. gameviewer_0 and
        //    gameviewdetail_* just ack-then-edit. gameviewjoin_* does NOT
        //    ack first — it may need to show an alert (e.g. insufficient
        //    balance) and Telegram only allows ONE answerCallbackQuery per
        //    callback, so the join method calls answerCallback itself.
        if (preg_match('/^gameviewer_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId, $this->msg('processing'));
            $this->showGameViewer($userId, $messageId);
            return;
        }
        if (preg_match('/^gameviewdetail_(\d+)$/', $data, $m)) {
            // vNEXT: no upfront ack — showGameViewerDetail answers its own
            // "this game is gone" alert; if it answers nothing, handle()'s
            // finally sends the generic ack.
            $this->showGameViewerDetail($userId, $messageId, (int)$m[1]);
            return;
        }
        if (preg_match('/^gameviewjoin_(\d+)$/', $data, $m)) {
            $this->joinGameFromViewer($userId, $messageId, (int)$m[1]);
            return;
        }

        // ── T46-m: Mafia voice-game callbacks. These are GAME actions and
        //    bypass the gender gate like every other game callback (the
        //    engine's per-action gates apply inside MafiaGame). The lobby
        //    buttons also live in GROUPS, so they must sit BEFORE the
        //    group-callback whitelist below.
        if (preg_match('/^mafia_(join|leave|start|cancel|role)_(\d+)$/', $data, $m)) {
            // T46-p: mafia join/create ran before every gate — a
            // satisfaction-blocked user could play (and a genderless/
            // unregistered one debit/insert). Same gate as gjoin_/spy_join.
            if (in_array($m[1], ['join', 'create'], true) && !$this->gateGameJoin($userId, $callbackId)) {
                return;
            }
            MafiaGame::routeLobbyAction($m[1], (int)$m[2], $userId, $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_act_(\d+)_(kill|save|check|shoot)_(\d+)$/', $data, $m)) {
            MafiaGame::handleNightAction($userId, (int)$m[1], $m[2], (int)$m[3], $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_pass_(\d+)$/', $data, $m)) {
            MafiaGame::handlePass($userId, (int)$m[1], $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_vote_(\d+)_(\d+)$/', $data, $m)) {
            MafiaGame::handleDayVote($userId, (int)$m[1], (int)$m[2], $this->bot, $this->config, $callbackId);
            return;
        }
        // ── T46-af (round 47): the full day system — speech turns, speech
        //    challenges, defense, final vote. Player operations live in the
        //    bot's PM (round 44 contract), so these sit with the other early
        //    mafia game routes, before the group whitelist gate.
        if (preg_match('/^mafia_spdone_(\d+)$/', $data, $m)) {
            MafiaGame::handleSpeechDone($userId, (int)$m[1], $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_spchal_(\d+)_(\d+)$/', $data, $m)) {
            MafiaGame::handleSpeechChallenge($userId, (int)$m[1], (int)$m[2], $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_dfdone_(\d+)$/', $data, $m)) {
            MafiaGame::handleDefenseDone($userId, (int)$m[1], $this->bot, $this->config, $callbackId);
            return;
        }
        if (preg_match('/^mafia_vfin_(\d+)_(\d+)$/', $data, $m)) {
            MafiaGame::handleFinalVote($userId, (int)$m[1], (int)$m[2], $this->bot, $this->config, $callbackId);
            return;
        }
        // ── T46-ae: like the best player of a finished game. Game action
        //    (PM-only per round 44) — sits with the other early mafia
        //    routes, before the group whitelist/registration gate.
        if (preg_match('/^mafia_like_(\d+)_(\d+)$/', $data, $m)) {
            MafiaMeta::handleLike($userId, (int)$m[1], (int)$m[2], $this->bot, $this->config, $callbackId);
            return;
        }
        // ── T46-y: in-group mafia settings (round 41) — group admins
        //    configure the game from the group. Admin/config actions sit
        //    with the other early mafia routes (before the registration
        //    gate); MafiaGame's own getChatMember gate is authoritative.
        if ($data === 'mafia_settings' || $data === 'mafia_grp_reg'
            || str_starts_with($data, 'mafia_setmenu_') || str_starts_with($data, 'mafia_set_')
            || str_starts_with($data, 'mafia_tgl_')) {
            MafiaGame::handleGroupSettingsCallback($data, $chatId, $messageId, $userId, $this->bot, $this->config, $callbackId);
            return;
        }

        // ── For all other callbacks, we need a registered user.
        // v2.9.32: userData already loaded above (for ban check)
        // v2.9.39: The tod_* board-action handler above (line ~162) now uses
        // handleTODBoardAction which syncs ALL messages (PV + inline + group).
        if ($userData === null) {
            $this->bot->answerCallback($callbackId);
            $this->registerNewUser($userId);
            return;
        }

        // ── Gender gate — must be set before using any other bot feature.
        //    Game callbacks (above) bypass this since the user is already in a game.
        if ((int)($userData['gender'] ?? 0) === 0) {
            // vNEXT: In groups the tapped message lives in the GROUP chat —
            // editMessage($userId /*PV*/, $messageId /*group msg id*/) would
            // edit the wrong chat / fail. Point the user to the bot's PV.
            if ($chatType !== 'private') {
                $this->bot->answerCallback($callbackId, 'برای تنظیم جنسیت، ربات را در پیام خصوصی باز کنید', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->bot->editMessage($userId, $messageId, emoji(40) . ' ' . $this->msg('gender_prompt'), genderSelectionKeyboard());
            return;
        }

        // ── Satisfaction gate: a blocked user can only interact with the
        //    satisfaction-related UI. All other buttons are ignored.
        if (!empty($userData['satisfactionPending'])) {
            $this->bot->sendMessage($userId, emoji(48) . ' ' . $this->msg('satisfaction_prompt'));
            return;
        }

        // ── Group-chat handling: in groups, only game-related callbacks work.
        //    Menu buttons (withdrawals, tasks, etc.) only work in private chats.
        //    v2.9.18: Group games are now supported — dispatch game callbacks
        //    to the group handler which edits the GROUP message (not user PV).
        if ($chatType !== 'private') {
            // Check if this is a group game callback
            // v2.9.35: Added todcat_group_ for category toggle
            // v3.2 (T42-f) CRITICAL FIX: gamecat_group_<cat>_<page> (the
            //   category buttons + pagination of the group game picker) and
            //   spyfee/spystart_group_ were NEVER whitelisted here — every
            //   category tap in the group /game menu (and every spy fee/start
            //   tap) died silently; the owner saw «هیچ واکنشی نشون نمیدن».
            //   The handlers already exist in dispatchGroup().
            $isGroupGameCb = preg_match('/^gametype_group_/', $data)
                || preg_match('/^gamefee_group_/', $data)
                || preg_match('/^gamerounds_group_/', $data)
                || preg_match('/^gamestart_group_/', $data)
                || preg_match('/^todfee_group_/', $data)
                || preg_match('/^todchanges_group_/', $data)
                || preg_match('/^todstart_group_/', $data)
                || preg_match('/^todcat_group_/', $data)
                || preg_match('/^gamecat_group_(\w+)_(\d+)$/', $data)
                || preg_match('/^spyfee_group_/', $data)
                || preg_match('/^spystart_group_/', $data)
                || preg_match('/^gjoin_(\d+)$/', $data)
                || preg_match('/^gcancel/', $data)
                || preg_match('/^tod_(begin|join|truth|dare|change|done|end)_(\d+)$/', $data)
                || $data === 'noop'
                || $data === 'back_to_main_menu'
                || $data === 'group_game_menu'
                // v3.3 (T46-n): «مافیا صوتی» group-picker entry (group/channel only).
                || $data === 'gametype_group_mafiavoice'
                // T46-p: the group mafia CTA (dispatched in dispatchGroup above).
                || $data === 'mafia_create';

            if ($isGroupGameCb) {
                try {
                    $this->dispatchGroup($userId, $chatId, $messageId, $userData, $data, $callbackId);
                } catch (\Throwable $e) {
                    error_log('[CALLBACK] group dispatch error: ' . $e->getMessage());
                }
            } else {
                // v3.2 (T42-f): NEVER leave a group tap unanswered again —
                // an explicit alert beats an eternal loading spinner.
                $this->bot->answerCallback($callbackId, 'این دکمه در گروه فعال نیست — ربات را در پیام خصوصی باز کن.', true);
            }
            return;
        }

        // ── Membership gate (mandatory channels)
        if (!$this->checkMembership($userId, $messageId)) {
            $userData['status'] = 'main';
            // T46-p: targeted status write — never round-trip the stale
            // request-start snapshot over concurrent flag/balance changes.
            Database::updateUserColumns($userId, ['status' => $userData['status']]);
            return;
        }

        // T46-ap (owner): referral approval completes HERE — the earliest
        // point where the invitee verifiably passes the join gate. Users who
        // registered while failing the gate and then confirmed via the
        // join-menu button (back_to_main_menu) used to never pay their
        // referrer (the pay lived only on /start). Atomic flag consume =
        // exactly-once; a no-op for everyone else.
        if (!empty($userData['isFirstTime'])) {
            Invite::payPendingReferral($userId, $this->config, $this->bot);
        }

        // ── Dispatch
        try {
            $this->dispatch($userId, $messageId, $userData, $data, $chatId, $callbackId);
        } catch (\Throwable $e) {
            error_log('[CALLBACK] dispatch error: ' . $e->getMessage());
            $this->bot->sendMessage($userId, emoji(10) . ' ' . $this->msg('error_generic'), backToMainMenuKeyboard());
        }
    }

    // vNEXT (G-B1): dispatch() now receives $callbackId — the G11
    // withdraw_new rejection alerts answer the callback query, and the
    // callback id was NOT in scope here before (TypeError under
    // strict_types on every rejection). Typed string to match
    // processCallback/answerCallback (Telegram callback ids are strings).
    private function dispatch(int $userId, int $messageId, array $userData, string $data, int $chatId, string $callbackId): void
    {
        // Log a sanitized event type for analytics
        $eventType = preg_replace('/[^a-zA-Z0-9_]/', '_', $data);
        $eventType = mb_substr($eventType, 0, 50);
        Database::logEvent($eventType, $userId);

        switch (true) {
            // ── Main menu ─────────────────────────────────────────────
            case $data === 'back_to_main_menu':
                $this->backToMainMenu($userId, $messageId, $userData); break;
            // v3.3 (T46-m, owner): the games section now ASKS FIRST whether the
            // game should be voice or text (مثل قبل) — the old two-mode menu
            // moved behind «بازی متنی».
            case $data === 'game':
                $this->showGameModeChooser($userId, $messageId); break;
            case $data === 'game_text':
                $this->showGameMenu($userId, $messageId); break;
            case $data === 'game_voice':
                $this->showVoiceGames($userId, $messageId); break;
            case $data === 'mafia_info':
                $this->showMafiaInfo($userId, $messageId); break;
            case $data === 'mafia_create':
                // T46-p: lobby creation is a join-class action — same gate.
                if ($this->gateGameJoin($userId, $callbackId)) {
                    MafiaGame::createLobby($userId, $this->bot, $this->config, $callbackId);
                }
                break;
            // ── T46-ae: player meta screens (PM only) — challenges, personal
            //    stats, hall of fame. Exact-string cases sit BEFORE the
            //    mafia_chal_take_ regex (switch(true) is order-sensitive).
            case $data === 'mafia_chal':
                MafiaMeta::challengesScreen($userId, $messageId, $this->bot, $this->config);
                break;
            case $data === 'mafia_stats':
                MafiaMeta::statsScreen($userId, $messageId, $this->bot, $this->config);
                break;
            case $data === 'mafia_top':
                MafiaMeta::topScreen($userId, $messageId, $this->bot, $this->config);
                break;
            case preg_match('/^mafia_chal_take_([a-z0-9_]{1,20})$/', $data, $m):
                MafiaMeta::takeChallenge($userId, $m[1], $messageId, $callbackId, $this->bot, $this->config);
                break;
            case $data === 'game_anon':
                $this->showGameTypeMenu($userId, $messageId, 'anonymous'); break;
            case $data === 'game_friends':
                $this->showGameTypeMenu($userId, $messageId, 'friends'); break;
            // v2.9.38: Anonymous TOD now uses the SAME config-stepper flow as
            // group mode (fee / max-changes / category), then after start the
            // waiting board offers a "send to chat" button to share the game.
            case preg_match('/^gametype_anon_truthordare$/', $data):
                $this->showTODConfigStepper($userId, $messageId, 'anonymous', 'truthordare'); break;
            case preg_match('/^gametype_friends_truthordare$/', $data):
                $this->startFriendsGame($userId, $messageId, 'truthordare', $userData); break;
            // v3.2 (T42-e): جاسوس entry from the anonymous/friends pickers —
            // spy is a GROUP game, so PV shows an info screen whose deep-link
            // button (t.me/<bot>?startgroup=spy) creates the group session.
            // MUST sit before the generic gametype_* catch-alls below.
            case preg_match('/^gametype_(anon|friends)_spy$/', $data, $m):
                $this->showSpyInfoScreen($userId, $messageId, $m[1] === 'anon' ? 'anonymous' : 'friends'); break;
            // v2.9.35: TOD config steppers (anonymous PV mode) with category.
            //   todcat_anonymous_truthordare_<normal|couple>_<fee>_<maxchanges>               (category toggle)
            //   todfee_anonymous_truthordare_<normal|couple>_<minus|plus>_<fee>_<maxchanges>  (fee stepper)
            //   todchanges_anonymous_truthordare_<normal|couple>_<fee>_<maxchanges>_<minus|plus> (changes stepper)
            //   todstart_anonymous_truthordare_<normal|couple>_<fee>_<maxchanges>             (start button)
            case preg_match('/^todcat_anonymous_truthordare_(normal|couple)_(\d+)_(\d+)$/', $data, $m):
                $this->handleTODCategoryToggle($userId, $messageId, 'anonymous', $m[1], (int)$m[2], (int)$m[3]); break;
            case preg_match('/^todfee_anonymous_truthordare_(normal|couple)_(minus|plus)_(\d+)_(\d+)$/', $data, $m):
                $this->handleTODFeeAction($userId, $messageId, $m[2], (int)$m[3], (int)$m[4], $m[1]); break;
            case preg_match('/^todchanges_anonymous_truthordare_(normal|couple)_(\d+)_(\d+)_(minus|plus)$/', $data, $m):
                $this->handleTODChangesAction($userId, $messageId, (int)$m[2], (int)$m[3], $m[4], $m[1]); break;
            // v2.9.45 (R6-24): anonymous TOD gender-preference pick (دوز parity) —
            //   todmatch_anonymous_truthordare_<normal|couple>_<fee>_<maxchanges>_<pref>
            //   pref: 0 = فرقی ندارد | 1 = پسر | 2 = دختر
            case preg_match('/^todmatch_anonymous_truthordare_(normal|couple)_(\d+)_(\d+)_(\d+)$/', $data, $m):
                $this->handleTODStart($userId, $messageId, (int)$m[2], (int)$m[3], $userData, $m[1], (int)$m[4]); break;
            // v2.9.45 (R6-23): «شروع بازی» on the anonymous TOD config screen no
            // longer starts the queue directly — it shows the دوز-parity gender
            // preference step (see showTODMatchPreference / handleTODStart).
            case preg_match('/^todstart_anonymous_truthordare_(normal|couple)_(\d+)_(\d+)$/', $data, $m):
                $this->showTODMatchPreference($userId, $messageId, $m[1], (int)$m[2], (int)$m[3]); break;
            // v3.1 (T40): category game picker navigation (anonymous/friends PV)
            case preg_match('/^gamecat_(anonymous|friends)_(\w+)_(\d+)$/', $data, $m):
                $cat = Game::categories()[$m[2]] ?? null;
                $catLabel = is_array($cat) ? $cat['label'] : 'بازی‌ها';
                $this->bot->editMessage($userId, $messageId,
                    emoji(40) . " {$catLabel} — بازی مورد نظرت را انتخاب کن:",
                    gameCategoryKeyboardPaged($m[1], $m[2], (int)$m[3])); break;
            case preg_match('/^gametype_anon_(.+)$/', $data, $m):
                $this->showEntryFeeStepper($userId, $messageId, 'anonymous', $m[1]); break;
            case preg_match('/^gametype_friends_(.+)$/', $data, $m):
                // Friends games are always free — skip the entry-fee stepper and
                // jump straight to the inline-switch keyboard.
                $this->startFriendsGame($userId, $messageId, $m[1], $userData); break;
            // v2.9.2: fee stepper now carries rounds (gamefee_<mode>_<type>_<minus|plus>_<fee>_<rounds>)
            case preg_match('/^gamefee_(anonymous|friends)_(\w+)_(minus|plus)_(\d+)_(\d+)$/', $data, $m):
                $this->handleEntryFeeAction($userId, $messageId, $m[1], $m[2], $m[3], (int)$m[4], (int)$m[5], $userData); break;
            // v2.9.2: rounds stepper (gamerounds_<mode>_<type>_<fee>_<rounds>_<minus|plus>)
            case preg_match('/^gamerounds_(anonymous|friends)_(\w+)_(\d+)_(\d+)_(minus|plus)$/', $data, $m):
                $this->handleRoundsAction($userId, $messageId, $m[1], $m[2], (int)$m[3], (int)$m[4], $m[5]); break;
            // v2.9.2: start button (gamestart_<mode>_<type>_<fee>_<rounds>)
            case preg_match('/^gamestart_(anonymous|friends)_(\w+)_(\d+)_(\d+)$/', $data, $m):
                $this->handleStartAction($userId, $messageId, $m[1], $m[2], (int)$m[3], (int)$m[4], $userData); break;
            // v2.9.2: match preference now carries rounds (gamematch_<mode>_<type>_<fee>_<rounds>_<pref>)
            // Regex captures: $m[1]=mode, $m[2]=gameType, $m[3]=fee, $m[4]=rounds, $m[5]=pref
            case preg_match('/^gamematch_(anonymous|friends)_(\w+)_(\d+)_(\d+)_(\d+)$/', $data, $m):
                $this->startGameWithPreference($userId, $messageId, $m[1], $m[2], (int)$m[3], (int)$m[4], (int)$m[5], $userData); break;
            case $data === 'withdrawals':
                $this->showWithdrawalMenu($userId, $messageId, $userData); break;
            case $data === 'withdraw_new':
                // vNEXT (G11): withdrawal entry gate — cooldown/satisfaction
                // check + duplicate-open guard BEFORE showing operator/type
                // menus. (T46-p: logic hoisted into gateWithdrawEntry so every
                // money-opening case shares it.)
                if (!$this->gateWithdrawEntry($userId, $callbackId)) {
                    break;
                }
                $this->showWithdrawalType($userId, $messageId); break;
            case $data === 'withdraw_charge':
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                $this->bot->editMessage($userId, $messageId, emoji(45) . ' ' . $this->msg('withdraw_select_operator'), chargeOperatorKeyboard()); break;
            case preg_match('/^withdraw_charge_op_(irancel|mci|rightel)$/', $data, $m):
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                $this->showChargeDenominations($userId, $messageId, $m[1]); break;
            case preg_match('/^withdraw_charge_amt_(irancel|mci|rightel)_(\d+)$/', $data, $m):
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                // v2.9.45 (R6-63/L4): forged amount-0 callbacks used to fall
                // through to the legacy claimRechargeCode() path — if a 0-amount
                // code existed in inventory the user got a free code.
                if ((int)$m[2] <= 0) {
                    $this->bot->answerCallback($this->currentCallbackId, 'مبلغ نامعتبر است.', true);
                    break;
                }
                $this->createChargeWithdrawal($userId, $messageId, $m[1], $userData, (int)$m[2]); break;
            case $data === 'withdraw_stars':
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                $this->startStarsWithdrawal($userId, $messageId, $userData); break;
            case $data === 'withdraw_card':
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                $this->startCardWithdrawal($userId, $messageId, $userData); break;
            // T46-ao (owner): «برداشت همین مقدار» on the insufficient-balance
            // card — cooldown gate + effective minimum + FRESH balance are all
            // re-validated at tap time (the baked amount is only a hint).
            case preg_match('/^wdquick_(\d+)$/', $data, $m):
                if (!$this->gateWithdrawEntry($userId, $callbackId)) { break; }
                $this->quickWithdrawFromBalance($userId, $messageId, (int)$m[1], $userData); break;
            case preg_match('/^wdrawreply_(\d+)$/', $data, $m):
                $this->userReplyToWithdrawal($userId, $messageId, $userData, (int)$m[1]); break;
            case preg_match('/^wdrawclose_(\d+)$/', $data, $m):
                $this->closeWithdrawalByUser($userId, $messageId, $userData, (int)$m[1]); break;
            case preg_match('/^wdrawanswer_(\d+)$/', $data, $m):
                $this->claimWithdrawal($userId, $messageId, (int)$m[1]); break;
            case $data === 'ntx':
                $this->showNtxMenu($userId, $messageId, $userData); break;
            case $data === 'ntx_convert':
                $this->showNtxConvertConfirm($userId, $messageId, $userData); break;
            case $data === 'ntx_convert_yes':
                $this->doNtxConvert($userId, $messageId, $userData); break;
            case $data === 'ntx_cancel':
                $this->backToMainMenu($userId, $messageId, $userData); break;
            case $data === 'tasks':
                $this->showTasksMenu($userId, $messageId); break;
            case preg_match('/^task_start_(\d+)$/', $data, $m):
                TaskSystem::startTask($userId, (int)$m[1], $this->bot, $this->config); break;
            case preg_match('/^task_verify_(\d+)$/', $data, $m):
                TaskSystem::verifyChannelTask($userId, (int)$m[1], $this->bot, $this->config); break;
            case preg_match('/^botverify_task_(\d+)$/', $data, $m):
                TaskSystem::verifyBotTask($userId, (int)$m[1], $this->bot, $this->config); break;
            case preg_match('/^task_check_(\d+)$/', $data, $m):
                // T46-l: configurable tasks — recount + auto-complete + pay.
                TaskSystem::checkProgressTask($userId, (int)$m[1], $this->bot, $this->config); break;
            case preg_match('/^task_confirm_(\d+)$/', $data, $m):
                // T46-l: manual tasks — user asserts completion for admin review.
                TaskSystem::confirmManualTask($userId, (int)$m[1], $this->bot, $this->config); break;
            case $data === 'reward':
                $this->claimReward($userId, $messageId, $userData); break;
            case preg_match('/^reward_game_(.+)$/', $data, $m):
                $gameEmoji = urldecode($m[1]);
                $this->playRewardGame($userId, $messageId, $userData, $gameEmoji); break;
            case $data === 'sub':
            // v3.3 (T46): the referral menu is now a full submenu («کسب درآمد رفرال»);
            // the legacy 'sub' callback keeps working and routes to the same hub.
            case $data === 'refhub':
                $this->showReferralHub($userId, $messageId, $userData); break;
            case preg_match('/^refview_(toman|token)$/', $data, $m):
                $this->showReferralView($userId, $messageId, $m[1]); break;
            case $data === 'refstats':
                $this->showReferralStats($userId, $messageId, $userData); break;
            case $data === 'profile':
                $this->showProfile($userId, $messageId, $userData); break;
            case $data === 'change_gender':
                $this->bot->editMessage($userId, $messageId, emoji(40) . ' ' . $this->msg('gender_prompt'), genderSelectionKeyboard()); break;
            case $data === 'league':
                $this->showLeague($userId, $messageId); break;
            // v3.3 (T46): league entered from the referral hub — back button
            // returns to the hub to keep the user inside the referral funnel.
            case $data === 'league_ref':
                $this->showLeague($userId, $messageId, true); break;
            case $data === 'support':
                $this->startSupport($userId, $messageId, $userData); break;
            case $data === 'supnew':
                $this->newSupportTicket($userId, $messageId, $userData); break;
            case preg_match('/^supreply_(\d+)$/', $data, $m):
                $this->userReplyToTicket($userId, $messageId, $userData, (int)$m[1]); break;
            case preg_match('/^supclose_(\d+)$/', $data, $m):
                $this->closeTicketByUser($userId, $messageId, $userData, (int)$m[1]); break;
            case preg_match('/^support_answer_(\d+)$/', $data, $m):
                $this->claimSupportTicket($userId, $messageId, (int)$m[1]); break;
            case $data === 'noop':
                // display-only button — just ack
                break;
            default:
                // unknown — silently ignore
                break;
        }
    }

    /**
     * v2.9.18: Dispatch group game callbacks. In groups, the bot shows a
     * direct game menu (no main menu). Only the sender (who called /start@bot)
     * can change settings and start games. Messages are edited in the GROUP chat.
     */
    private function dispatchGroup(int $userId, int $chatId, int $messageId, array $userData, string $data, string $callbackId): void
    {
        // noop and back_to_main_menu — just ack
        if ($data === 'noop' || $data === 'back_to_main_menu') {
            return;
        }

        // v2.9.22: group_game_menu — go back to game selection menu (replaces back_to_main_menu in groups)
        if ($data === 'group_game_menu') {
            $this->bot->answerCallback($callbackId);
            $text = emoji(40) . ' یک بازی را برای شروع در گروه انتخاب کنید:';
            $this->bot->editMessage($chatId, $messageId, $text, groupGameMenuKeyboard());
            return;
        }

        // ── Sender verification: only the sender can change settings and start ──
        $senderKey = 'group_sender_' . $chatId;
        $senderId = (int)(Database::getSetting($senderKey) ?? '0');
        // v2.9.35: Added todcat_group_ for category toggle
        $isConfigCallback = (
            preg_match('/^gametype_group_/', $data) ||
            preg_match('/^gamefee_group_/', $data) ||
            preg_match('/^gamerounds_group_/', $data) ||
            preg_match('/^gamestart_group_/', $data) ||
            preg_match('/^todfee_group_/', $data) ||
            preg_match('/^todchanges_group_/', $data) ||
            preg_match('/^todstart_group_/', $data) ||
            preg_match('/^todcat_group_/', $data) ||
            preg_match('/^spyfee_group_/', $data) ||
            preg_match('/^spystart_group_/', $data)
        );

        if ($isConfigCallback && $senderId > 0 && $userId !== $senderId) {
            $this->bot->answerCallback($callbackId, '❌ فقط فرستنده بازی می‌تواند تنظیمات را تغییر دهد.', true);
            return;
        }

        // ── Game type selection ──
        if ($data === 'gametype_group_tictactoe') {
            $this->showGroupEntryFeeStepper($chatId, $messageId, 'tictactoe');
            return;
        }
        if ($data === 'gametype_group_truthordare') {
            $this->showGroupTODConfigStepper($chatId, $messageId);
            return;
        }

        // ── v3.3 (T46-n, owner): «مافیا صوتی» — the group/channel-only voice
        // mafia entry on the group picker. Shows the full description INSIDE
        // the group; «ساخت بازی جدید» is handled by the early mafia_* routes
        // (already reachable from groups) and posts the lobby to the
        // configured game group.
        if ($data === 'gametype_group_mafiavoice') {
            $cfg = MafiaGame::cfg($this->config);
            $this->bot->editMessage($chatId, $messageId, MafiaGame::infoText($this->config), groupMafiaKeyboard($cfg['group_id'] !== 0));
            $this->bot->answerCallback($callbackId);
            return;
        }

        // T46-p (HIGH): «ساخت بازی جدید در گروه» was a DEAD button inside the
        // group — 'mafia_create' matched neither the early mafia_(join|leave|
        // start|cancel|role)_<id> regex nor this whitelist, so the group CTA
        // always answered «این دکمه در گروه فعال نیست». Route it to the same
        // createLobby the PV path uses (it posts the lobby into the
        // configured game group and answers the callback itself).
        if ($data === 'mafia_create') {
            // T46-p: same join-class gate as the PV path.
            if ($this->gateGameJoin($userId, $callbackId)) {
                // T46-z: THIS group message is edited into the lobby board —
                // the whole game runs on the message the bot already sent
                // (owner directive: no fresh message per stage).
                MafiaGame::createLobby($userId, $this->bot, $this->config, $callbackId, $messageId);
            }
            return;
        }

        // ── v3.2 (T42-c): جاسوس (spy) — group-only multiplayer Spyfall ──
        // Config screen (sender-gated below via the spystart/spyfee prefixes).
        if ($data === 'gametype_group_spy') {
            $this->showGroupSpyConfigStepper($chatId, $messageId);
            return;
        }
        // Fee stepper: spyfee_group_<minus|plus>_<fee>
        if (preg_match('/^spyfee_group_(minus|plus)_(\d+)$/', $data, $m)) {
            // T46-k: clamp via Game::FEE_MAX — the same bound every economics
            // text renders (single source of truth).
            $newFee = $m[1] === 'minus' ? max(0, (int)$m[2] - 1) : min(Game::FEE_MAX, (int)$m[2] + 1);
            $this->bot->editMessage($chatId, $messageId, self::spyConfigText(), $this->spyConfigKeyboard($newFee));
            return;
        }
        // Start: spystart_group_<fee>
        if (preg_match('/^spystart_group_(\d+)$/', $data, $m)) {
            $this->handleGroupSpyStart($userId, $chatId, $messageId, (int)$m[1], $callbackId);
            return;
        }
        // Board actions (any participant / PV cards): engine-side gates apply.
        //   spy_join_<sid> / spy_begin_<sid> / spy_end_<sid> / spy_vote_<sid>
        //   spy_guess_<sid>        (spy: OPEN the location list — UI only)
        //   spy_votec_<sid>_<uid>  (cast a vote)
        //   spy_loc_<sid>_<idx>    (spy's location guess — the real action)
        //   spy_card_<sid>         (back to the card view)
        if (preg_match('/^spy_(join|begin|end|vote)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, $m[1], (int)$m[2], 0, $callbackId);
            return;
        }
        if (preg_match('/^spy_guess_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'openguess', (int)$m[1], 0, $callbackId);
            return;
        }
        if (preg_match('/^spy_votec_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'cast', (int)$m[1], (int)$m[2], $callbackId);
            return;
        }
        if (preg_match('/^spy_loc_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleSpyBoardAction($userId, $chatId, $messageId, 'guess', (int)$m[1], (int)$m[2], $callbackId);
            return;
        }
        if (preg_match('/^spy_card_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $impl = Game::load('spy');
            $this->bot->editMessage($userId, $messageId, $impl->getStatusText($session, $userId, $this->config), $impl->renderKeyboard($session, $userId));
            $this->bot->answerCallback($callbackId);
            return;
        }
        // v3.1 (T40): category page navigation for the group game picker
        if (preg_match('/^gamecat_group_(\w+)_(\d+)$/', $data, $m)) {
            $this->bot->answerCallback($callbackId);
            $cat = Game::categories()[$m[1]] ?? null;
            $catLabel = is_array($cat) ? $cat['label'] : 'بازی‌ها';
            $text = emoji(40) . " {$catLabel} — یک بازی را برای شروع در گروه انتخاب کنید:";
            $this->bot->editMessage($chatId, $messageId, $text, gameCategoryKeyboardPaged('group', $m[1], (int)$m[2]));
            return;
        }
        // v3.0 (T37) / v3.1 (T40): every quick game gets the SAME fee/rounds
        // stepper as دوز (owner directive: تنظیمات مثل دوز).
        if (preg_match('/^gametype_group_(\w+)$/', $data, $m) && Game::isQuickGame($m[1])) {
            $this->showGroupEntryFeeStepper($chatId, $messageId, $m[1]);
            return;
        }

        // ── TTT fee stepper: gamefee_group_<type>_<minus|plus>_<fee>_<rounds> ──
        if (preg_match('/^gamefee_group_(\w+)_(minus|plus)_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleGroupFeeStepper($chatId, $messageId, $m[1], $m[2], (int)$m[3], (int)$m[4]);
            return;
        }
        // ── TTT rounds stepper: gamerounds_group_<type>_<fee>_<rounds>_<minus|plus> ──
        if (preg_match('/^gamerounds_group_(\w+)_(\d+)_(\d+)_(minus|plus)$/', $data, $m)) {
            $this->handleGroupRoundsStepper($chatId, $messageId, $m[1], (int)$m[2], (int)$m[3], $m[4]);
            return;
        }
        // ── TTT start: gamestart_group_<type>_<fee>_<rounds> ──
        if (preg_match('/^gamestart_group_(\w+)_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleGroupStart($userId, $chatId, $messageId, $m[1], (int)$m[2], (int)$m[3], $callbackId);
            return;
        }

        // ── TOD category toggle: todcat_group_truthordare_<normal|couple>_<fee>_<maxchanges> ──
        // v2.9.35: Toggles between عادی and کاپلی question categories.
        if (preg_match('/^todcat_group_truthordare_(normal|couple)_(\d+)_(\d+)$/', $data, $m)) {
            $newCat = $m[1] === 'normal' ? 'couple' : 'normal';
            $this->handleGroupTODCategoryToggle($chatId, $messageId, $newCat, (int)$m[2], (int)$m[3]);
            return;
        }
        // ── TOD fee stepper: todfee_group_truthordare_<normal|couple>_<minus|plus>_<fee>_<maxchanges> ──
        if (preg_match('/^todfee_group_truthordare_(normal|couple)_(minus|plus)_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleGroupTODFeeStepper($chatId, $messageId, $m[2], (int)$m[3], (int)$m[4], $m[1]);
            return;
        }
        // ── TOD changes stepper: todchanges_group_truthordare_<normal|couple>_<fee>_<maxchanges>_<minus|plus> ──
        if (preg_match('/^todchanges_group_truthordare_(normal|couple)_(\d+)_(\d+)_(minus|plus)$/', $data, $m)) {
            $this->handleGroupTODChangesStepper($chatId, $messageId, (int)$m[2], (int)$m[3], $m[4], $m[1]);
            return;
        }
        // ── TOD start: todstart_group_truthordare_<normal|couple>_<fee>_<maxchanges> ──
        if (preg_match('/^todstart_group_truthordare_(normal|couple)_(\d+)_(\d+)$/', $data, $m)) {
            $this->handleGroupTODStart($userId, $chatId, $messageId, (int)$m[2], (int)$m[3], $callbackId, $m[1]);
            return;
        }
    }

    // ─── Group game handlers ──────────────────────────────────────────

    private function showGroupEntryFeeStepper(int $chatId, int $messageId, string $gameType): void
    {
        // v3.1 (T40): quick games get a game-name + rules header too.
        $header = '';
        if (Game::isQuickGame($gameType)) {
            $label = Game::catalog()[$gameType]['label'] ?? $gameType;
            try {
                $rules = Game::load($gameType)->getRulesText();
            } catch (\Throwable $e) {
                $rules = '';
            }
            $header = emoji(0) . " بازی «{$label}»\n\n" . $rules . "\n\n";
        }
        // vNEXT (14-b/U10b): same panel default as the PV stepper — was 0.
        // v3.1 (T40): the panel default applies to دوز; other games default to 0.
        if ($gameType === 'tictactoe') {
            $defaultFee = (int)(Settings::getSetting('game_tictactoe_entry_fee') ?? '0');
        } else {
            $defaultFee = 0;
        }
        // T46-k: live shared block (group fees ALWAYS apply) + Game::FEE_MAX clamp.
        $defaultFee = max(0, min(Game::FEE_MAX, $defaultFee));
        $text = $header . emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, true, $defaultFee, 1);
        $this->bot->editMessage($chatId, $messageId, $text, gameEntryFeeKeyboard('group', $gameType, $defaultFee, 1));
    }

    private function showGroupTODConfigStepper(int $chatId, int $messageId): void
    {
        $defaultFee = (int)($this->config['game']['tod_default_fee'] ?? 1);
        $defaultMax = (int)($this->config['game']['tod_default_max_changes'] ?? 3);
        $this->bot->editMessage($chatId, $messageId, self::todConfigText(), gameTODConfigKeyboard('group', 'truthordare', $defaultFee, $defaultMax, 'normal'));
    }

    // ── v3.2 (T42-c): جاسوس (spy) config + board actions ────────────────

    /** Spy config screen text — meaningful rules + fee explanation
     *  (T46-k: the fee bound renders live from Game::FEE_MAX). */
    private static function spyConfigText(): string
    {
        $feeMax = faDigits(Game::FEE_MAX);
        return emoji(44) . " بازی «جاسوس»\n\n" . (Game::load('spy')->getRulesText()) . "\n\n" .
            emoji(20) . " هزینه ورود هر بازیکن را انتخاب کنید (۰ تا {$feeMax} توکن):\n" .
            "۰ = بازی رایگان (بدون پاداش)\n" .
            "بیشتر از ۰ = هر بازیکن موقع شروع پرداخت می‌کند و کل مبلغ بین برنده‌ها تقسیم می‌شود";
    }

    /** Spy config screen keyboard — fee stepper (− / label / +) + start. */
    private function spyConfigKeyboard(int $fee): string
    {
        $kb = [];
        $kb[] = [[
            'text' => 'هزینه ورود هر بازیکن: ' . $fee . ' توکن',
            'callback_data' => 'noop',
            'style' => 'primary',
            'icon_custom_emoji_id' => emoji_id(20),
        ]];
        $kb[] = [
            ['text' => '−', 'callback_data' => 'spyfee_group_minus_' . $fee, 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(24)],
            ['text' => '+', 'callback_data' => 'spyfee_group_plus_' . $fee, 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(8)],
        ];
        $kb[] = [[
            'text' => 'شروع بازی',
            'callback_data' => 'spystart_group_' . $fee,
            'style' => 'success',
            'icon_custom_emoji_id' => emoji_id(40),
        ]];
        $kb[] = [makeButton('back_to_games_menu', 'برگشت به منوی قبلی', 'group_game_menu', 'danger', 11)];
        return json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    private function showGroupSpyConfigStepper(int $chatId, int $messageId): void
    {
        $this->bot->editMessage($chatId, $messageId, self::spyConfigText(), $this->spyConfigKeyboard(0));
    }

    /**
     * v3.2 (T42-e): PV info screen for جاسوس, opened from the anonymous /
     * friends pickers. Spy is a GROUP game — no session is created here; the
     * «افزودن به گروه» deep-link (t.me/<bot>?startgroup=spy) adds the bot to
     * the chosen group and lands as «/start spy» which creates the session.
     * PV context → custom-emoji text via getRulesText().
     */
    private function showSpyInfoScreen(int $userId, int $messageId, string $mode): void
    {
        $backCb = $mode === 'anonymous' ? 'game_anon' : 'game_friends';
        $botUsername = preg_replace('/^@+/', '', (string)$this->bot->getBotUsername());
        $kb = [];
        if ($botUsername !== '') {
            $kb[] = [[
                'text' => 'افزودن ربات به گروه و شروع بازی',
                'url' => 'https://t.me/' . $botUsername . '?startgroup=spy',
                'style' => 'success',
                'icon_custom_emoji_id' => emoji_id(40),
            ]];
        }
        $kb[] = [makeButton('back_to_games_menu', 'برگشت به منوی قبلی', $backCb, 'danger', 11)];
        $text = emoji(44) . " بازی «جاسوس» (گروهی)\n\n" . (Game::load('spy')->getRulesText()) . "\n\n" .
            emoji(35) . " این بازی فقط داخل گروه قابل اجراست؛ با دکمه زیر ربات را به گروه اضافه کنید تا بازی به‌صورت خودکار ساخته شود:";
        $this->bot->editMessage($userId, $messageId, $text, json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function handleGroupSpyStart(int $userId, int $chatId, int $messageId, int $fee, string $callbackId): void
    {
        // v3.3 (T43): TRACK-aware pre-gate — an ACTIVE game on the NORMAL
        // track blocks a spy start; an active ANONYMOUS game never does
        // (parallel tracks — «بازی حالت ناشناس جداست"). The authoritative
        // gate lives in Game::startGroupSpy.
        $existing = Database::getActiveOnlySessionInTrack($userId, 'normal');
        if ($existing !== null) {
            $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        // v3.3 (T43): NO stale-queue drop here — owner directive: waiting
        // lists survive until one of them STARTS (expulsion happens only at
        // the waiting→active transition, via the track-scoped sweep inside
        // the begin path) or until the silent 24h expiry.
        try {
            $res = Game::startGroupSpy($userId, $chatId, $this->config, $fee);
        } catch (\Throwable $e) {
            error_log('[spy start] exception: ' . $e->getMessage());
            $this->bot->answerCallback($callbackId, $this->msg('error_generic'), true);
            return;
        }
        if (empty($res['success'])) {
            $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        Database::setSetting('group_sender_' . $chatId, (string)$userId);
        $this->bot->answerCallback($callbackId, 'بازی جاسوس ساخته شد');
        $session = Database::getGameSession((int)$res['session']['id']);
        if ($session === null) return;
        $impl = Game::load('spy');
        $this->bot->editMessage($chatId, $messageId, $impl->getStatusText($session, -1, $this->config), $impl->renderKeyboard($session, -1));
    }

    /**
     * Spy board actions — engine call + alert map + board syncs (the tapped
     * group/PV message AND every player's private card message).
     */
    /**
     * T46-p: withdrawal entry gate hoisted onto EVERY money-opening case —
     * the cooldown/duplicate-open checks existed only on withdraw_new, so a
     * stale keyboard (Telegram buttons never expire) let a user with an open
     * request or an active cooldown open ANOTHER withdrawal via the charge /
     * stars / card buttons.
     */
    private function gateWithdrawEntry(int $userId, string $callbackId): bool
    {
        if (!Withdrawal::canUserRequestWithdrawal($userId, $this->config)) {
            $this->bot->answerCallback($callbackId, 'امکان ثبت درخواست برداشت جدید در حال حاضر وجود ندارد.', true);
            return false;
        }
        foreach (Database::getActiveWithdrawalsForUser($userId) as $openWdraw) {
            if (in_array((string)($openWdraw['status'] ?? ''), ['open', 'claimed', 'answered'], true)) {
                $this->bot->answerCallback($callbackId, 'شما یک درخواست در جریان دارید.', true);
                return false;
            }
        }
        return true;
    }

    /**
     * T46-p: unified join gate (satisfaction + registration/gender/
     * requirements) for the join paths that predated it — spy_join and the
     * mafia lobby join/create. Same wording/behaviour as the gjoin_ gate
     * (satisfaction first, then InlineHandler::checkAllRequirements).
     */
    private function gateGameJoin(int $userId, string $callbackId): bool
    {
        $gateUser = Database::loadUser($userId);
        if ($gateUser !== null && !empty($gateUser['satisfactionPending'])) {
            $this->bot->answerCallback($callbackId, $this->msg('satisfaction_prompt'), true);
            return false;
        }
        $gateError = \InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($gateError !== null) {
            $this->bot->answerCallback($callbackId, $gateError, true);
            return false;
        }
        return true;
    }

    private function handleSpyBoardAction(int $userId, int $chatId, int $messageId, string $action, int $sessionId, int $arg, string $callbackId): void
    {
        // T46-p: spy_join was the ONLY join path with no registration/
        // gender/satisfaction gate — unregistered group members could become
        // full players of fee-0 lobbies and win credits (ghost user rows).
        if ($action === 'join' && !$this->gateGameJoin($userId, $callbackId)) {
            return;
        }
        try {
            $result = Game::handleSpyAction($sessionId, $userId, $action, $this->bot, $this->config, $arg);
        } catch (\Throwable $e) {
            error_log('[spy board action] exception sid=' . $sessionId . ' user=' . $userId . ' action=' . $action . ' — ' . $e->getMessage());
            $this->bot->answerCallback($callbackId, $this->msg('error_generic'), true);
            return;
        }
        if (empty($result['success'])) {
            $alertMap = [
                'not_found' => 'بازی پیدا نشد.',
                'wrong_game_type' => 'نوع بازی نامعتبر است.',
                'game_ended' => 'بازی پایان یافته است.',
                'already_in_game' => 'شما قبلاً در این بازی هستید.',
                'game_full' => 'ظرفیت این بازی پر است.',
                'already_started' => 'بازی قبلاً شروع شده است.',
                'only_creator_can_start' => 'فقط سازنده بازی می‌تواند بازی را شروع کند.',
                'need_more_players' => 'برای شروع، حداقل ۳ بازیکن لازم است.',
                'insufficient_fee' => 'موجودی تکن یکی از بازیکنان کافی نیست — بازی شروع نشد.',
                'not_in_game' => 'شما در این بازی نیستید.',
                'not_active' => 'بازی فعال نیست.',
                'vote_already_open' => 'رأی‌گیری قبلاً شروع شده است.',
                'vote_not_open' => 'رأی‌گیری هنوز شروع نشده است.',
                'already_voted' => 'شما رأی خود را ثبت کرده‌اید.',
                'invalid_vote' => 'رأی نامعتبر است.',
                'not_spy' => 'فقط جاسوس می‌تواند مکان را حدس بزند.',
                'not_game_owner' => 'فقط سازنده بازی می‌تواند بازی را پایان دهد.',
                'busy_try_again' => 'لطفاً چند لحظه بعد دوباره تلاش کنید.',
                'unknown_action' => 'عمل نامعتبر.',
                'invalid_move' => 'حدس نامعتبر است.',
            ];
            $alert = $alertMap[$result['message'] ?? ''] ?? $this->msg('error_generic');
            $this->bot->answerCallback($callbackId, $alert, true);
            return;
        }

        // Success acks
        $acks = [
            'join' => 'به بازی پیوستید',
            'started' => 'بازی شروع شد — کارت‌ها ارسال شد',
            'vote_open' => 'رأی‌گیری شروع شد',
            'vote_recorded' => 'رأی شما ثبت شد',
            'finished' => 'بازی تمام شد',
            'ended' => 'بازی پایان یافت',
        ];
        $ack = $acks[$result['message'] ?? ''] ?? null;
        if ($ack !== null) {
            $this->bot->answerCallback($callbackId, $ack);
        } else {
            $this->bot->answerCallback($callbackId);
        }

        $session = Database::getGameSession($sessionId);
        if ($session === null) return;
        $impl = Game::load('spy');

        // Spy opened the location list — edit their card into the list UI.
        if (($result['message'] ?? '') === 'guess_ui') {
            $kb = [];
            $row = [];
            foreach (SpyGame::LOCATIONS as $i => $loc) {
                $row[] = [
                    'text' => $loc[0],
                    'callback_data' => 'spy_loc_' . $sessionId . '_' . $i,
                    'style' => 'danger',
                    'icon_custom_emoji_id' => emoji_id(17),
                ];
                if (count($row) === 2) {
                    $kb[] = $row;
                    $row = [];
                }
            }
            if (!empty($row)) $kb[] = $row;
            $kb[] = [[
                'text' => 'برگشت به کارت من',
                'callback_data' => 'spy_card_' . $sessionId,
                'style' => 'primary',
                'icon_custom_emoji_id' => emoji_id(11),
            ]];
            $txt = emoji(17) . " مکان مخفی را انتخاب کن — حدس درست = برد، حدس غلط = باخت فوری:";
            try {
                $this->bot->editMessage($chatId, $messageId, $txt, json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
            } catch (\Throwable $e) {
                error_log('[spy board action] guess-ui edit failed sid=' . $sessionId . ' — ' . $e->getMessage());
            }
            return;
        }

        // Refresh the tapped board. Group-board taps render the shared view
        // (-1); PV taps (vote cast / spy's location guess / card nav) render
        // the tapper's own private view.
        $pvTapped = in_array($action, ['cast', 'guess'], true);
        $viewer = ($pvTapped || (string)($session['mode'] ?? '') !== 'group') ? $userId : -1;
        try {
            $this->bot->editMessage($chatId, $messageId, $impl->getStatusText($session, $viewer, $this->config), $impl->renderKeyboard($session, $viewer));
        } catch (\Throwable $e) {
            error_log('[spy board action] tapped-board edit failed sid=' . $sessionId . ' — ' . $e->getMessage());
        }

        // When the ballot opens, send EVERY player a private ballot message.
        if (($result['message'] ?? '') === 'vote_open') {
            foreach (SpyGame::playerIds($session) as $uid) {
                $mid = (int)(Database::getSetting('spy_card_msg_' . $sessionId . '_' . $uid) ?? '0');
                try {
                    $kb = $impl->renderKeyboard($session, $uid);
                    $txt = emoji(49) . ' رأی‌گیری شروع شد — به یک نفر که فکر می‌کنی جاسوس است رأی بده:'
                        . "\n" . $impl->getStatusText($session, $uid, $this->config);
                    if ($mid > 0) {
                        $this->bot->editMessage($uid, $mid, $txt, $kb);
                    } else {
                        $this->bot->sendMessage($uid, $txt, $kb);
                    }
                } catch (\Throwable $e) {
                    error_log('[spy board action] ballot send failed sid=' . $sessionId . ' uid=' . $uid . ' — ' . $e->getMessage());
                }
            }
            return;
        }

        // For other state changes refresh every player's private card too.
        if (in_array($result['message'] ?? '', ['started', 'finished', 'ended'], true)) {
            foreach (SpyGame::playerIds($session) as $uid) {
                if ($uid === $userId) continue;
                $mid = (int)(Database::getSetting('spy_card_msg_' . $sessionId . '_' . $uid) ?? '0');
                if ($mid <= 0) continue;
                try {
                    $this->bot->editMessage($uid, $mid, $impl->getStatusText($session, $uid, $this->config), $impl->renderKeyboard($session, $uid));
                } catch (\Throwable $e) {}
            }
        }
    }

    private function handleGroupFeeStepper(int $chatId, int $messageId, string $gameType, string $action, int $currentFee, int $currentRounds): void
    {
        // T46-k: live shared block with the CURRENT values + Game::FEE_MAX clamp.
        $newFee = $action === 'minus' ? max(0, $currentFee - 1) : min(Game::FEE_MAX, $currentFee + 1);
        $text = emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, true, $newFee, $currentRounds);
        $this->bot->editMessage($chatId, $messageId, $text, gameEntryFeeKeyboard('group', $gameType, $newFee, $currentRounds));
    }

    private function handleGroupRoundsStepper(int $chatId, int $messageId, string $gameType, int $currentFee, int $currentRounds, string $action): void
    {
        // T46-k: live shared block with the CURRENT values + Game::ROUNDS_MAX clamp.
        $newRounds = $action === 'minus' ? max(1, $currentRounds - 1) : min(Game::ROUNDS_MAX, $currentRounds + 1);
        $text = emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, true, $currentFee, $newRounds);
        $this->bot->editMessage($chatId, $messageId, $text, gameEntryFeeKeyboard('group', $gameType, $currentFee, $newRounds));
    }

    private function handleGroupStart(int $userId, int $chatId, int $messageId, string $gameType, int $fee, int $rounds, string $callbackId): void
    {
        // Check if user already has an active group game
        $existing = Game::getActiveSessionForUser($userId, 'group');
        if ($existing !== null && $existing['status'] === 'active') {
            $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        // Cancel old waiting session
        // v2.9.44 (T16-6): route through Game::cancelWaitingSession (same as the
        // PV path) — the bare status flip leaked group_chat_id_/group_msg_id_
        // rows and left stale copies of the old group board interactive.
        if ($existing !== null && $existing['status'] === 'waiting') {
            Game::cancelWaitingSession((int)$existing['id'], $userId, $this->bot, $this->config);
        }

        $result = Game::startGroup($userId, $gameType, $chatId, $this->config, $fee, $rounds);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'insufficient_entry_fee') {
                $totalCost = $fee * $rounds;
                $balance = $result['balance'] ?? 0;
                $this->bot->answerCallback($callbackId, "موجودی توکن بازی شما کافی نیست. هزینه: {$totalCost} | موجودی: {$balance}", true);
            } elseif ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            } else {
                $this->bot->answerCallback($callbackId, 'خطا در ایجاد بازی.', true);
            }
            return;
        }

        $session = $result['session'];
        $sessionId = (int)$session['id'];

        // Store the group chat ID and message ID for later edits
        Database::setSetting('group_chat_id_' . $sessionId, (string)$chatId);
        Database::setSetting('group_msg_id_' . $sessionId, (string)$messageId);
        Database::updateGameSession($sessionId, ['p1_board_msg_id' => $messageId]);

        $this->bot->answerCallback($callbackId, '✅ بازی ایجاد شد');

        // Render the board (waiting state) on the group message
        $render = Game::renderGroupBoardMessage($session, $this->config);
        $this->bot->editMessage($chatId, $messageId, $render['text'], $render['keyboard']);

        // Clear the sender setting (game started, no more config needed)
        // v2.9.45 (R6-55/LOW): the sender key is now KEPT after start — with
        // multi-waiting, stale config panels used to become ownerless and let
        // ANY member fire gamestart_group_/todstart_group_. A new /start
        // overwrites the key with the new sender, so old panels stay gated.
        // (Deleted key would make ownership checks pass for everyone.)
    }

    // v2.9.35: Group TOD category toggle — switches between عادی and کاپلی.
    private function handleGroupTODCategoryToggle(int $chatId, int $messageId, string $category, int $currentFee, int $currentMaxChanges): void
    {
        $this->bot->editMessage($chatId, $messageId, self::todConfigText(), gameTODConfigKeyboard('group', 'truthordare', $currentFee, $currentMaxChanges, $category));
    }

    private function handleGroupTODFeeStepper(int $chatId, int $messageId, string $action, int $currentFee, int $currentMaxChanges, string $category = 'normal'): void
    {
        $newFee = $action === 'minus' ? max(0, $currentFee - 1) : min(Game::FEE_MAX, $currentFee + 1);
        $this->bot->editMessage($chatId, $messageId, self::todConfigText(), gameTODConfigKeyboard('group', 'truthordare', $newFee, $currentMaxChanges, $category));
    }

    private function handleGroupTODChangesStepper(int $chatId, int $messageId, int $currentFee, int $currentMaxChanges, string $action, string $category = 'normal'): void
    {
        $newMax = $action === 'minus' ? max(0, $currentMaxChanges - 1) : min(20, $currentMaxChanges + 1);
        $this->bot->editMessage($chatId, $messageId, self::todConfigText(), gameTODConfigKeyboard('group', 'truthordare', $currentFee, $newMax, $category));
    }

    private function handleGroupTODStart(int $userId, int $chatId, int $messageId, int $fee, int $maxChanges, string $callbackId, string $category = 'normal'): void
    {
        $existing = Game::getActiveSessionForUser($userId, 'group');
        if ($existing !== null && $existing['status'] === 'active' && $existing['game_type'] === 'truthordare') {
            $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        if ($existing !== null && $existing['status'] === 'waiting') {
            // v2.9.44 (T16-6): same cleanup route as handleGroupStart.
            Game::cancelWaitingSession((int)$existing['id'], $userId, $this->bot, $this->config);
        }

        $result = Game::startGroupTOD($userId, $chatId, $this->config, $fee, $maxChanges, $category);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($callbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            } else {
                $this->bot->answerCallback($callbackId, 'خطا در ایجاد بازی.', true);
            }
            return;
        }

        $session = $result['session'];
        $sessionId = (int)$session['id'];

        Database::setSetting('group_chat_id_' . $sessionId, (string)$chatId);
        Database::setSetting('group_msg_id_' . $sessionId, (string)$messageId);
        Database::updateGameSession($sessionId, ['p1_board_msg_id' => $messageId]);

        $this->bot->answerCallback($callbackId, '✅ بازی ایجاد شد');
        $render = Game::renderGroupBoardMessage($session, $this->config);
        $this->bot->editMessage($chatId, $messageId, $render['text'], $render['keyboard']);

        // v2.9.45 (R6-55/LOW): the sender key is now KEPT after start — with
        // multi-waiting, stale config panels used to become ownerless and let
        // ANY member fire gamestart_group_/todstart_group_. A new /start
        // overwrites the key with the new sender, so old panels stay gated.
        // (Deleted key would make ownership checks pass for everyone.)
    }

    private function msg(string $key): string
    {
        return Settings::getMessage($key, $this->config);
    }

    private function checkMembership(int $userId, int $messageId): bool
    {
        $channels = Database::getMandatoryChannels();
        if (!empty($channels)) {
            if (!$this->bot->checkChannelMembership($userId, $messageId, $this->config['telegram_bot_token'], $channels)) {
                return false;
            }
        }
        // Also check mandatory bot verifications (previous-bot start check).
        return BotVerifier::checkMandatory($userId, $messageId, $this->bot, $this->config);
    }

    private function backToMainMenu(int $userId, int $messageId, array $userData): void
    {
        $userData['status'] = 'main';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);
        // T46-c: shared main-menu text (welcome + شروع بازی/رفرال legend).
        $this->bot->editMessage($userId, $messageId, mainMenuText($this->config), mainMenuKeyboard());
    }

    // ─── Game callbacks ─────────────────────────────────────────────

    /**
     * v2.9.6: Handle callbacks from inline messages (friends-game setup).
     * These come from the inline message that was sent via inline mode.
     * The user configures fee/rounds on the inline message, then taps
     * "شروع بازی" → the inline message is edited to show a "پیوستن به بازی"
     * button. When another user taps "پیوستن به بازی", the game starts.
     */
    private function handleInlineCallback(int $userId, string $inlineMessageId, string $callbackId, string $data): void
    {
        // v2.9.19: Sender verification is now done via callback_data encoding.
        // The sender's userId is encoded as the LAST numeric segment in every
        // config/setup callback. This is 100% reliable and does NOT depend on
        // chosen_inline_result (inline feedback) which may not fire for all bots.
        //
        // Config callbacks (owner-only, senderId is last segment):
        //   gamefee_friends_<type>_<minus|plus>_<fee>_<rounds>_<senderId>
        //   gamerounds_friends_<type>_<fee>_<rounds>_<minus|plus>_<senderId>
        //   gamestart_friends_<type>_<fee>_<rounds>_<senderId>
        //   todfee_friends_<type>_<minus|plus>_<fee>_<maxchanges>_<senderId>
        //   todchanges_friends_<type>_<fee>_<maxchanges>_<minus|plus>_<senderId>
        //   todstart_friends_<type>_<fee>_<maxchanges>_<senderId>
        //   inline_gender_boy_<senderId> / inline_gender_girl_<senderId>
        //   inline_recheck_<senderId>
        //
        // Game play buttons (gmove_*, gcancelreq_*, gjoin_pending_*, tod_begin_*, etc.)
        // are NOT restricted — both players can use them (verified by game logic).

        // ── Fee stepper: gamefee_friends_<type>_<minus|plus>_<fee>_<rounds>_<senderId>
        if (preg_match('/^gamefee_friends_(\w+)_(minus|plus)_(\d+)_(\d+)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[5];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تنظیمات را تغییر دهد.', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->handleInlineFeeStepper($inlineMessageId, $m[1], $m[2], (int)$m[3], (int)$m[4], $senderId);
            return;
        }
        // ── Rounds stepper: gamerounds_friends_<type>_<fee>_<rounds>_<minus|plus>_<senderId>
        if (preg_match('/^gamerounds_friends_(\w+)_(\d+)_(\d+)_(minus|plus)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[5];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تنظیمات را تغییر دهد.', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->handleInlineRoundsStepper($inlineMessageId, $m[1], (int)$m[2], (int)$m[3], $m[4], $senderId);
            return;
        }
        // ── Start button: gamestart_friends_<type>_<fee>_<rounds>_<senderId>
        if (preg_match('/^gamestart_friends_(\w+)_(\d+)_(\d+)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[4];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند شروع کند.', true);
                return;
            }
            // Do NOT call answerCallback here — handleInlineStart does it.
            $this->handleInlineStart($userId, $inlineMessageId, $m[1], (int)$m[2], (int)$m[3]);
            return;
        }

        // ── TOD config steppers (inline/friends mode) v2.9.35: with category ──
        //   todcat_friends_<type>_<normal|couple>_<fee>_<maxchanges>_<senderId>               (category toggle)
        //   todfee_friends_<type>_<normal|couple>_<minus|plus>_<fee>_<maxchanges>_<senderId>  (fee stepper)
        //   todchanges_friends_<type>_<normal|couple>_<fee>_<maxchanges>_<minus|plus>_<senderId> (changes stepper)
        //   todstart_friends_<type>_<normal|couple>_<fee>_<maxchanges>_<senderId>             (start button)
        if (preg_match('/^todcat_friends_(\w+)_(normal|couple)_(\d+)_(\d+)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[5];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تنظیمات را تغییر دهد.', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->handleInlineTODCategoryToggle($inlineMessageId, $m[1], $m[2], (int)$m[3], (int)$m[4], $senderId);
            return;
        }
        if (preg_match('/^todfee_friends_(\w+)_(normal|couple)_(minus|plus)_(\d+)_(\d+)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[6];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تنظیمات را تغییر دهد.', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->handleInlineTODFeeStepper($inlineMessageId, $m[1], $m[3], (int)$m[4], (int)$m[5], $senderId, $m[2]);
            return;
        }
        if (preg_match('/^todchanges_friends_(\w+)_(normal|couple)_(\d+)_(\d+)_(minus|plus)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[6];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تنظیمات را تغییر دهد.', true);
                return;
            }
            $this->bot->answerCallback($callbackId);
            $this->handleInlineTODChangesStepper($inlineMessageId, $m[1], (int)$m[3], (int)$m[4], $m[5], $senderId, $m[2]);
            return;
        }
        if (preg_match('/^todstart_friends_(\w+)_(normal|couple)_(\d+)_(\d+)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[5];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند شروع کند.', true);
                return;
            }
            $this->handleInlineTODStart($userId, $inlineMessageId, $m[1], (int)$m[3], (int)$m[4], $m[2]);
            return;
        }

        // ── vNEXT (14-b/U6): TOD rules guide from shared inline boards.
        // Inline callbacks carry no chat id, so the rules go to the tapper's PV.
        if (preg_match('/^tod_guide_(\d+)$/', $data, $m)) {
            $this->handleTODGuide($userId, (int)$m[1], 0, $callbackId, true);
            return;
        }
        // ── v2.9.33: TOD board actions (inline mode) ──
        // tod_<begin|join|truth|dare|change|done|end>_<sessionId>
        if (preg_match('/^tod_(begin|join|truth|dare|change|done|end)_(\d+)$/', $data, $m)) {
            $this->handleInlineTODBoardAction($userId, $m[1], (int)$m[2], $inlineMessageId, $callbackId);
            return;
        }
        // ── Join button: gjoin_pending_<inviterId>
        if (preg_match('/^gjoin_pending_(\d+)$/', $data, $m)) {
            $inviterId = (int)$m[1];
            $sessionId = (int)(Database::getSetting('pending_friends_session_' . $inviterId) ?? '0');
            if ($sessionId > 0) {
                // v2.9.10: Do NOT call answerCallback here — handleInlineJoin
                // will call it with the appropriate success/error message.
                // Calling it twice (processing + result) doesn't work because
                // Telegram only accepts ONE answerCallbackQuery per callback.
                $this->handleInlineJoin($userId, $inviterId, $sessionId, $inlineMessageId);
            } else {
                $this->bot->answerCallback($callbackId, '❌ بازی پیدا نشد یا منقضی شده است.', true);
            }
            return;
        }
        // ── Game moves: gmove_<sessionId>_<idx>
        // v2.9.7: Handle game moves from inline messages. Both players
        // interact with the same inline message — the move is processed
        // and the inline message is edited to show the updated board.
        if ($move = Game::parseMoveCallback($data)) {
            $this->handleInlineGameMove($userId, $move['sessionId'], $move['move'], $inlineMessageId);
            return;
        }
        // ── v2.9.11: Cancel request flow for inline mode ──
        // gcancelreq_: anyone can tap → request cancel → edit inline message
        // gcancelconfirm_: only NON-requester can tap → cancel game
        // gcancelreject_: only NON-requester can tap → reject cancel → revert
        // gcancelback_: only requester can tap → take back request
        if (preg_match('/^gcancelreq_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $cancelBy = (int)($session['cancel_requested_by'] ?? 0);
            if ($cancelBy > 0) {
                // Someone already requested — can't request again
                $this->bot->answerCallback($callbackId, '⏳ درخواست لغو قبلاً ارسال شده. منتظر پاسخ حریف بمانید.', true);
                return;
            }
            try {
                // vNEXT (14-b/U3): requestCancel refuses non-participants and
                // already-finished games — don't claim success when nothing
                // happened. Same alerts the inline move handler answers.
                $cancelResult = Game::requestCancel($sid, $userId, $this->bot, $this->config);
                if (empty($cancelResult['success'])) {
                    $refusalKey = (($cancelResult['message'] ?? '') === 'not_your_game')
                        ? 'game_alert_not_your_game'
                        : 'game_alert_already_finished';
                    $this->bot->answerCallback($callbackId, $this->msg($refusalKey), true);
                    return;
                }
                $this->bot->answerCallback($callbackId, '✅ درخواست لغو ارسال شد');
            } catch (\Throwable $e) {
                error_log('[inline gcancelreq] exception: ' . $e->getMessage());
                $this->bot->answerCallback($callbackId);
            }
            return;
        }
        if (preg_match('/^gcancelconfirm_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $cancelBy = (int)($session['cancel_requested_by'] ?? 0);
            $p1Id = (int)$session['player1_id'];
            $p2Id = (int)($session['player2_id'] ?? 0);
            $userNum = ($userId === $p1Id) ? 1 : (($userId === $p2Id) ? 2 : 0);
            // v2.9.11: Only the NON-requester can confirm cancel
            if ($cancelBy > 0 && $userNum === $cancelBy) {
                $this->bot->answerCallback($callbackId, '❌ شما درخواست لغو را دادید. منتظر پاسخ حریف بمانید.', true);
                return;
            }
            // vNEXT (14-b/U3): confirmCancel silently refuses non-participants
            // and non-active sessions — don't claim success when nothing happened.
            if ($userNum === 0) {
                $this->bot->answerCallback($callbackId, $this->msg('game_alert_not_your_game'), true);
                return;
            }
            if ((string)($session['status'] ?? '') !== 'active') {
                $this->bot->answerCallback($callbackId, $this->msg('game_alert_already_finished'), true);
                return;
            }
            try {
                Game::confirmCancel($sid, $userId, $this->bot, $this->config);
                $this->bot->answerCallback($callbackId, '✅ بازی لغو شد');
            } catch (\Throwable $e) {
                error_log('[inline gcancelconfirm] exception: ' . $e->getMessage());
                $this->bot->answerCallback($callbackId);
            }
            return;
        }
        if (preg_match('/^gcancelreject_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $cancelBy = (int)($session['cancel_requested_by'] ?? 0);
            $p1Id = (int)$session['player1_id'];
            $p2Id = (int)($session['player2_id'] ?? 0);
            $userNum = ($userId === $p1Id) ? 1 : (($userId === $p2Id) ? 2 : 0);
            // v2.9.11: Only the NON-requester can reject cancel
            if ($cancelBy > 0 && $userNum === $cancelBy) {
                $this->bot->answerCallback($callbackId, '❌ شما درخواست لغو را دادید. منتظر پاسخ حریف بمانید.', true);
                return;
            }
            // vNEXT (14-b/U3): rejectCancel silently refuses non-participants
            // (G-B5 gate) — don't claim success when nothing happened.
            if ($userNum === 0) {
                $this->bot->answerCallback($callbackId, $this->msg('game_alert_not_your_game'), true);
                return;
            }
            try {
                Game::rejectCancel($sid, $userId, $this->bot, $this->config);
                $this->bot->answerCallback($callbackId, '✅ درخواست لغو رد شد. بازی ادامه دارد.');
            } catch (\Throwable $e) {
                error_log('[inline gcancelreject] exception: ' . $e->getMessage());
                $this->bot->answerCallback($callbackId);
            }
            return;
        }
        if (preg_match('/^gcancelback_(\d+)$/', $data, $m)) {
            $sid = (int)$m[1];
            $session = Database::getGameSession($sid);
            if ($session === null) { $this->bot->answerCallback($callbackId); return; }
            $cancelBy = (int)($session['cancel_requested_by'] ?? 0);
            $p1Id = (int)$session['player1_id'];
            $p2Id = (int)($session['player2_id'] ?? 0);
            $userNum = ($userId === $p1Id) ? 1 : (($userId === $p2Id) ? 2 : 0);
            // v2.9.11: Only the requester can take back their request
            if ($cancelBy === 0 || $userNum !== $cancelBy) {
                $this->bot->answerCallback($callbackId, '❌ شما نمی‌توانید این درخواست را پس بگیرید.', true);
                return;
            }
            try {
                Game::cancelCancelRequest($sid, $userId, $this->bot, $this->config);
                $this->bot->answerCallback($callbackId, '✅ درخواست لغو پس گرفته شد.');
            } catch (\Throwable $e) {
                error_log('[inline gcancelback] exception: ' . $e->getMessage());
                $this->bot->answerCallback($callbackId);
            }
            return;
        }
        // ── v2.9.19: Inline gender selection (with senderId)
        //   inline_gender_boy_<senderId> / inline_gender_girl_<senderId>
        if (preg_match('/^inline_gender_(boy|girl)_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[2];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند جنسیت تنظیم کند.', true);
                return;
            }
            $gender = $m[1] === 'boy' ? 1 : 2;
            Database::setUserGender($userId, $gender);
            $this->bot->answerCallback($callbackId, '✅ جنسیت تنظیم شد');
            $this->inlineRecheck($userId, $inlineMessageId);
            return;
        }
        // ── v2.9.23: Resume game — bring the active game board to THIS inline message
        if ($data === 'inline_resume_game') {
            $this->handleInlineResumeGame($userId, $inlineMessageId, $callbackId);
            return;
        }
        // ── v2.9.19: Re-check requirements (with senderId)
        //   inline_recheck_<senderId>
        if (preg_match('/^inline_recheck_(\d+)$/', $data, $m)) {
            $senderId = (int)$m[1];
            if ($userId !== $senderId) {
                $this->bot->answerCallback($callbackId, '❌ فقط فرستنده این بازی می‌تواند تأیید کند.', true);
                return;
            }
            $this->bot->answerCallback($callbackId, '🔄 بررسی مجدد...');
            $this->inlineRecheck($userId, $inlineMessageId);
            return;
        }
        // ── back_to_main_menu: in inline mode, just ack (can't go to main menu)
        if ($data === 'back_to_main_menu') {
            $this->bot->answerCallback($callbackId);
            return;
        }
        // ── noop (display-only buttons)
        if ($data === 'noop') {
            $this->bot->answerCallback($callbackId);
            return;
        }
        // Unknown callback from inline message
        $this->bot->answerCallback($callbackId);
    }

    /**
     * v2.9.8: Re-check all requirements and update the inline message.
     * Called after gender selection or "confirm membership/bot" button.
     * Edits the inline message to show the next step or the game config.
     */
    private function inlineRecheck(int $userId, string $inlineMessageId): void
    {
        $error = InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($error !== null) {
            // Some check failed — edit the inline message to show what's needed
            // But since we can't show the exact same UI as the initial inline result,
            // just show a text message with instructions.
            // For gender: show gender buttons
            // For channels: show channel join buttons
            // For bots: show bot start buttons

            $userData = Database::loadUser($userId);
            if ($userData === null) {
                $text = "❌ برای استفاده از بازی، ابتدا ربات را /start کنید.";
                $kb = ['inline_keyboard' => []];
                $botUsername = Database::getSetting('bot_username') ?? '';
                $cleanUsername = preg_replace('/^@+/', '', $botUsername);
                if ($cleanUsername !== '') {
                    $kb['inline_keyboard'][] = [['text' => '▶️ استارت ربات', 'url' => "https://t.me/{$cleanUsername}?start=1"]];
                }
                $this->bot->editInlineMessageText($inlineMessageId, $text, json_encode($kb), 'HTML');
                return;
            }

            if ((int)($userData['gender'] ?? 0) === 0) {
                $text = "👤 جنسیت خود را انتخاب کنید:\n\nبرای استفاده از بازی، ابتدا باید جنسیت خود را مشخص کنید.";
                // v2.9.19: Encode senderId in gender callbacks
                $kb = ['inline_keyboard' => [[
                    ['text' => '👦 پسرم', 'callback_data' => 'inline_gender_boy_' . $userId],
                    ['text' => '👧 دخترم', 'callback_data' => 'inline_gender_girl_' . $userId],
                ]]];
                $this->bot->editInlineMessageText($inlineMessageId, $text, json_encode($kb), 'HTML');
                return;
            }

            $channels = Database::getMandatoryChannels();
            if (!empty($channels)) {
                if (!$this->bot->checkChannelMembership($userId, null, $this->config['telegram_bot_token'], $channels, true)) {
                    $text = "📢 برای استفاده از بازی، در کانال‌های زیر عضو شوید سپس تأیید بزنید:";
                    $kb = [];
                    foreach ($channels as $ch) {
                        $handle = is_array($ch) ? ($ch['channel_handle'] ?? '') : (string)$ch;
                        $type = is_array($ch) ? ($ch['channel_type'] ?? 'public') : 'public';
                        $link = is_array($ch) ? ($ch['join_link'] ?? null) : null;
                        $name = is_array($ch) ? ($ch['display_name'] ?? $handle) : $handle;
                        if ($type === 'private' && !empty($link)) {
                            $url = $link;
                        } elseif ($handle === '@netrex' && empty($link)) {
                            $url = 'https://t.me/+klwTyA-6QmQxYmZk';
                        } else {
                            $clean = preg_replace('/^@+/', '', $handle);
                            $url = "https://t.me/{$clean}";
                        }
                        $kb[] = [['text' => '📢 عضویت در ' . $name, 'url' => $url]];
                    }
                    $kb[] = [['text' => '✅ تأیید عضویت', 'callback_data' => 'inline_recheck_' . $userId]];
                    $this->bot->editInlineMessageText($inlineMessageId, $text, json_encode(['inline_keyboard' => $kb]), 'HTML');
                    return;
                }
            }

            if (!BotVerifier::checkMandatory($userId, null, $this->bot, $this->config, true)) {
                $text = "🤖 برای استفاده از بازی، ربات‌های اجباری را استارت کنید سپس تأیید بزنید:";
                $kb = [];
                $verifiers = Database::getMandatoryBotVerifications();
                foreach ($verifiers as $bv) {
                    $username = (string)($bv['bot_username'] ?? '');
                    if ($username === '') continue;
                    $clean = preg_replace('/^@+/', '', $username);
                    $kb[] = [['text' => '🤖 استارت ' . $username, 'url' => "https://t.me/{$clean}?start=1"]];
                }
                $kb[] = [['text' => '✅ تأیید استارت', 'callback_data' => 'inline_recheck_' . $userId]];
                $this->bot->editInlineMessageText($inlineMessageId, $text, json_encode(['inline_keyboard' => $kb]), 'HTML');
                return;
            }

            // If we get here, all checks passed but checkAllRequirements returned an error.
            // This shouldn't happen, but just in case:
            $text = "❌ خطا در بررسی. لطفاً دوباره تلاش کنید.";
            $this->bot->editInlineMessageText($inlineMessageId, $text, null, 'HTML');
            return;
        }

        // All checks pass — check if already in game
        $existing = Game::getActiveSessionForUser($userId, 'friends');
        if ($existing !== null) {
            $text = "⚠️ شما در حال حاضر در یک بازی فعال هستید.\n\nبرای بازی جدید، ابتدا بازی فعلی را پایان دهید.";
            $this->bot->editInlineMessageText($inlineMessageId, $text, null, 'HTML');
            return;
        }

        // Show the game config menu
        // v2.9.19: Pass senderId so all callbacks encode it for owner verification
        // T46-k: the explanation renders LIVE from the shared dynamic block —
        // inline friends games DO honor the chosen fee (handleInlineStart
        // overrides the session + joinSession debits fee×rounds), so the full
        // economics applies here. Inline variant = plain unicode, no slots.
        $text = "🎮 بازی دوز با دوستان\n\n" .
            // v2.9.48 (R11): rounds = duration ⌛ (was 🔄 change — mismatch).
            "💰 هزینه هر دور و ⌛ تعداد دور را انتخاب کنید:\n\n" .
            Game::feeEconomicsBlock(true, $this->config, true, 0, 1);
        $keyboard = gameInlineEntryFeeKeyboard('tictactoe', 0, 1, $userId);
        $this->bot->editInlineMessageText($inlineMessageId, $text, $keyboard, 'HTML');
    }

    /**
     * v2.9.23: Resume an active game on THIS inline message.
     * Finds the user's active friends game, updates the inline_msg_id
     * to point to this new inline message, and shows the game board.
     */
    private function handleInlineResumeGame(int $userId, string $inlineMessageId, string $callbackId): void
    {
        $session = Game::getActiveSessionForUser($userId, 'friends');
        if ($session === null || $session['status'] !== 'active') {
            $this->bot->answerCallback($callbackId, '❌ بازی فعالی پیدا نشد.', true);
            return;
        }

        $sessionId = (int)$session['id'];

        // v2.9.25: ADD this inline_message_id to the list of active inline messages
        // for this session. We keep ALL inline messages in sync (not just the latest).
        $key = 'inline_msg_ids_' . $sessionId;
        $existing = Database::getSetting($key) ?? '';
        $ids = $existing !== '' ? explode(',', $existing) : [];
        if (!in_array($inlineMessageId, $ids, true)) {
            $ids[] = $inlineMessageId;
        }
        // v2.9.45 (R6-54/LOW): cap the CSV (newest 20) — resumes used to grow
        // it unboundedly and every id costs one inline edit per sync.
        if (count($ids) > 20) {
            $ids = array_slice($ids, -20);
        }
        Database::setSetting($key, implode(',', $ids));

        // Also set the primary inline_msg_id (for backwards compat)
        Database::setSetting('inline_msg_id_' . $sessionId, $inlineMessageId);

        // Show the game board on this message
        $render = Game::renderInlineBoardMessage($session, $this->config);
        $this->bot->editInlineMessageText($inlineMessageId, $render['text'], $render['keyboard'], 'HTML');
        $this->bot->answerCallback($callbackId, '✅ بازی بازگردانده شد');
    }

    /**
     * v2.9.7: Handle a game move from an inline message.
     * Both players interact with the same inline message. The move is
     * processed by Game::handleMove which calls editOrSendBoard — for
     * inline sessions, editOrSendBoard detects the inline_message_id
     * and edits the inline message directly.
     */
    private function handleInlineGameMove(int $userId, int $sessionId, string $move, string $inlineMessageId): void
    {
        try {
            $result = Game::handleMove($sessionId, $userId, $move, $this->bot, $this->config);
        } catch (\Throwable $e) {
            error_log('[inline handleGameMove] exception sid=' . $sessionId . ' user=' . $userId . ' — ' . $e->getMessage());
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('error_generic'), true);
            return;
        }
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            $msgKeyMap = [
                'not_your_turn'     => 'game_alert_not_your_turn',
                'invalid_move'      => 'game_alert_invalid_move',
                'not_your_game'     => 'game_alert_not_your_game',
                'already_finished'  => 'game_alert_already_finished',
                'not_found'         => 'game_alert_already_finished',
                // v2.9.44 (T13-4b): joinSession refuses truthordare sessions
                // (TOD joins go through handleTODAction) — never leak the raw key.
                'wrong_game_type'   => 'game_alert_already_finished',
            ];
            $configKey = $msgKeyMap[$msgKey] ?? 'error_generic';
            $alertText = $this->msg($configKey);
            if ($alertText === '') $alertText = $this->msg('error_generic');
            $this->bot->answerCallback($this->currentCallbackId, $alertText, true);
        } else {
            $this->bot->answerCallback($this->currentCallbackId);
        }
    }

    /**
     * v2.9.7: Handle the fee stepper on an inline message.
     * Only the keyboard changes — the text stays the same.
     * Uses gameInlineEntryFeeKeyboard (no back button, no custom emoji).
     */
    private function handleInlineFeeStepper(string $inlineMessageId, string $gameType, string $action, int $currentFee, int $currentRounds, int $senderId): void
    {
        if ($action === 'minus') {
            $newFee = max(0, $currentFee - 1);
        } else {
            // T46-k: Game::FEE_MAX — the bound every economics text renders.
            $newFee = min(Game::FEE_MAX, $currentFee + 1);
        }
        $keyboard = gameInlineEntryFeeKeyboard($gameType, $newFee, $currentRounds, $senderId);
        $this->bot->editInlineMessageReplyMarkup($inlineMessageId, $keyboard);
    }

    /**
     * v2.9.7: Handle the rounds stepper on an inline message.
     * Only the keyboard changes — the text stays the same.
     * Uses gameInlineEntryFeeKeyboard (no back button, no custom emoji).
     */
    private function handleInlineRoundsStepper(string $inlineMessageId, string $gameType, int $currentFee, int $currentRounds, string $action, int $senderId): void
    {
        if ($action === 'minus') {
            $newRounds = max(1, $currentRounds - 1);
        } else {
            // T46-k: Game::ROUNDS_MAX — the bound every economics text renders.
            $newRounds = min(Game::ROUNDS_MAX, $currentRounds + 1);
        }
        $keyboard = gameInlineEntryFeeKeyboard($gameType, $currentFee, $newRounds, $senderId);
        $this->bot->editInlineMessageReplyMarkup($inlineMessageId, $keyboard);
    }

    /**
     * v2.9.6: Handle the "شروع بازی" button on an inline message.
     * Creates a friends-game session (with the configured fee + rounds),
     * stores the inline_message_id so we can later edit the message when
     * the opponent joins, and edits the inline message to show a
     * "پیوستن به بازی" button.
     */
    private function handleInlineStart(int $userId, string $inlineMessageId, string $gameType, int $fee, int $rounds): void
    {
        // v2.9.45 (R6-88/MEDIUM): inline callbacks dispatch BEFORE the generic
        // satisfaction gate — without this a satisfaction-blocked user could
        // create (and via moves, play) a friends game from an inline config
        // message. Same gate set the join paths enforce.
        $inlineStartUser = Database::loadUser($userId);
        if ($inlineStartUser !== null && !empty($inlineStartUser['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return;
        }
        $inlineStartReq = \InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($inlineStartReq !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $inlineStartReq, true);
            return;
        }

        // Check if user already has an ACTIVE game (waiting games don't block new game)
        $existing = Game::getActiveSessionForUser($userId, 'friends');
        if ($existing !== null && $existing['status'] === 'active') {
            $this->bot->answerCallback($this->currentCallbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        // v3.3 (T43): the old waiting session now STAYS ALIVE — owner
        // directive: queues are only ever dropped by ACTIVATION (the
        // track-scoped sweep at waiting→active) or the silent 24h expiry,
        // never by the user merely starting another queue.

        // Check balance for total cost
        $totalCost = $fee * $rounds;
        if ($totalCost > 0) {
            $balance = Wallet::balance($userId, 'game_token');
            if ($balance < $totalCost) {
                $this->bot->answerCallback($this->currentCallbackId,
                    "موجودی توکن بازی شما کافی نیست. هزینه هر دور: {$fee} × {$rounds} دور = {$totalCost} توکن بازی\nموجودی شما: {$balance} توکن بازی", true);
                return;
            }
        }

        // Create the friends-game session
        $result = Game::startFriends($userId, $gameType, null, $this->config);
        if (!$result['success']) {
            $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی. لطفاً دوباره تلاش کنید.', true);
            return;
        }

        $session = $result['session'];
        $sessionId = (int)$session['id'];

        // Set fee + rounds on the session (startFriends creates a free session
        // by default — we override it here with the user's chosen values)
        if ($fee > 0 || $rounds > 1) {
            $rewardType = $fee > 0 ? 'ntx' : 'game_token';
            Database::updateGameSession($sessionId, [
                'reward_type'   => $rewardType,
                'reward_amount' => $fee,
                'total_rounds'  => $rounds,
            ]);
        }

        // Store the inline_message_id so we can edit this message later
        Database::setSetting('inline_msg_id_' . $sessionId, $inlineMessageId);
        // v2.9.25: Also initialize the list of all inline message IDs
        Database::setSetting('inline_msg_ids_' . $sessionId, $inlineMessageId);
        // Store the pending session id for the join callback
        Database::setSetting('pending_friends_session_' . $userId, (string)$sessionId);

        // v2.9.10: Answer the callback to clear the loading spinner
        $this->bot->answerCallback($this->currentCallbackId, '✅ بازی ایجاد شد');

        // Edit the inline message to show full game details + "پیوستن به بازی" button
        $totalCost = $fee * $rounds;
        $p1Name = 'کاربر ' . $userId;
        $p1User = Database::loadUser($userId);
        if ($p1User && !empty($p1User['displayName']) && $p1User['displayName'] !== 'user') {
            $p1Name = $p1User['displayName'];
        }

        // v2.9.17: Full game details — same as anonymous viewer detail
        $p1Gender = (int)($p1User['gender'] ?? 0);
        $p1GenderText = ['نامشخص', 'پسر', 'دختر'][$p1Gender] ?? 'نامشخص';

        $rewardType = $fee > 0 ? 'ntx' : 'game_token';
        $currencyName = Settings::getMessage('currency_' . $rewardType, $this->config);

        // v3.0 (T37): quick games carry their own catalog label (the old text
        // hard-coded «بازی دوز با دوستان» for every type).
        // v3.1 (T40): uniform fee/rounds display for every game (دوز parity).
        $friendsTitle = $gameType === 'tictactoe'
            ? 'بازی دوز با دوستان'
            : 'بازی ' . (Game::catalog()[$gameType]['label'] ?? $gameType);
        $roundsLine = (int)$rounds > 1 ? "\n⌛ تعداد دور: {$rounds} دور" : '';
        $newText = "🎮 {$friendsTitle}\n\n" .
            "👤 بازیکن: <a href=\"tg://user?id={$userId}\">{$p1Name}</a>\n" .
            "⚧️ جنسیت: {$p1GenderText}\n" .
            $roundsLine;
        if ($fee > 0) {
            $newText .= "💰 هزینه هر دور: {$fee} توکن بازی\n";
            $newText .= "💵 مجموع هزینه: {$totalCost} توکن بازی\n";
            // T46-ap (owner): the round winner takes the WHOLE round pot (2× fee).
            $newText .= "🏆 پاداش برنده هر دور (کل استخر دور): " . ($fee * 2) . " {$currencyName}\n";
        } else {
            $newText .= "🎮 بازی رایگان — بدون پاداش\n";
        }
        $newText .= "\n⏳ وضعیت: در انتظار حریف\n\n" .
            "برای پیوستن به بازی، روی دکمه زیر بزنید:";

        // v2.9.14: Add a URL button to start the bot, above the join button
        $botUsername = Database::getSetting('bot_username') ?? '';
        $cleanUsername = preg_replace('/^@+/', '', $botUsername);
        $kbRows = [];
        if ($cleanUsername !== '') {
            $kbRows[] = [['text' => '🤖 استارت ربات', 'url' => "https://t.me/{$cleanUsername}?start=1"]];
        }
        $kbRows[] = [['text' => '▶️ پیوستن به بازی', 'callback_data' => 'gjoin_pending_' . $userId]];
        $newKeyboard = json_encode(['inline_keyboard' => $kbRows]);
        $this->bot->editInlineMessageText($inlineMessageId, $newText, $newKeyboard, 'HTML');
    }

    /**
     * v2.9.7: Handle the "پیوستن به بازی" button on an inline message.
     * The joining user (player2) joins the session. Both players share
     * the SAME inline message — the inline message is edited to show
     * the game board. NO new private message is sent to player2.
     * Both players interact with the same inline message.
     */
    private function handleInlineJoin(int $userId, int $inviterId, int $sessionId, string $inlineMessageId): void
    {
        // vNEXT (G-B8): per-join entry log was unconditional (log spam on every
        // join) — gated like G12 did for InlineHandler.
        if (defined('NETREX_DEBUG') && NETREX_DEBUG) error_log('[handleInlineJoin] userId=' . $userId . ' inviterId=' . $inviterId . ' sessionId=' . $sessionId);

        // Can't join your own game
        if ($userId === $inviterId) {
            error_log('[handleInlineJoin] self-join blocked');
            $this->bot->answerCallback($this->currentCallbackId, '❌ شما نمی‌توانید به بازی خودتان بپیوندید.', true);
            return;
        }

        // v2.9.8: Check ALL requirements for the joining user
        $error = InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($error !== null) {
            error_log('[handleInlineJoin] requirements failed: ' . $error);
            $this->bot->answerCallback($this->currentCallbackId, $error, true);
            return;
        }

        // vNEXT (23-a): satisfaction gate. checkAllRequirements does NOT cover
        // the satisfaction block, and inline callbacks are dispatched at the
        // TOP of processCallback — before its generic satisfaction gate — so
        // without this a satisfaction-blocked user could join here and pay
        // the entry fee (same gap class 21 closed for the /start joingame_
        // deep-link). Banned users are already refused in processCallback.
        // Same wording/behavior as gateTODJoin so every join path is uniform.
        $joinUser = Database::loadUser($userId);
        if ($joinUser !== null && !empty($joinUser['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return;
        }

        // Join the session
        $result = Game::joinSession($sessionId, $userId, $this->bot, $this->config);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            error_log('[handleInlineJoin] joinSession failed: ' . $msgKey);
            if ($msgKey === 'insufficient_entry_fee') {
                $fee = $result['fee'] ?? 1;
                $rounds = $result['rounds'] ?? 1;
                $totalCost = $fee * $rounds;
                $this->bot->answerCallback($this->currentCallbackId,
                    "موجودی توکن بازی شما کافی نیست. هزینه هر دور: {$fee} × {$rounds} دور = {$totalCost} توکن بازی", true);
            } elseif ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($this->currentCallbackId,
                    Settings::getMessage('game_already_active', $this->config), true);
            } else {
                // v2.9.44 (T16-1a): never leak raw machine keys (already_started_or_finished,
                // game_just_filled, wrong_game_type, not_found …) as alert text.
                $joinAlerts = [
                    'already_started_or_finished' => 'این بازی دیگر در دسترس نیست.',
                    'game_just_filled' => 'متأسفانه بازیکن دیگری زودتر از شما به بازی پیوست.',
                    'wrong_game_type'  => 'این بازی دیگر در دسترس نیست.',
                    'not_found'        => 'این بازی دیگر در دسترس نیست.',
                ];
                $this->bot->answerCallback($this->currentCallbackId,
                    $joinAlerts[$msgKey] ?? $this->msg('error_generic'), true);
            }
            return;
        }

        // Success — ack the callback
        $this->bot->answerCallback($this->currentCallbackId);

        $session = $result['session'];
        // v2.9.44 (T16-7): the inviter's pending pointer has served its purpose —
        // clear it (only when it still points at THIS session) so stale
        // gjoin_pending_ taps can't resolve to a dead game later.
        $inviterIdResolved = (int)($session['player1_id'] ?? $inviterId);
        $pendingVal = Database::getSetting('pending_friends_session_' . $inviterIdResolved);
        if ($pendingVal === (string)$sessionId) {
            Database::deleteSetting('pending_friends_session_' . $inviterIdResolved);
        }
        if (defined('NETREX_DEBUG') && NETREX_DEBUG) error_log('[handleInlineJoin] joinSession OK, status=' . ($session['status'] ?? '?'));

        // Edit the inline message to show the game board
        $render = Game::renderInlineBoardMessage($session, $this->config);
        $editResult = $this->bot->editInlineMessageText($inlineMessageId, $render['text'], $render['keyboard'], 'HTML');
        if ($editResult === null || (isset($editResult->ok) && !$editResult->ok)) {
            error_log('[handleInlineJoin] editInlineMessageText FAILED: ' . ($editResult->description ?? 'null'));
        } else {
            if (defined('NETREX_DEBUG') && NETREX_DEBUG) error_log('[handleInlineJoin] editInlineMessageText OK');
        }
    }

    private function showGameMenu(int $userId, int $messageId): void
    {
        // T46-c (owner): users did not know what «شروع بازی» does — the menu
        // it opens now explains both modes and what comes next.
        // T46-m: this is now the «بازی متنی» destination (the games section
        // itself first asks صوتی/متنی via showGameModeChooser).
        $text = emoji(40) . " <b>شروع بازی متنی</b>\n\n"
            . "حالت بازی را انتخاب کن:\n"
            . emoji(35) . " <b>بازی ناشناس:</b> با یک حریف تصادفی رقابت کن.\n"
            . emoji(7) . " <b>بازی با دوستان:</b> بازی را برای دوستانت بفرست و با آن‌ها بازی کن.\n\n"
            . emoji(0) . " بعد از انتخاب حالت، بازی و شرط‌بندی را خودت تعیین می‌کنی.";
        $this->bot->editMessage($userId, $messageId, $text, gameMenuKeyboard());
    }

    /** T46-m: the «صوتی یا متنی؟» chooser — the new first screen of بازی‌ها. */
    private function showGameModeChooser(int $userId, int $messageId): void
    {
        $this->bot->editMessage($userId, $messageId, gameModeChooserText($this->config), gameModeChooserKeyboard());
    }

    /** T46-m: the voice-games list (Mafia lives here). */
    private function showVoiceGames(int $userId, int $messageId): void
    {
        $cfg = MafiaGame::cfg($this->config);
        $this->bot->editMessage($userId, $messageId, voiceGamesText($this->config), voiceGamesKeyboard($cfg['enabled']));
    }

    /** T46-m: the full, dynamic mafia description screen. */
    private function showMafiaInfo(int $userId, int $messageId): void
    {
        $cfg = MafiaGame::cfg($this->config);
        $this->bot->editMessage($userId, $messageId, MafiaGame::infoText($this->config), mafiaMenuKeyboard($cfg['group_id'] !== 0));
    }

    private function showGameTypeMenu(int $userId, int $messageId, string $mode): void
    {
        $this->bot->editMessage($userId, $messageId, emoji(40) . ' یک نوع بازی را انتخاب کنید:', gameTypeKeyboard($mode));
    }

    private function showEntryFeeStepper(int $userId, int $messageId, string $mode, string $gameType): void
    {
        // v3.1 (T40): EVERY game has تنظیمات like دوز — the fee/rounds stepper
        // applies to all quick games too (owner directive). The default fee is
        // the panel's game_tictactoe_entry_fee for دوز and 0 for all others.
        // Quick games also get a game-name + rules header (the old flow jumped
        // to settings without ever naming the picked game — confusing).
        $header = '';
        if (Game::isQuickGame($gameType)) {
            $label = Game::catalog()[$gameType]['label'] ?? $gameType;
            try {
                $rules = Game::load($gameType)->getRulesText();
            } catch (\Throwable $e) {
                $rules = '';
            }
            $header = emoji(0) . " بازی «{$label}»\n\n" . $rules . "\n\n";
        }
        if ($gameType === 'tictactoe') {
            $defaultFee = (int)(Settings::getSetting('game_tictactoe_entry_fee') ?? '0');
        } else {
            $defaultFee = 0;
        }
        // T46-k: clamp via Game::FEE_MAX and render the explanation LIVE from
        // the shared block (feeApplies=false for the friends path — startFriendsGame
        // ignores fee/rounds, so the text must say that instead of fees that
        // would never apply).
        $defaultFee = max(0, min(Game::FEE_MAX, $defaultFee));
        $text = $header . emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, $mode !== 'friends', $defaultFee, 1);
        $this->bot->editMessage($userId, $messageId, $text, gameEntryFeeKeyboard($mode, $gameType, $defaultFee, 1));
    }

    // ─── Truth or Dare (v2.9.33) ────────────────────────────────────

    /**
     * Show the TOD config stepper (anonymous PV mode).
     * User picks the penalty fee (per excess change) and the number of
     * free changes allowed per player, then taps "شروع بازی".
     */

    /**
     * v2.9.35: Handle TOD game action callbacks (both PV and inline).
     * Actions: begin, join, truth, dare, change, done, end
     */
    private function handleTODCallback(int $userId, string $action, int $sessionId, string $callbackId, int $chatId, int $messageId, bool $isInline): void
    {
        try {
            $result = Game::handleTODAction($sessionId, $userId, $action, $this->bot, $this->config);
        } catch (\Throwable $e) {
            error_log('[TOD callback] exception: ' . $e->getMessage());
            $this->bot->answerCallback($callbackId, '❌ خطا رخ داد.', true);
            return;
        }

        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            $alertMap = [
                'not_found'           => '❌ بازی پیدا نشد.',
                'already_started'     => '❌ بازی قبلاً شروع شده.',
                'need_more_players'   => '❌ حداقل ۲ نفر برای شروع لازم است.',
                'not_in_game'         => '❌ شما در این بازی نیستید.',
                'not_your_turn'       => '❌ نوبت شما نیست!',
                'no_question'         => '❌ هنوز سوالی انتخاب نشده.',
                'not_active'          => '❌ بازی فعال نیست.',
                'game_ended'          => '❌ بازی به پایان رسیده.',
                'already_in_game'     => '❌ شما قبلاً در بازی هستید.',
            ];
            $alert = $alertMap[$msgKey] ?? '❌ خطا.';
            $this->bot->answerCallback($callbackId, $alert, true);
            return;
        }

        // Success messages
        $successMsgs = [
            'started'        => '✅ بازی شروع شد!',
            'joined'         => '✅ به بازی پیوستید!',
            'question_shown' => '',
            'changed'        => '✅ سوال تغییر کرد!',
            'done'           => '✅ نوبت بعدی!',
            'ended'          => '✅ بازی پایان یافت.',
        ];
        $msg = $successMsgs[$result['message'] ?? ''] ?? '';
        $this->bot->answerCallback($callbackId, $msg);

        // Reload session and update board
        $session = Database::getGameSession($sessionId);
        if ($session === null) return;

        if ($isInline) {
            // Update all inline messages
            $allIdsStr = Database::getSetting('inline_msg_ids_' . $sessionId) ?? '';
            $allIds = $allIdsStr !== '' ? explode(',', $allIdsStr) : [];
            $primaryId = Database::getSetting('inline_msg_id_' . $sessionId) ?? '';
            if ($primaryId !== '' && !in_array($primaryId, $allIds, true)) {
                $allIds[] = $primaryId;
            }
            $render = Game::renderInlineBoardMessage($session, $this->config);
            foreach ($allIds as $imid) {
                $imid = trim($imid);
                if ($imid === '') continue;
                try {
                    $this->bot->editInlineMessageText($imid, $render['text'], $render['keyboard'], 'HTML');
                } catch (\Throwable $e) {
                    error_log('[TOD callback] inline edit failed: ' . $e->getMessage());
                }
            }
        } elseif ($chatId < 0) {
            // v2.9.18: Group mode — edit the group message with group rendering
            $render = Game::renderGroupBoardMessage($session, $this->config);
            $this->bot->editMessage($chatId, $messageId, $render['text'], $render['keyboard']);
        } else {
            // PV mode — edit the message
            $render = Game::renderBoardMessage($session, $userId, $this->config);
            $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
        }
    }

    private function showTODConfigStepper(int $userId, int $messageId, string $mode, string $gameType, string $category = 'normal'): void
    {
        $defaultFee = (int)($this->config['game']['tod_default_fee'] ?? 1);
        $defaultMax = (int)($this->config['game']['tod_default_max_changes'] ?? 3);
        $text = self::todConfigText();
        $this->bot->editMessage($userId, $messageId, $text, gameTODConfigKeyboard($mode, $gameType, $defaultFee, $defaultMax, $category));
    }

    /**
     * v2.9.38: Shared, concise RTL-friendly config text for all TOD steppers.
     */
    private static function todConfigText(): string
    {
        // v2.9.45 (R6-36): this text is only ever rendered in PV/group
        // (editMessage) — plain 💰/🔄/🎯 became custom emoji placeholders
        // (mapping: 💰→20, 🔄→13, 🎯→0). Never used inline, so no plain branch.
        return emoji(0) . " <b>جرات یا حقیقت</b>\n\n" .
            "تنظیمات بازی رو انتخاب کنید:\n\n" .
            emoji(20) . " جریمه تغییر اضافی — عدد بالای صفر، هر تغییر مازاد جریمه داره\n" .
            emoji(13) . " تعداد تغییر مجاز — تعداد دفعاتی که سوال رو رایگان عوض می‌کنید\n" .
            emoji(0) . " دسته — عادی یا کاپلی";
    }

    /**
     * v2.9.35: Handle the TOD category toggle (anonymous PV mode).
     * Switches between عادی and کاپلی question categories.
     */
    private function handleTODCategoryToggle(int $userId, int $messageId, string $mode, string $category, int $currentFee, int $currentMaxChanges): void
    {
        $newCat = $category === 'normal' ? 'couple' : 'normal';
        $this->bot->editMessage($userId, $messageId, self::todConfigText(), gameTODConfigKeyboard($mode, 'truthordare', $currentFee, $currentMaxChanges, $newCat));
    }

    /**
     * Handle the TOD fee stepper (anonymous PV mode).
     */
    private function handleTODFeeAction(int $userId, int $messageId, string $action, int $currentFee, int $currentMaxChanges, string $category = 'normal'): void
    {
        if ($action === 'minus') {
            $newFee = max(0, $currentFee - 1);
        } else {
            $newFee = min(Game::FEE_MAX, $currentFee + 1);
        }
        $this->bot->editMessage($userId, $messageId, self::todConfigText(), gameTODConfigKeyboard('anonymous', 'truthordare', $newFee, $currentMaxChanges, $category));
    }

    /**
     * Handle the TOD max-changes stepper (anonymous PV mode).
     */
    private function handleTODChangesAction(int $userId, int $messageId, int $currentFee, int $currentMaxChanges, string $action, string $category = 'normal'): void
    {
        if ($action === 'minus') {
            $newMax = max(0, $currentMaxChanges - 1);
        } else {
            $newMax = min(20, $currentMaxChanges + 1);
        }
        $this->bot->editMessage($userId, $messageId, self::todConfigText(), gameTODConfigKeyboard('anonymous', 'truthordare', $currentFee, $newMax, $category));
    }

    /**
     * v2.9.45 (R6-25): anonymous TOD gender-preference step (دوز parity).
     * Shown after «شروع بازی» on the anonymous TOD config screen; the actual
     * queue start happens in handleTODStart via the todmatch_ callback.
     * Uses the SAME prompt text the دوز flow uses (game_match_prompt).
     */
    private function showTODMatchPreference(int $userId, int $messageId, string $category, int $fee, int $maxChanges): void
    {
        $this->bot->editMessage($userId, $messageId,
            emoji(40) . ' ' . $this->msg('game_match_prompt'),
            todMatchPreferenceKeyboard($category, $fee, $maxChanges));
    }

    /**
     * Handle the TOD start button (anonymous PV mode).
     * Creates a waiting TOD session and edits the current menu message into
     * the waiting/game board.
     *
     * v2.9.45 (R6-26): anonymous TOD is دوز-parity strict 2-player auto-match —
     * Game::startTOD either MATCHES the user into an existing compatible queue
     * (game auto-begins; 'joined') or creates a fresh waiting queue ('waiting').
     * Old WAITING sessions are only cancelled when they are truthordare (R6-4)
     * — a waiting دوز queue stays alive (multi-queue while waiting is allowed).
     */
    private function handleTODStart(int $userId, int $messageId, int $fee, int $maxChanges, array $userData, string $category = 'normal', int $matchPref = 0): void
    {
        // v2.9.39: Fixed resume logic — only resume ACTIVE games. Old WAITING
        // sessions are cancelled so the user can start a fresh game with new
        // settings (fee / max-changes / category). Previously, ANY waiting or
        // active TOD session was "resumed", which meant the user could never
        // start a new game if they had an old waiting one — the bot just kept
        // showing the old board with old settings.
        $existing = Game::getActiveSessionForUser($userId, 'anonymous');

        // ACTIVE TOD game → resume it (show the board on the current message)
        if ($existing !== null && $existing['status'] === 'active' && $existing['game_type'] === 'truthordare') {
            $this->bot->answerCallback($this->currentCallbackId);
            $render = Game::renderBoardMessage($existing, $userId, $this->config);
            $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
            // v2.9.45 (R6-42/MEDIUM-1): only the CREATOR may repoint
            // p1_board_msg_id — a JOINER re-entering via this branch used to
            // hijack the creator's board pointer into the joiner's chat, so
            // every later creator-board edit (cancel family, cron, sweep)
            // targeted the wrong chat. Joiners register via the joiner map.
            if ((int)$existing['player1_id'] === $userId) {
                Database::updateGameSession((int)$existing['id'], ['p1_board_msg_id' => $messageId]);
            } else {
                Game::addTodJoinerBoard((int)$existing['id'], $userId, $messageId);
            }
            return;
        }

        // Another ACTIVE game (not TOD) → block
        if ($existing !== null && $existing['status'] === 'active') {
            $this->bot->answerCallback($this->currentCallbackId,
                Settings::getMessage('game_already_active', $this->config), true);
            return;
        }

        // Old WAITING session → cancel it so we can create a fresh one with
        // the user's newly-picked settings.
        // vNEXT (17-a): route through Game::cancelWaitingSession — it gates
        // creator+waiting (no-op otherwise) and covers the inline/group PV
        // edit + inactivity/cancel_msg/inline/group KV cleanup in one place
        // (Game.php:1089). pending_friends_session_ is user-keyed, so it is
        // still deleted explicitly.
        // v2.9.45 (R6-4): ONLY cancel WAITING TRUTHORDARE queues — a waiting
        // دوز (or any other type) must NOT be hijacked by a TOD start
        // (multi-queue while waiting is allowed; the first match sweeps the rest).
        if ($existing !== null && $existing['status'] === 'waiting' && (string)($existing['game_type'] ?? '') === 'truthordare') {
            $oldSid = (int)$existing['id'];
            Game::cancelWaitingSession($oldSid, $userId, $this->bot, $this->config);
            Database::deleteSetting('pending_friends_session_' . $userId);
        }

        try {
            $result = Game::startTOD($userId, 'anonymous', null, $this->config, $fee, $maxChanges, $category, $matchPref, $this->bot);
        } catch (\Throwable $e) {
            error_log('[TOD start] exception: ' . $e->getMessage());
            $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی.', true);
            return;
        }
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($this->currentCallbackId,
                    Settings::getMessage('game_already_active', $this->config), true);
            } else {
                $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی.', true);
            }
            return;
        }

        $session = $result['session'];
        if ($session === null) {
            $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی.', true);
            return;
        }
        $sessionId = (int)$session['id'];

        // v2.9.45 (R6-26): matched path — the user filled seat 2 of an existing
        // queue and the game AUTO-BEGAN. startTOD (bot passed) already
        // re-rendered the creator's board; here we only turn THIS user's
        // message into their own board and register it for future syncs.
        // IMPORTANT: never overwrite p1_board_msg_id with the joiner's msg id.
        if (!empty($result['joined'])) {
            $this->bot->answerCallback($this->currentCallbackId, '✅ بازی شروع شد!');
            $render = Game::renderBoardMessage($session, $userId, $this->config);
            $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
            Game::addTodJoinerBoard($sessionId, $userId, $messageId);
            return;
        }

        // Waiting path — store the waiting message's msg_id as p1_board_msg_id
        // so the board can be edited in-place when player 2 auto-matches.
        Database::updateGameSession($sessionId, ['p1_board_msg_id' => $messageId]);

        $this->bot->answerCallback($this->currentCallbackId, '✅ بازی ایجاد شد');
        $render = Game::renderBoardMessage($session, $userId, $this->config);
        $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
    }

    /**
     * Handle TOD board actions (anonymous PV mode).
     * Actions: begin, join, truth, dare, change, done, end.
     * After the action, reloads the session and re-renders the board.
     */
    private function handleTODBoardAction(int $userId, string $action, int $sessionId, int $messageId): void
    {
        // vNEXT (14-b/U1): same join gates as the TTT join path — the tod_
        // regex above runs BEFORE the registration/gender gates, so
        // unregistered / genderless users could join via PV+group boards.
        if ($action === 'join' && !$this->gateTODJoin($userId)) return;
        try {
            $result = Game::handleTODAction($sessionId, $userId, $action, $this->bot, $this->config);
        } catch (\Throwable $e) {
            error_log('[TOD board action] exception sid=' . $sessionId . ' user=' . $userId . ' action=' . $action . ' — ' . $e->getMessage());
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('error_generic'), true);
            return;
        }

        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            // v2.9.45 (R6-27): the anonymous cap is exactly 2 players (R6-12) —
            // word the game_full alert per session mode (group/inline keep the
            // 30-player wording).
            $gameFullAlert = 'ظرفیت این بازی پر است (حداکثر ۳۰ نفر).';
            $alertModeSession = Database::getGameSession($sessionId);
            if ($alertModeSession !== null && (string)($alertModeSession['mode'] ?? '') === 'anonymous') {
                $gameFullAlert = 'این بازی ۲ نفره است و ظرفیت آن تکمیل شده.';
            }
            $alertMap = [
                'not_your_turn'    => 'نوبت شما نیست!',
                'already_started'  => 'بازی قبلاً شروع شده است.',
                'not_in_game'      => 'شما در این بازی نیستید.',
                'need_more_players' => 'برای شروع، حداقل ۲ بازیکن لازم است.',
                'game_ended'       => 'بازی پایان یافته است.',
                'no_question'      => 'هیچ سوالی برای تغییر وجود ندارد.',
                'not_active'       => 'بازی فعال نیست.',
                'already_in_game'  => 'شما قبلاً در این بازی هستید.',
                'wrong_game_type'  => 'نوع بازی نامعتبر است.',
                'not_found'        => 'بازی پیدا نشد.',
                'unknown_action'   => 'عمل نامعتبر.',
                // vNEXT (G-B11): Game.php sends the same wording as a PV
                // message on the debit-refusal path — the alert maps here were
                // missing the key, so the user got a generic error alert.
                'insufficient_fee_for_change' => 'موجودی توکن بازی شما کافی نیست. تغییر سوال انجام نشد.',
                // vNEXT (14-b/U2): keys 14-a added to handleTODAction — question
                // fetch failure (no question in DB bucket or JSON fallback) and
                // the join capacity cap (30 players).
                'question_unavailable' => 'فعلاً سوالی در دسترس نیست؛ لطفاً چند لحظه بعد دوباره تلاش کنید.',
                'game_full' => $gameFullAlert,
                // v2.9.45 (R6-13/R6-14): creator-only begin/end alerts (group
                // boards tap the shared شروع بازی / پایان بازی buttons too).
                'only_creator_can_start' => 'فقط سازنده بازی می‌تواند بازی را شروع کند.',
                'not_game_owner'         => 'فقط سازنده بازی می‌تواند بازی را پایان دهد.',
                // v2.9.45 (R6-57): per-session mutation lock busy — retry toast.
                'busy_try_again' => 'لطفاً چند لحظه بعد دوباره تلاش کنید.',
            ];
            $alertText = $alertMap[$msgKey] ?? $this->msg('error_generic');
            $this->bot->answerCallback($this->currentCallbackId, $alertText, true);
            return;
        }

        // Success — ack the callback
        // v2.9.45 (R6-12): 'joined_started' (anonymous seat-2 join auto-began
        // the game) is acked like a normal successful join.
        $successMsgs = [
            'begin'  => '✅ بازی شروع شد!',
            'join'   => '✅ شما به بازی پیوستید!',
            'truth'  => '✅ حقیقت انتخاب شد.',
            'dare'   => '✅ جرات انتخاب شد.',
            'change' => '✅ سوال تغییر کرد.',
            'done'   => '✅ نوبت بعدی!',
            'end'    => '✅ بازی پایان یافت.',
        ];
        $ackText = $successMsgs[$action] ?? '✅';
        $this->bot->answerCallback($this->currentCallbackId, $ackText);

        // Reload the session and re-render the board on the current message
        $session = Database::getGameSession($sessionId);
        if ($session === null) return;
        // v2.9.38: Sync PV + inline boards so the creator's PV message and the
        // shared inline message both reflect the latest game state.
        $this->updateAllGameMessages($session, $userId, $messageId, 0);
    }

    /**
     * v2.9.22: Update all game messages (PV, group, inline) after a session change.
     * Used by force-end and other actions that need to refresh all related messages.
     */
    private function updateAllGameMessages(array $session, int $userId, int $messageId, int $chatId): void
    {
        $sessionId = (int)$session['id'];
        $sessionMode = (string)($session['mode'] ?? 'anonymous');

        // Reload session
        $session = Database::getGameSession($sessionId);
        if ($session === null) return;

        // Update PV message (for anonymous mode)
        if ($sessionMode === 'anonymous') {
            try {
                $render = Game::renderBoardMessage($session, $userId, $this->config);
                $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
            } catch (\Throwable $e) {
                error_log('[updateAllGameMessages] PV edit failed: ' . $e->getMessage());
            }
            // v2.9.44 (T16-3): also refresh every viewer-joined player's own PV
            // board (registered in tod_joiner_boards_<sid>) so nobody is left on
            // a stale waiting keyboard after شروع بازی / turn changes. Skip the
            // exact (tapper, message) pair already edited above.
            $joinerBoards = Game::getTodJoinerBoards($sessionId);
            foreach ($joinerBoards as $jUid => $jMid) {
                if ($jUid === $userId && $jMid === $messageId) continue;
                try {
                    $jRender = Game::renderBoardMessage($session, (int)$jUid, $this->config);
                    $this->bot->editMessage((int)$jUid, (int)$jMid, $jRender['text'], $jRender['keyboard']);
                } catch (\Throwable $e) {
                    error_log('[updateAllGameMessages] joiner board edit failed sid=' . $sessionId . ' uid=' . $jUid . ': ' . $e->getMessage());
                }
            }
            // v2.9.45 (R6-48/MEDIUM): sync the CREATOR's board too when the
            // tapper is a joiner — without this the creator's PV board kept
            // the old question/turn state while the joiner's board advanced
            // (the viewer-join and inline paths already sync the creator).
            $creatorId = (int)($session['player1_id'] ?? 0);
            if ($creatorId > 0 && $creatorId !== $userId) {
                $creatorMsgId = (int)($session['p1_board_msg_id'] ?? 0);
                if ($creatorMsgId > 0 && !isset($joinerBoards[$creatorId])) {
                    try {
                        $cRender = Game::renderBoardMessage($session, $creatorId, $this->config);
                        $this->bot->editMessage($creatorId, $creatorMsgId, $cRender['text'], $cRender['keyboard']);
                    } catch (\Throwable $e) {
                        error_log('[updateAllGameMessages] creator board edit failed sid=' . $sessionId . ': ' . $e->getMessage());
                    }
                }
            }
        }

        // Update group message
        if ($sessionMode === 'group') {
            $groupChatId = (int)(Database::getSetting('group_chat_id_' . $sessionId) ?? '0');
            $groupMsgId = (int)(Database::getSetting('group_msg_id_' . $sessionId) ?? '0');
            // v3.3 (T44) CRITICAL SIGN FIX: group chat ids are NEGATIVE
            // (-100…). The old `$groupChatId > 0` guard was written for PV
            // user ids and was ALWAYS false for groups — every group-board
            // refresh (TOD join/begin/turn, quick-game leaves, …) was silently
            // skipped: the joiner got the «پیوستید» toast while the group
            // board froze on the 1-player waiting view forever.
            if ($groupChatId !== 0 && $groupMsgId > 0) {
                try {
                    $render = Game::renderGroupBoardMessage($session, $this->config);
                    $this->bot->editMessage($groupChatId, $groupMsgId, $render['text'], $render['keyboard']);
                } catch (\Throwable $e) {
                    error_log('[updateAllGameMessages] group edit failed: ' . $e->getMessage());
                }
            }
        }

        // Update inline messages
        $allIdsStr = Database::getSetting('inline_msg_ids_' . $sessionId) ?? '';
        $allIds = $allIdsStr !== '' ? explode(',', $allIdsStr) : [];
        $primaryId = Database::getSetting('inline_msg_id_' . $sessionId) ?? '';
        if ($primaryId !== '' && !in_array($primaryId, $allIds, true)) {
            $allIds[] = $primaryId;
        }
        if (!empty($allIds)) {
            $render = Game::renderInlineBoardMessage($session, $this->config);
            foreach ($allIds as $imid) {
                $imid = trim($imid);
                if ($imid === '') continue;
                try {
                    $this->bot->editInlineMessageText($imid, $render['text'], $render['keyboard'], 'HTML');
                } catch (\Throwable $e) {
                    error_log('[updateAllGameMessages] inline edit failed: ' . $e->getMessage());
                }
            }
        }
    }

    // ─── Inline-mode TOD handlers (v2.9.33) ────────────────────────

    /**
     * v2.9.33: Handle the TOD fee stepper on an inline message.
     * v2.9.35: Added category param. Only the keyboard changes — the text stays the same.
     */
    private function handleInlineTODFeeStepper(string $inlineMessageId, string $gameType, string $action, int $currentFee, int $currentMaxChanges, int $senderId, string $category = 'normal'): void
    {
        if ($action === 'minus') {
            $newFee = max(0, $currentFee - 1);
        } else {
            $newFee = min(Game::FEE_MAX, $currentFee + 1);
        }
        $keyboard = gameInlineTODConfigKeyboard($gameType, $newFee, $currentMaxChanges, $senderId, $category);
        $this->bot->editInlineMessageReplyMarkup($inlineMessageId, $keyboard);
    }

    /**
     * v2.9.33: Handle the TOD max-changes stepper on an inline message.
     * v2.9.35: Added category param. Only the keyboard changes — the text stays the same.
     */
    private function handleInlineTODChangesStepper(string $inlineMessageId, string $gameType, int $currentFee, int $currentMaxChanges, string $action, int $senderId, string $category = 'normal'): void
    {
        if ($action === 'minus') {
            $newMax = max(0, $currentMaxChanges - 1);
        } else {
            $newMax = min(20, $currentMaxChanges + 1);
        }
        $keyboard = gameInlineTODConfigKeyboard($gameType, $currentFee, $newMax, $senderId, $category);
        $this->bot->editInlineMessageReplyMarkup($inlineMessageId, $keyboard);
    }

    /**
     * v2.9.35: Handle the TOD category toggle on an inline message.
     * Switches between عادی and کاپلی. Only the keyboard changes.
     */
    private function handleInlineTODCategoryToggle(string $inlineMessageId, string $gameType, string $category, int $currentFee, int $currentMaxChanges, int $senderId): void
    {
        $newCat = $category === 'normal' ? 'couple' : 'normal';
        $keyboard = gameInlineTODConfigKeyboard($gameType, $currentFee, $currentMaxChanges, $senderId, $newCat);
        $this->bot->editInlineMessageReplyMarkup($inlineMessageId, $keyboard);
    }

    /**
     * v2.9.33: Handle the "شروع بازی" button on an inline TOD message.
     * v2.9.35: Added category param — passed to Game::startTOD.
     * Creates a friends TOD session, stores the inline_message_id, and edits
     * the inline message to show "waiting for players" with a join button.
     */
    private function handleInlineTODStart(int $userId, string $inlineMessageId, string $gameType, int $fee, int $maxChanges, string $category = 'normal'): void
    {
        // v2.9.45 (R6-88/MEDIUM): same inline-start gate (see handleInlineStart).
        $todStartUser = Database::loadUser($userId);
        if ($todStartUser !== null && !empty($todStartUser['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return;
        }
        $todStartReq = \InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($todStartReq !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $todStartReq, true);
            return;
        }

        // Check if user already has an ACTIVE TOD game (waiting games don't block)
        $existing = Game::getActiveSessionForUser($userId, 'friends');
        if ($existing !== null && $existing['status'] === 'active' && $existing['game_type'] === 'truthordare') {
            $this->bot->answerCallback($this->currentCallbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            return;
        }
        // v3.3 (T43): the old waiting TOD queue now STAYS ALIVE — owner
        // directive: queues are only ever dropped by ACTIVATION (the
        // track-scoped sweep at waiting→active) or the silent 24h expiry,
        // never by the user merely starting another queue.

        try {
            $result = Game::startTOD($userId, 'friends', null, $this->config, $fee, $maxChanges, $category);
        } catch (\Throwable $e) {
            error_log('[TOD inline start] exception: ' . $e->getMessage());
            $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی.', true);
            return;
        }
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($this->currentCallbackId, 'شما در حال حاضر در یک بازی فعال هستید.', true);
            } else {
                $this->bot->answerCallback($this->currentCallbackId, 'خطا در ایجاد بازی.', true);
            }
            return;
        }

        $session = $result['session'];
        $sessionId = (int)$session['id'];

        // Store the inline_message_id so we can edit this message later
        Database::setSetting('inline_msg_id_' . $sessionId, $inlineMessageId);
        Database::setSetting('inline_msg_ids_' . $sessionId, $inlineMessageId);
        Database::setSetting('pending_friends_session_' . $userId, (string)$sessionId);

        $this->bot->answerCallback($this->currentCallbackId, '✅ بازی ایجاد شد');

        // Render the board (waiting state) on the inline message
        $render = Game::renderInlineBoardMessage($session, $this->config);
        $this->bot->editInlineMessageText($inlineMessageId, $render['text'], $render['keyboard'], 'HTML');
    }

    /**
     * v2.9.33: Handle TOD board actions from an inline message.
     * Both players interact with the same inline message — after the action,
     * the inline message is edited to show the updated board.
     */
    private function handleInlineTODBoardAction(int $userId, string $action, int $sessionId, string $inlineMessageId, string $callbackId): void
    {
        // vNEXT (14-b/U1): same join gates as the TTT inline join path —
        // inline callbacks bypass the registration/gender gates entirely.
        if ($action === 'join' && !$this->gateTODJoin($userId)) return;
        try {
            $result = Game::handleTODAction($sessionId, $userId, $action, $this->bot, $this->config);
        } catch (\Throwable $e) {
            error_log('[TOD inline board action] exception sid=' . $sessionId . ' user=' . $userId . ' action=' . $action . ' — ' . $e->getMessage());
            $this->bot->answerCallback($callbackId, $this->msg('error_generic'), true);
            return;
        }

        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            $alertMap = [
                'not_your_turn'    => 'نوبت شما نیست!',
                'already_started'  => 'بازی قبلاً شروع شده است.',
                'not_in_game'      => 'شما در این بازی نیستید.',
                'need_more_players' => 'برای شروع، حداقل ۲ بازیکن لازم است.',
                'game_ended'       => 'بازی پایان یافته است.',
                'no_question'      => 'هیچ سوالی برای تغییر وجود ندارد.',
                'not_active'       => 'بازی فعال نیست.',
                'already_in_game'  => 'شما قبلاً در این بازی هستید.',
                'wrong_game_type'  => 'نوع بازی نامعتبر است.',
                'not_found'        => 'بازی پیدا نشد.',
                'unknown_action'   => 'عمل نامعتبر.',
                // vNEXT (G-B11): Game.php sends the same wording as a PV
                // message on the debit-refusal path — the alert maps here were
                // missing the key, so the user got a generic error alert.
                'insufficient_fee_for_change' => 'موجودی توکن بازی شما کافی نیست. تغییر سوال انجام نشد.',
                // vNEXT (14-b/U2): keys 14-a added to handleTODAction — question
                // fetch failure (no question in DB bucket or JSON fallback) and
                // the join capacity cap (30 players).
                'question_unavailable' => 'فعلاً سوالی در دسترس نیست؛ لطفاً چند لحظه بعد دوباره تلاش کنید.',
                'game_full' => 'ظرفیت این بازی پر است (حداکثر ۳۰ نفر).',
                // v2.9.45 (R6-57): per-session mutation lock busy — retry toast.
                'busy_try_again' => 'لطفاً چند لحظه بعد دوباره تلاش کنید.',
                // v2.9.45 (R6-27): creator-only begin/end alerts — inline/friends
                // boards keep the shared شروع بازی button for every viewer; the
                // server gates it to the creator (R6-13), so surface the alert.
                'only_creator_can_start' => 'فقط سازنده بازی می‌تواند بازی را شروع کند.',
                'not_game_owner'         => 'فقط سازنده بازی می‌تواند بازی را پایان دهد.',
            ];
            $alertText = $alertMap[$msgKey] ?? $this->msg('error_generic');
            $this->bot->answerCallback($callbackId, $alertText, true);
            return;
        }

        // Success — ack the callback
        $successMsgs = [
            'begin'  => '✅ بازی شروع شد!',
            'join'   => '✅ شما به بازی پیوستید!',
            'truth'  => '✅ حقیقت انتخاب شد.',
            'dare'   => '✅ جرات انتخاب شد.',
            'change' => '✅ سوال تغییر کرد.',
            'done'   => '✅ نوبت بعدی!',
            'end'    => '✅ بازی پایان یافت.',
        ];
        $ackText = $successMsgs[$action] ?? '✅';
        $this->bot->answerCallback($callbackId, $ackText);

        // Reload the session
        $session = Database::getGameSession($sessionId);
        if ($session === null) return;

        // v2.9.33: Update ALL inline messages associated with this session
        // (in case the user sent the inline result multiple times).
        $allIdsStr = Database::getSetting('inline_msg_ids_' . $sessionId) ?? '';
        $allIds = $allIdsStr !== '' ? explode(',', $allIdsStr) : [];
        if (!in_array($inlineMessageId, $allIds, true)) {
            $allIds[] = $inlineMessageId;
            // vNEXT (14-b/U7): after a terminal transition (end/finish) Game.php
            // already swept this session's KV — do NOT re-create the keys for a
            // finished/cancelled session. The edits below still proceed using
            // the in-memory ids.
            if (!in_array((string)($session['status'] ?? ''), ['finished', 'cancelled'], true)) {
                Database::setSetting('inline_msg_ids_' . $sessionId, implode(',', $allIds));
            }
        }
        $render = Game::renderInlineBoardMessage($session, $this->config);
        foreach ($allIds as $imid) {
            $imid = trim($imid);
            if ($imid === '') continue;
            try {
                $this->bot->editInlineMessageText($imid, $render['text'], $render['keyboard'], 'HTML');
            } catch (\Throwable $e) {
                error_log('[TOD inline board action] edit inline failed imid=' . $imid . ' — ' . $e->getMessage());
            }
        }

        // v2.9.38: Also refresh the creator's PV board (anonymous mode) so it
        // stays in sync with the shared inline message.
        $sessionMode = (string)($session['mode'] ?? '');
        if ($sessionMode === 'anonymous') {
            $creatorId = (int)($session['player1_id'] ?? 0);
            $creatorMsgId = (int)($session['p1_board_msg_id'] ?? 0);
            if ($creatorId > 0 && $creatorMsgId > 0) {
                try {
                    $pvRender = Game::renderBoardMessage($session, $creatorId, $this->config);
                    $this->bot->editMessage($creatorId, $creatorMsgId, $pvRender['text'], $pvRender['keyboard']);
                } catch (\Throwable $e) {
                    error_log('[TOD inline board action] PV sync edit failed: ' . $e->getMessage());
                }
            }
        }
    }

    /**
     * vNEXT (14-b/U1): shared join gate for TOD board/share join taps.
     * Applies the SAME requirement checks the TTT join path applies:
     * InlineHandler::checkAllRequirements (registration, gender, mandatory
     * channels, bot verifications — the exact helper and texts used by
     * handleInlineJoin) plus the satisfaction block; banned users are
     * already refused upstream in processCallback. Returns true when the
     * user may join; otherwise answers the callback with an alert and
     * returns false.
     */
    private function gateTODJoin(int $userId): bool
    {
        $reqError = InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($reqError !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $reqError, true);
            return false;
        }
        $joinUser = Database::loadUser($userId);
        if ($joinUser !== null && !empty($joinUser['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return false;
        }
        return true;
    }

    /**
     * vNEXT (14-b/U6): «📖 راهنما» (tod_guide_<sessionId>) — public game
     * info, so NO ownership/participant check (the standard per-user rate
     * limit + banned check already apply upstream). $sessionId is accepted
     * for callback-format compatibility; the rules text is static. Sent as
     * a NEW message: to the chat the board lives in (PV/group) or — for
     * shared inline boards, which carry no chat id — to the tapper's PV.
     */
    private function handleTODGuide(int $userId, int $sessionId, int $chatId, string $callbackId, bool $isInline): void
    {
        $this->bot->answerCallback($callbackId);
        $targetId = $isInline ? $userId : $chatId;
        try {
            $rulesText = Game::load('truthordare')->getRulesText();
            $this->bot->sendMessage($targetId, $rulesText);
        } catch (\Throwable $e) {
            error_log('[TOD guide] send rules failed target=' . $targetId . ' — ' . $e->getMessage());
        }
    }

    private function handleEntryFeeAction(int $userId, int $messageId, string $mode, string $gameType, string $action, int $currentFee, int $currentRounds, array $userData): void
    {
        // T46-k: the explanation renders LIVE from the shared block with the
        // CURRENT stepper values — it updates with every ± tap (was a static
        // 4-line text with zero numbers).
        if ($action !== 'minus' && $action !== 'plus') return;
        $newFee = $action === 'minus'
            ? max(0, $currentFee - 1)
            : min(Game::FEE_MAX, $currentFee + 1);
        $text = emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, $mode !== 'friends', $newFee, $currentRounds);
        $this->bot->editMessage($userId, $messageId, $text, gameEntryFeeKeyboard($mode, $gameType, $newFee, $currentRounds));
    }

    /**
     * v2.9.2: Handle the rounds stepper (gamerounds_<mode>_<type>_<fee>_<rounds>_<minus|plus>).
     * Rounds range: 1-9 (1 = single round, 3 = best-of-3, etc.)
     */
    private function handleRoundsAction(int $userId, int $messageId, string $mode, string $gameType, int $currentFee, int $currentRounds, string $action): void
    {
        // T46-k: live shared block with the CURRENT values + Game::ROUNDS_MAX clamp.
        if ($action !== 'minus' && $action !== 'plus') return;
        $newRounds = $action === 'minus'
            ? max(1, $currentRounds - 1)
            : min(Game::ROUNDS_MAX, $currentRounds + 1);
        $text = emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n"
            . Game::feeEconomicsBlock(false, $this->config, $mode !== 'friends', $currentFee, $newRounds);
        $this->bot->editMessage($userId, $messageId, $text, gameEntryFeeKeyboard($mode, $gameType, $currentFee, $newRounds));
    }

    /**
     * v2.9.2: Handle the start button (gamestart_<mode>_<type>_<fee>_<rounds>).
     * For anonymous: show match-preference keyboard.
     * For friends: start the friends game (always free, single round).
     */
    private function handleStartAction(int $userId, int $messageId, string $mode, string $gameType, int $fee, int $rounds, array $userData): void
    {
        if ($mode === 'anonymous') {
            $this->showMatchPreference($userId, $messageId, $mode, $gameType, $fee, $rounds);
        } else {
            // Friends mode: always free, always single round
            $this->startFriendsGame($userId, $messageId, $gameType, $userData);
        }
    }

    /**
     * Show the match-preference (gender) keyboard after the user picks the entry fee + rounds.
     * The keyboard's callback_data encodes mode, gameType, fee, rounds so the chosen
     * preference can be combined with them in startGameWithPreference().
     */
    private function showMatchPreference(int $userId, int $messageId, string $mode, string $gameType, int $fee, int $rounds = 1): void
    {
        $this->bot->editMessage($userId, $messageId,
            emoji(40) . ' ' . $this->msg('game_match_prompt'),
            matchPreferenceKeyboard($mode, $gameType, $fee, $rounds));
    }

    /**
     * v2.9.2: Called from the gamematch_<mode>_<gameType>_<fee>_<rounds>_<pref> callback.
     * For anonymous mode: starts the game with the given fee + rounds + gender preference.
     * For friends mode (legacy path): delegates to startFriendsGame (free, no pref, single round).
     */
    private function startGameWithPreference(int $userId, int $messageId, string $mode, string $gameType, int $fee, int $rounds, int $pref, array $userData): void
    {
        // v2.9.45 (R6-45/LOW): stale pre-R6 keyboards still carry
        // gamematch_anonymous_truthordare_<fee>_<rounds>_<pref> — the old path
        // created a category-less TOD queue via startAnonymous. The user
        // ALREADY expressed their pref in the tap, so start the new flow
        // directly with that pref (TOD config defaults: free + 3 changes).
        if ($mode === 'anonymous' && $gameType === 'truthordare') {
            $this->handleTODStart($userId, $messageId, 0, 3, $userData, 'normal', $pref);
            return;
        }
        if ($mode === 'anonymous') {
            $this->startAnonymousGame($userId, $messageId, $gameType, $userData, $fee, $pref, $rounds);
        } else {
            $this->startFriendsGame($userId, $messageId, $gameType, $userData);
        }
    }

    private function startAnonymousGame(int $userId, int $messageId, string $gameType, array $userData, int $entryFee = 0, int $matchPref = 0, int $rounds = 1): void
    {
        // v3.1 (T40): the fee/rounds chosen in the stepper now apply to ALL
        // games (دوز parity) — the old QuickGame force-free bypass is removed.
        // If the user already has an active game, resume it by editing the current message
        // v2.9.45 (R6-29): intercept/resume ONLY an existing TICTACTOE session —
        // a waiting (or active) TOD must neither hijack this دوز start nor be
        // resumed by it. Anything else falls through to Game::startAnonymous,
        // whose R6-5 gate blocks only ACTIVE games (multi-queue while waiting).
        $existing = Game::getActiveSessionForUser($userId, 'anonymous');
        if ($existing !== null && (string)($existing['game_type'] ?? '') === 'tictactoe') {
            $this->bot->answerCallback($this->currentCallbackId);
            // Edit the current menu message into the game board (or waiting message)
            if ($existing['status'] === 'waiting') {
                $fee = (int)($existing['reward_amount'] ?? 0);
                $exRounds = (int)($existing['total_rounds'] ?? 1);
                $totalCost = $fee * $exRounds;
                $feeText = $fee > 0 ? " (هزینه هر دور: {$fee} × {$exRounds} دور = {$totalCost} توکن بازی)" : ' (رایگان)';
                $waitText = emoji(8) . ' ' . $this->msg('game_waiting_opponent') . $feeText;
                $cancelKb = json_encode(['inline_keyboard' => [
                    [['text' => ' خروج از صف', 'callback_data' => 'gcancel_' . (int)$existing['id'], 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)]],
                ]]);
                $this->bot->editMessage($userId, $messageId, $waitText, $cancelKb);
                Database::updateGameSession((int)$existing['id'], ['p1_board_msg_id' => $messageId]);
            } else {
                // Active game — show the board
                $render = Game::renderBoardMessage($existing, $userId, $this->config);
                $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
                $field = ((int)$existing['player1_id'] === $userId) ? 'p1_board_msg_id' : 'p2_board_msg_id';
                Database::updateGameSession((int)$existing['id'], [$field => $messageId]);
            }
            return;
        }
        $result = Game::startAnonymous($userId, $gameType, $this->config, $entryFee, $matchPref, $rounds);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'insufficient_entry_fee') {
                $fee = $result['fee'] ?? 1;
                $balance = $result['balance'] ?? 0;
                $totalCost = $fee * $rounds;
                $this->bot->editMessage($userId, $messageId,
                    emoji(10) . " موجودی توکن بازی شما کافی نیست.\n\nهزینه هر دور: {$fee} توکن بازی\nمجموع هزینه ({$rounds} دور): {$totalCost} توکن بازی\nموجودی شما: {$balance} توکن بازی",
                    backToMainMenuKeyboard());
            } elseif ($msgKey === 'already_in_game') {
                $this->bot->editMessage($userId, $messageId,
                    emoji(10) . ' ' . $this->msg('game_already_active'),
                    backToMainMenuKeyboard());
            } else {
                $this->bot->sendMessage($userId, $this->msg('error_generic'), backToMainMenuKeyboard());
            }
            return;
        }
        $session = $result['session'];
        $sessionId = (int)$session['id'];

        if (!empty($result['waiting'])) {
            $cancelKb = json_encode(['inline_keyboard' => [
                [['text' => ' خروج از صف', 'callback_data' => 'gcancel_' . $sessionId, 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)]],
            ]]);
            $totalCost = $entryFee * $rounds;
            $feeText = $entryFee > 0 ? " (هزینه هر دور: {$entryFee} × {$rounds} دور = {$totalCost} توکن بازی)" : ' (رایگان)';
            $waitText = emoji(8) . ' ' . $this->msg('game_waiting_opponent') . $feeText;
            // Show queue position when there are waiting sessions ahead of this one
            if (isset($result['queue_position']) && (int)$result['queue_position'] > 1) {
                $waitText .= "\n" . $this->fillTemplate($this->msg('game_queue_position'), (int)$result['queue_position']);
            }
            $this->bot->editMessage($userId, $messageId, $waitText, $cancelKb);
            // Store the waiting message's msg_id as p1_board_msg_id so when
            // player2 joins, we can EDIT this message into the board.
            Database::updateGameSession($sessionId, ['p1_board_msg_id' => $messageId]);
        } else {
            // Matched immediately — player2 ($userId) joined an existing waiting session.
            // Player1 had a "waiting" message — EDIT it into the board (if msg_id stored).
            // Player2's menu message → edit into the board.
            $p1Id = (int)$session['player1_id'];
            $p2Id = $userId;

            // Player1 — try to EDIT their existing waiting message
            $render1 = Game::renderBoardMessage($session, $p1Id, $this->config);
            $p1MsgId = isset($session['p1_board_msg_id']) ? (int)$session['p1_board_msg_id'] : 0;
            $p1Edited = false;
            if ($p1MsgId > 0) {
                $result1 = $this->bot->editMessage($p1Id, $p1MsgId, $render1['text'], $render1['keyboard']);
                // Check if edit succeeded
                if ($result1 !== null && isset($result1->ok) && $result1->ok === true) {
                    $p1Edited = true;
                } elseif ($result1 !== null && isset($result1->description) && str_contains((string)$result1->description, 'not modified')) {
                    $p1Edited = true; // message is already up-to-date
                }
            }
            if (!$p1Edited) {
                // Edit failed or no stored msg_id — send new message
                $r1 = $this->bot->sendMessage($p1Id, $render1['text'], $render1['keyboard']);
                if ($r1 && isset($r1->result->message_id)) {
                    Database::updateGameSession($sessionId, ['p1_board_msg_id' => (int)$r1->result->message_id]);
                }
            }

            // Player2 — edit their menu message into the board
            $render2 = Game::renderBoardMessage($session, $p2Id, $this->config);
            $this->bot->editMessage($userId, $messageId, $render2['text'], $render2['keyboard']);
            Database::updateGameSession($sessionId, ['p2_board_msg_id' => $messageId]);
        }
    }

    /**
     * Friends games are always free (no entry fee, no reward). The flow is:
     *   gametype_friends_<type> → startFriendsGame (this method) →
     *   show switch_inline_query keyboard → user picks a chat →
     *   InlineHandler::handleChosenResult creates the session via Game::startFriends.
     */
    private function startFriendsGame(int $userId, int $messageId, string $gameType, array $userData): void
    {
        $existing = Game::getActiveSessionForUser($userId, 'anonymous');
        if ($existing !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('game_already_active'), true);
            return;
        }

        // v2.9.20: Customize message based on game type
        // v3.0 (T37): quick games carry their catalog label instead of «دوز».
        if ($gameType === 'truthordare') {
            $text = emoji(0) . ' برای بازی جرات یا حقیقت، روی دکمه زیر بزنید، یک چت (گروه یا پی‌وی) را انتخاب کنید و بازی را ارسال کنید:';
        } elseif (Game::isQuickGame($gameType)) {
            $label = Game::catalog()[$gameType]['label'] ?? $gameType;
            $text = emoji(0) . " برای بازی {$label} با دوستان، روی دکمه زیر بزنید، یک چت (گروه یا پی‌وی) را انتخاب کنید و بازی را ارسال کنید:";
        } else {
            $text = emoji(0) . ' برای بازی با دوستان، روی دکمه زیر بزنید، یک چت (گروه یا پی‌وی) را انتخاب کنید و سپس دوز را ارسال کنید:';
        }
        $this->bot->editMessage($userId, $messageId, $text, friendsGameInlineSwitchKeyboard($gameType));
    }

    private function handleGameMove(int $userId, int $sessionId, string $move, int $chatId, int $messageId): void
    {
        try {
            $result = Game::handleMove($sessionId, $userId, $move, $this->bot, $this->config);
        } catch (\Throwable $e) {
            error_log('[handleGameMove] exception sid=' . $sessionId . ' user=' . $userId . ' move=' . $move . ' — ' . $e->getMessage());
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('error_generic'), true);
            return;
        }
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            // Map the internal error keys to the game_alert_* message keys (short popups, no emoji)
            $msgKeyMap = [
                'not_your_turn'     => 'game_alert_not_your_turn',
                'invalid_move'      => 'game_alert_invalid_move',
                'not_your_game'     => 'game_alert_not_your_game',
                'already_finished'  => 'game_alert_already_finished',
                'not_found'         => 'game_alert_already_finished',
                // v2.9.44 (T13-4b): joinSession refuses truthordare sessions
                // (TOD joins go through handleTODAction) — never leak the raw key.
                'wrong_game_type'   => 'game_alert_already_finished',
            ];
            $configKey = $msgKeyMap[$msgKey] ?? 'error_generic';
            $alertText = $this->msg($configKey);
            if ($alertText === '') $alertText = $this->msg('error_generic');
            $this->bot->answerCallback($this->currentCallbackId, $alertText, true);
        } else {
            $this->bot->answerCallback($this->currentCallbackId);
        }
    }


    /**
     * v2.9.45 (R6-85): sprintf on ADMIN-EDITABLE message templates corrupts
     * the whole sentence when an editor leaves a stray '%' (sprintf throws /
     * mangles). Substitute %s and %d left-to-right instead — unknown '%'
     * stays literal.
     */
    private function fillTemplate(string $template, ...$args): string
    {
        $out = '';
        $pos = 0;
        $len = strlen($template);
        $i = 0;
        while (($pct = strpos($template, '%', $pos)) !== false) {
            $out .= substr($template, $pos, $pct - $pos);
            $spec = substr($template, $pct, 2);
            if (($spec === '%s' || $spec === '%d') && $i < count($args)) {
                $out .= (string)$args[$i];
                $i++;
                $pos = $pct + 2;
            } elseif ($spec === '%%') {
                $out .= '%';
                $pos = $pct + 2;
            } else {
                $out .= '%';
                $pos = $pct + 1;
            }
        }
        $out .= substr($template, $pos);
        return $out;
    }

    private function handleGameJoin(int $userId, int $sessionId, int $chatId, int $messageId): void
    {
        // vNEXT (23-a): satisfaction gate. gjoin_<sid> and gjoin_pending_<uid>
        // taps are dispatched at the TOP of processCallback (parseJoinCallback
        // / the gjoin_pending_ branch) — BEFORE its generic satisfaction gate
        // — so without this a satisfaction-blocked user could join a group/
        // pending friends game here and pay the entry fee (same gap class 21
        // closed for the /start joingame_ deep-link; PV viewer joins go
        // through dispatch(), which runs AFTER that gate). Banned users are
        // already refused upstream in processCallback. Same wording/behavior
        // as gateTODJoin so every join path is uniform.
        $joinUser = Database::loadUser($userId);
        if ($joinUser !== null && !empty($joinUser['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return;
        }
        // v2.9.45 (R6-51/MEDIUM): full join gate — gjoin_<sid> previously
        // enforced ONLY the satisfaction gate, so unregistered/genderless
        // group members could join free group TTT games and later round-win
        // credits would upsert ghost users rows (same gate as tod_join_).
        $reqError = \InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($reqError !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $reqError, true);
            return;
        }

        $result = Game::joinSession($sessionId, $userId, $this->bot, $this->config);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'insufficient_entry_fee') {
                $fee = $result['fee'] ?? 1;
                $rounds = $result['rounds'] ?? 1;
                $totalCost = $fee * $rounds;
                $this->bot->answerCallback($this->currentCallbackId,
                    "موجودی توکن بازی شما کافی نیست. هزینه هر دور: {$fee} × {$rounds} دور = {$totalCost} توکن بازی", true);
            } elseif ($msgKey === 'opponent_insufficient_fee') {
                $this->bot->answerCallback($this->currentCallbackId,
                    'حریف دیگر موجودی کافی ندارد. بازی لغو شد.', true);
            } else {
                // v2.9.44 (T16-1b): never leak raw machine keys — same map as
                // handleInlineJoin (a group-board creator tapping their own
                // «پیوستن به بازی» used to get a popup reading "already_in_game").
                $joinAlerts = [
                    'already_started_or_finished' => 'این بازی دیگر در دسترس نیست.',
                    'game_just_filled' => 'متأسفانه بازیکن دیگری زودتر از شما به بازی پیوست.',
                    'already_in_game'  => 'شما قبلاً در این بازی هستید.',
                    'wrong_game_type'  => 'این بازی دیگر در دسترس نیست.',
                    'not_found'        => 'این بازی دیگر در دسترس نیست.',
                ];
                $this->bot->answerCallback($this->currentCallbackId,
                    $joinAlerts[$msgKey] ?? $this->msg('error_generic'), true);
            }
            return;
        }
        $session = $result['session'];
        $p1Id = (int)$session['player1_id'];
        $p2Id = $userId;

        // v2.9.18: For group sessions, edit the GROUP message (shared board).
        // For anonymous/friends sessions, send NEW board messages to each player's PV.
        $sessionMode = (string)($session['mode'] ?? 'anonymous');
        if ($sessionMode === 'group') {
            // Edit the group message with group rendering (viewerUserId=-1)
            $render = Game::renderGroupBoardMessage($session, $this->config);
            $this->bot->editMessage($chatId, $messageId, $render['text'], $render['keyboard']);
            Database::updateGameSession($sessionId, ['p1_board_msg_id' => $messageId]);
            return;
        }

        // Player1 — send a NEW board message and store its msg_id.
        $render1 = Game::renderBoardMessage($session, $p1Id, $this->config);
        $r1 = $this->bot->sendMessage($p1Id, $render1['text'], $render1['keyboard']);
        if ($r1 && isset($r1->result->message_id)) {
            Database::updateGameSession($sessionId, ['p1_board_msg_id' => (int)$r1->result->message_id]);
        }

        // Player2 (the joiner) — send a NEW board message and store its msg_id.
        $render2 = Game::renderBoardMessage($session, $p2Id, $this->config);
        $r2 = $this->bot->sendMessage($p2Id, $render2['text'], $render2['keyboard']);
        if ($r2 && isset($r2->result->message_id)) {
            Database::updateGameSession($sessionId, ['p2_board_msg_id' => (int)$r2->result->message_id]);
        }
    }

    private function handleGameCancel(int $userId, int $sessionId, int $chatId, int $messageId): void
    {
        // Check the session status to decide which cancel flow to use
        $session = Database::getGameSession($sessionId);
        if ($session === null) return;

        if ($session['status'] === 'waiting') {
            // Waiting session — exit the queue (edit the waiting message)
            Game::cancelWaitingSession($sessionId, $userId, $this->bot, $this->config, $messageId);
        } else {
            // Active session — start the cancel REQUEST flow (not direct cancel)
            Game::requestCancel($sessionId, $userId, $this->bot, $this->config);
        }
    }

    // ─── Game viewer (browse/join waiting games) ────────────────────

    /**
     * Show the list of currently-waiting anonymous games (excluding the
     * viewer's own waiting sessions, since you can't join your own).
     * Triggered by the "دیدن بازی‌ها" button on the entry-fee stepper.
     */
    private function showGameViewer(int $userId, int $messageId): void
    {
        $sessions = Database::getAllWaitingAnonymousSessions($userId);
        // vNEXT (14-b/U4): pass the true waiting count (same visibility as the
        // list — viewer's own sessions excluded) so the keyboard can surface
        // the LIMIT-50 overflow instead of truncating silently.
        // v2.9.44 (T13-4): both game types are listed now — sum both counts.
        // v3.0 (T37): one query across the whole 42-type catalog.
        $waitingTotal = Database::countAllWaitingAnonymousGames($userId);
        $render = gameViewerKeyboard($sessions, $userId, $waitingTotal);
        $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
    }

    /**
     * Show details of a single waiting game + a "confirm join" button.
     * The detail text is built from the game_viewer_detail message template
     * (game name, entry fee, reward, match preference) plus the player1 name.
     */
    private function showGameViewerDetail(int $userId, int $messageId, int $sessionId): void
    {
        $session = Database::getGameSession($sessionId);
        if ($session === null || $session['status'] !== 'waiting') {
            $this->bot->answerCallback($this->currentCallbackId, 'این بازی دیگر در دسترس نیست.', true);
            return;
        }
        $fee = (int)($session['reward_amount'] ?? 0);
        $matchGender = (int)($session['match_gender'] ?? 0);
        $prefText = ['فرقی ندارد', 'پسر', 'دختر'][$matchGender] ?? 'فرقی ندارد';
        $gameType = (string)($session['game_type'] ?? 'tictactoe');
        // vNEXT (14-b/U5b): truthordare named + safe fallback for unknown
        // types (the raw game_type key used to leak into the text).
        // v3.0 (T37): catalog-driven label for all 42 types.
        $gameName = Game::catalog()[$gameType]['label'] ?? 'بازی ناشناس';
        $totalRounds = (int)($session['total_rounds'] ?? 1);
        $totalCost = $fee * $totalRounds;
        $p1Name = 'کاربر ' . $session['player1_id'];
        $p1User = Database::loadUser((int)$session['player1_id']);
        if ($p1User && !empty($p1User['displayName']) && $p1User['displayName'] !== 'user') {
            $p1Name = $p1User['displayName'];
        }
        // v2.9.4: Show the opponent's actual gender (from users.gender) and
        // their match preference (what gender opponent they want).
        $p1Gender = (int)($p1User['gender'] ?? 0);
        $p1GenderText = ['نامشخص', 'پسر', 'دختر'][$p1Gender] ?? 'نامشخص';

        // v2.9.44 (T13-4c): TOD-specific detail — the shared template speaks in
        // دوز terms (entry fee / winner reward / rounds) which are all wrong
        // for جرات یا حقیقت (no entry fee, no winner reward; total_rounds
        // holds the FREE change count, reward_amount the excess-change fee).
        if ($gameType === 'truthordare') {
            $boardState = is_array($session['board_state'] ?? null) ? $session['board_state'] : [];
            $category = (string)($boardState['category'] ?? 'normal');
            $categoryText = $category === 'couple' ? 'کاپلی' : 'عادی';
            // v2.9.45 (R6-37): PV outgoing text (viewer detail) — plain unicode
            // emoji → custom emoji slots (mapping: 🔥→0, 👤→35, ⚧→23, 🎯→0,
            // 🔄→13, 💰→20, 🎁→8). This branch never renders inline.
            $text = emoji(0) . " <b>جرات یا حقیقت</b> — بازی گروهی\n\n";
            $text .= emoji(35) . " بازیکن: " . $p1Name . "\n";
            $text .= emoji(23) . " جنسیت: " . $p1GenderText . "\n";
            $text .= emoji(0) . " دسته سوالات: " . $categoryText . "\n";
            $text .= emoji(13) . " تعداد تغییر مجاز هر بازیکن: " . $totalRounds . "\n";
            if ($fee > 0) {
                $text .= emoji(20) . " جریمه هر تغییر اضافی: " . $fee . " توکن بازی\n";
            }
            $text .= emoji(8) . " هزینه ورود: رایگان";
            $this->bot->editMessage($userId, $messageId, $text, gameViewerDetailKeyboard($sessionId));
            return;
        }

        // T46-ap (owner): the winner takes the WHOLE round pot (own stake +
        // the opponent's) — the prize line renders 2× the per-round fee.
        $text = $this->fillTemplate(
            Settings::getMessage('game_viewer_detail', $this->config),
            $gameName, $fee, $fee * 2, $prefText
        );
        // vNEXT (14-b/U5c): for fee-0 games the shared template rendered
        // «هزینه ورود: 0 …» / «پاداش برنده: 0 …» plus a contradicting
        // free-game line — swap the two zero lines for one honest line.
        // (config.php itself is out of scope; if an admin customized the
        // template so the lines no longer match, the free-game note is
        // appended at the end instead.)
        if ($fee === 0) {
            // v2.9.45 (R6-37): PV text — plain 🎮 → custom emoji slot 0.
            $freeLine = "\n" . emoji(0) . " بازی رایگان — بدون هزینه و بدون پاداش";
            $text = str_replace("\n هزینه ورود: 0 توکن بازی", $freeLine, $text, $replaced);
            $text = str_replace("\n پاداش برنده: 0 توکن NTX", '', $text, $replaced2);
            if (empty($replaced) && empty($replaced2)) {
                $text .= $freeLine;
            }
        }
        $text .= "\n\n بازیکن: " . $p1Name;
        $text .= "\n جنسیت حریف: " . $p1GenderText;
        $text .= "\n تعداد دور: " . $totalRounds . " دور";
        if ($fee > 0) {
            $text .= "\n هزینه هر دور: " . $fee . " توکن بازی";
            $text .= "\n مجموع هزینه: " . $totalCost . " توکن بازی";
        }

        $this->bot->editMessage($userId, $messageId, $text, gameViewerDetailKeyboard($sessionId));
    }

    /**
     * Join a waiting game from the viewer. Pre-checks the entry-fee balance
     * so we can show a friendly alert BEFORE calling Game::joinSession. On
     * success, sends a fresh board to player1 and edits the viewer-detail
     * message (the one the joiner tapped) into player2's board — this reuses
     * the existing message id so we don't leave a stray detail message around.
     */
    private function joinGameFromViewer(int $userId, int $messageId, int $sessionId): void
    {
        $session = Database::getGameSession($sessionId);
        if ($session === null || $session['status'] !== 'waiting') {
            $this->bot->answerCallback($this->currentCallbackId, 'این بازی دیگر در دسترس نیست.', true);
            return;
        }

        // v2.9.44 (T13-5): satisfaction gate — same wording/behavior as
        // handleGameJoin (23-a). gameviewjoin_ is dispatched BEFORE the
        // generic satisfaction gate in processCallback, so without this a
        // satisfaction-blocked user could join from the viewer.
        $joinUserPre = Database::loadUser($userId);
        if ($joinUserPre !== null && !empty($joinUserPre['satisfactionPending'])) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('satisfaction_prompt'), true);
            return;
        }

        // v2.9.44 (T13-4): TOD-aware viewer join. Game::joinSession is a
        // TicTacToe join — it flips the session to 'active', clobbers
        // board_state with TTT cells and debits fee×rounds as an entry fee.
        // Running it on a truthordare session corrupted the game (that's why
        // TOD was hidden from this list in 14-a/T1). Waiting TOD games are
        // NOW listed again and joined through Game::handleTODAction('join'):
        // stays 'waiting' for more players, no entry fee, multi-player.
        if ((string)($session['game_type'] ?? '') === 'truthordare') {
            if (!$this->gateTODJoin($userId)) return;
            // v2.9.45 (R6-43/MEDIUM-2): active-only — waiting queues are legal
            // under the R6 multi-list model and must not block viewer joins.
            // v3.3 (T43): TRACK-aware — joining an anonymous viewer listing
            // checks only the anonymous track, joining a normal listing only
            // the normal track (an active group/inline game must never block
            // an anonymous join and vice versa — «بازی حالت ناشناس جداست»).
            $viewerJoinTrack = ((string)($session['mode'] ?? '')) === 'anonymous' ? 'anonymous' : 'normal';
            $existingActive = Database::getActiveOnlySessionInTrack($userId, $viewerJoinTrack);
            if ($existingActive !== null && (int)$existingActive['id'] !== $sessionId) {
                $this->bot->answerCallback($this->currentCallbackId,
                    Settings::getMessage('game_already_active', $this->config), true);
                return;
            }
            $todResult = Game::handleTODAction($sessionId, $userId, 'join', $this->bot, $this->config);
            if (!$todResult['success']) {
                // v2.9.45 (R6-28): word game_full per session mode — anonymous
                // TOD is strictly 2-player (R6-12); group/inline keep the
                // 30-player cap wording.
                $todGameFull = 'ظرفیت این بازی پر است (حداکثر ۳۰ نفر).';
                if ((string)($session['mode'] ?? '') === 'anonymous') {
                    $todGameFull = 'این بازی ۲ نفره است و ظرفیت آن تکمیل شده.';
                }
                $todAlerts = [
                    'already_in_game' => 'شما قبلاً در این بازی هستید.',
                    // v2.9.45 (R6-101/LOW): auto-begin landing between the
                    // pre-check and the seat-take, and the R6-57 lock being
                    // busy, were generic «خطای عمومی» before.
                    'already_started' => 'این بازی همین حالا شروع شد.',
                    'busy_try_again'  => 'لطفاً چند لحظه بعد دوباره تلاش کنید.',
                    'game_full'       => $todGameFull,
                    'game_ended'      => 'این بازی دیگر در دسترس نیست.',
                    'not_found'       => 'این بازی دیگر در دسترس نیست.',
                ];
                $todKey = (string)($todResult['message'] ?? '');
                $this->bot->answerCallback($this->currentCallbackId,
                    $todAlerts[$todKey] ?? $this->msg('error_generic'), true);
                return;
            }
            // v2.9.45 (R6-28): success ack — 'joined_started' (anonymous seat-2
            // join AUTO-BEGAN the game, R6-12) toasts exactly like 'joined'.
            $this->bot->answerCallback($this->currentCallbackId, '✅ شما به بازی پیوستید!');
            $fresh = Database::getGameSession($sessionId);
            if ($fresh === null) return;
            // The sync below renders WHATEVER status the session now has — on
            // the auto-began path every board (joiner PV, creator PV, shared
            // inline) flips from the waiting board to the active one in-place.
            // Joiner: the tapped viewer-detail message becomes their board.
            $joinerRender = Game::renderBoardMessage($fresh, $userId, $this->config);
            $this->bot->editMessage($userId, $messageId, $joinerRender['text'], $joinerRender['keyboard']);
            // v2.9.44 (T16-3): register the joiner board so begin/turn changes
            // keep it in sync (updateAllGameMessages refreshes these; without
            // this the joiner's board stayed on the dead waiting keyboard).
            Game::addTodJoinerBoard($sessionId, $userId, $messageId);
            // Creator PV board sync (the joiner count / شروع بازی must appear).
            $creatorId = (int)($fresh['player1_id'] ?? 0);
            $creatorMsgId = (int)($fresh['p1_board_msg_id'] ?? 0);
            if ($creatorId > 0 && $creatorMsgId > 0 && $creatorId !== $userId) {
                try {
                    $creatorRender = Game::renderBoardMessage($fresh, $creatorId, $this->config);
                    $this->bot->editMessage($creatorId, $creatorMsgId, $creatorRender['text'], $creatorRender['keyboard']);
                } catch (\Throwable $e) {
                    error_log('[joinGameFromViewer] creator PV sync failed sid=' . $sessionId . ': ' . $e->getMessage());
                }
            }
            // Shared inline boards (if the waiting game was ever shared inline).
            $todIdsStr = Database::getSetting('inline_msg_ids_' . $sessionId) ?? '';
            $todIds = $todIdsStr !== '' ? explode(',', $todIdsStr) : [];
            $inlineRender = null;
            foreach ($todIds as $imid) {
                $imid = trim($imid);
                if ($imid === '') continue;
                try {
                    if ($inlineRender === null) $inlineRender = Game::renderInlineBoardMessage($fresh, $this->config);
                    $this->bot->editInlineMessageText($imid, $inlineRender['text'], $inlineRender['keyboard'], 'HTML');
                } catch (\Throwable $e) {
                    error_log('[joinGameFromViewer] inline sync failed sid=' . $sessionId . ': ' . $e->getMessage());
                }
            }
            return;
        }

        // v2.9.45 (R6-77/MEDIUM): full join gate for the TTT viewer path —
        // it ran satisfaction+balance only, so channel-left / unregistered /
        // genderless users kept playing and winning via stale viewer messages
        // (the TOD branch of this very method already gates; now both do).
        $viewerReqError = \InlineHandler::checkAllRequirements($userId, $this->bot, $this->config);
        if ($viewerReqError !== null) {
            $this->bot->answerCallback($this->currentCallbackId, $viewerReqError, true);
            return;
        }

        // Pre-check entry fee so we can show the friendly game_viewer_insufficient
        // alert. (Game::joinSession also checks, but its return path can't show
        // a custom alert since the answerCallback was not consumed yet.)
        // v2.9.3: total cost = fee × rounds (both players pay this).
        $fee = (int)($session['reward_amount'] ?? 0);
        $totalRounds = (int)($session['total_rounds'] ?? 1);
        $totalCost = $fee * $totalRounds;
        if ($totalCost > 0 && Wallet::balance($userId, 'game_token') < $totalCost) {
            $this->bot->answerCallback($this->currentCallbackId,
                $this->fillTemplate(Settings::getMessage('game_viewer_insufficient', $this->config), $totalCost), true);
            return;
        }

        // Join the game. (Does not call answerCallback — leaves it for us.)
        $result = Game::joinSession($sessionId, $userId, $this->bot, $this->config);
        if (!$result['success']) {
            $msgKey = $result['message'] ?? 'error_generic';
            if ($msgKey === 'insufficient_entry_fee') {
                $this->bot->answerCallback($this->currentCallbackId,
                    $this->fillTemplate(Settings::getMessage('game_viewer_insufficient', $this->config), $fee), true);
            } elseif ($msgKey === 'already_in_game') {
                $this->bot->answerCallback($this->currentCallbackId,
                    Settings::getMessage('game_already_active', $this->config), true);
            } elseif ($msgKey === 'wrong_game_type') {
                $this->bot->answerCallback($this->currentCallbackId, 'این بازی دیگر در دسترس نیست.', true);
            } else {
                // v2.9.44 (T16-1c): map the remaining machine keys instead of
                // leaking them (game_just_filled on join races, etc.).
                $joinAlerts = [
                    'already_started_or_finished' => 'این بازی دیگر در دسترس نیست.',
                    'game_just_filled' => 'متأسفانه بازیکن دیگری زودتر از شما به بازی پیوست.',
                    'opponent_insufficient_fee' => 'حریف دیگر موجودی کافی ندارد. بازی لغو شد.',
                    'not_found' => 'این بازی دیگر در دسترس نیست.',
                ];
                $this->bot->answerCallback($this->currentCallbackId,
                    $joinAlerts[$msgKey] ?? $this->msg('error_generic'), true);
            }
            return;
        }

        // Success — ack the callback (no alert).
        $this->bot->answerCallback($this->currentCallbackId);

        // Send board to both players.
        // Player1: try to EDIT their existing waiting message (stored in
        // p1_board_msg_id) into the board. This avoids sending a NEW message
        // and keeps the conversation clean. If the edit fails or there's no
        // stored msg_id, fall back to sending a new message.
        // Player2 (the joiner): edit their viewer-detail message (the one they
        // tapped) into the board.
        $session = $result['session'];
        $p1Id = (int)$session['player1_id'];
        $p2Id = $userId;

        // Player1 — try to EDIT their existing waiting message
        $render1 = Game::renderBoardMessage($session, $p1Id, $this->config);
        $p1MsgId = isset($session['p1_board_msg_id']) ? (int)$session['p1_board_msg_id'] : 0;
        $p1Edited = false;
        if ($p1MsgId > 0) {
            $result1 = $this->bot->editMessage($p1Id, $p1MsgId, $render1['text'], $render1['keyboard']);
            if ($result1 !== null && isset($result1->ok) && $result1->ok === true) {
                $p1Edited = true;
            } elseif ($result1 !== null && isset($result1->description) && str_contains((string)$result1->description, 'not modified')) {
                $p1Edited = true; // message is already up-to-date
            }
        }
        if (!$p1Edited) {
            // Edit failed or no stored msg_id — send new message
            $r1 = $this->bot->sendMessage($p1Id, $render1['text'], $render1['keyboard']);
            if ($r1 && isset($r1->result->message_id)) {
                Database::updateGameSession($sessionId, ['p1_board_msg_id' => (int)$r1->result->message_id]);
            }
        }

        // Player2 — edit their viewer-detail message into the board
        $render2 = Game::renderBoardMessage($session, $p2Id, $this->config);
        $this->bot->editMessage($userId, $messageId, $render2['text'], $render2['keyboard']);
        Database::updateGameSession($sessionId, ['p2_board_msg_id' => $messageId]);
    }

    // ─── Withdrawal callbacks ───────────────────────────────────────

    private function showWithdrawalMenu(int $userId, int $messageId, array $userData): void
    {
        $active = Database::getActiveWithdrawalsForUser($userId);
        if (!empty($active)) {
            $text = $this->msg('withdraw_list_open');
            foreach ($active as $w) {
                $text .= formatWithdrawalRow($w, $this->config) . "\n";
            }
            $this->bot->editMessage($userId, $messageId, $text, withdrawalListKeyboard($active));
        } else {
            $this->bot->editMessage($userId, $messageId, emoji(44) . ' ' . $this->msg('withdraw_menu'), withdrawalMenuKeyboard());
        }
    }

    private function showWithdrawalType(int $userId, int $messageId): void
    {
        $this->bot->editMessage($userId, $messageId, emoji(44) . ' ' . $this->msg('withdraw_select_type'), withdrawalTypeKeyboard());
    }

    private function showChargeDenominations(int $userId, int $messageId, string $operator): void
    {
        $render = chargeDenominationKeyboard($operator, $this->config);
        $this->bot->editMessage($userId, $messageId, $render['text'], $render['keyboard']);
    }

    private function createChargeWithdrawal(int $userId, int $messageId, string $operator, array $userData, int $amountToman = 0): void
    {
        $displayName = $userData['displayName'] ?? 'user';
        $result = Withdrawal::createChargeRequest($userId, $operator, $this->config, $displayName, $amountToman);
        // vNEXT (G7): assign() historically returned '' on success — an empty
        // text would make editMessage 400 and lose the whole message. Use the
        // fallback text whenever the message comes back empty/whitespace.
        $chargeText = trim((string)($result['message'] ?? ''));
        if ($chargeText === '') {
            $chargeText = !empty($result['success'])
                ? '✅ کد شارژ شما صادر و ارسال شد.'
                : $this->msg('error_generic');
        }
        $this->bot->editMessage($userId, $messageId, $chargeText, backToMainMenuKeyboard());
    }

    private function startStarsWithdrawal(int $userId, int $messageId, array $userData): void
    {
        $userData['status'] = 'waiting_stars_amount';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);
        // T46-ap (owner): the stars flow is STARS-denominated — show the
        // honest math up front (balance, per-star rate, affordable max,
        // admin-set minimum) instead of asking for a toman amount.
        $bal = (int)Wallet::balance($userId, 'toman');
        $rate = max(1, (int)($this->config['withdrawal']['star_toman_value'] ?? 10000));
        $minStar = max(1, (int)($this->config['withdrawal']['min_star'] ?? 50));
        $maxStars = intdiv(max(0, $bal), $rate);
        $text = emoji(46) . " <b>برداشت استارز</b>\n\n"
            . emoji(44) . ' موجودی شما: ' . number_format($bal) . " تومان\n"
            . emoji(46) . ' نرخ هر استارز: ' . number_format($rate) . " تومان\n"
            . emoji(52) . ' با موجودی فعلی می‌توانید تا <b>' . number_format($maxStars) . " استارز</b> برداشت کنید\n"
            . emoji(10) . ' حداقل برداشت: <b>' . number_format($minStar) . " استارز</b>\n\n"
            . 'تعداد استارز موردنظر خود را ارسال کنید:';
        $this->bot->editMessage($userId, $messageId, $text, backToMainMenuKeyboard(), 'HTML');
    }

    private function startCardWithdrawal(int $userId, int $messageId, array $userData): void
    {
        $userData['status'] = 'waiting_withdrawal_amount';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);
        $this->bot->editMessage($userId, $messageId, emoji(47) . ' ' . $this->msg('withdraw_prompt_amount'), backToMainMenuKeyboard());
    }

    /**
     * T46-ao (owner): «برداشت همین مقدار» — starts a card withdrawal for the
     * user's CURRENT balance. The button bakes the balance it was rendered
     * with, but the amount actually used is re-clamped against the FRESH
     * balance and the effective (admin-configurable) minimum at tap time.
     */
    private function quickWithdrawFromBalance(int $userId, int $messageId, int $hintAmount, array $userData): void
    {
        $minToman = (int)($this->config['withdrawal']['min_toman'] ?? 30000);
        $balance = Wallet::balance($userId, 'toman');
        $amount = ($hintAmount > 0 && $hintAmount <= $balance) ? $hintAmount : $balance;
        if ($amount < $minToman) {
            $this->bot->editMessage(
                $userId,
                $messageId,
                emoji(10) . ' ' . sprintf('موجودی فعلی شما %d تومان است و از حداقل مبلغ برداشت (%d تومان) کمتر است. با بازی یا زیرمجموعه‌گیری نقدی موجودی‌ات را بیشتر کن.', $balance, $minToman),
                insufficientBalanceKeyboard($balance, $minToman)
            );
            return;
        }
        Database::setSetting('pending_wdraw_amount_' . $userId, (string)$amount);
        Database::updateUserColumns($userId, ['status' => 'waiting_withdrawal_card_info']);
        $this->bot->editMessage($userId, $messageId, emoji(47) . ' ' . $this->msg('withdraw_prompt_card'), backToMainMenuKeyboard());
    }

    private function userReplyToWithdrawal(int $userId, int $messageId, array $userData, int $withdrawalId): void
    {
        $w = Database::getWithdrawal($withdrawalId);
        if (!$w || (int)$w['user_id'] !== $userId) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('withdraw_not_found'), true);
            return;
        }
        if ($w['status'] === 'closed' || $w['status'] === 'rejected') {
            // vNEXT (11-a contract F-A2): the bare reopen of closed rows was a
            // self-service money printer (close → refund → reply-tap reopens
            // WITHOUT re-debit → close → refund again). reopenAfterUserClose
            // re-establishes the card/stars hold atomically and only reopens
            // rows the USER closed. On failure the row stays closed — stop.
            $reopen = Withdrawal::reopenAfterUserClose($withdrawalId, $userId);
            if (!$reopen['ok']) {
                $error = (string)($reopen['error'] ?? '');
                $errorText = match ($error) {
                    'not_found' => 'درخواست یافت نشد.',
                    'not_reopenable' => 'این درخواست در وضعیت فعلی قابل بازگشایی نیست.',
                    default => $error !== '' ? $error : $this->msg('error_generic'),
                };
                $this->bot->answerCallback($this->currentCallbackId, $errorText, true);
                return;
            }
        }
        Database::setSetting('active_wdraw_' . $userId, (string)$withdrawalId);
        $userData['status'] = 'waiting_withdrawal_reply';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);

        $adminName = $w['admin_name'] ?? 'پشتیبانی';
        $summary = Withdrawal::buildConversationSummary($withdrawalId, $adminName, $this->config);
        $summary .= "\n────────────\n\n" . $this->msg('withdraw_reply_prompt');
        // vNEXT (G6): Telegram's hard limit is 4096 chars — clamp long
        // conversations so the edit never 400s and silently loses the message.
        $summary = mb_substr($summary, 0, 3900);
        $this->bot->editMessage($userId, $messageId, $summary, withdrawalAnswerKeyboard($adminName, $withdrawalId));
    }

    private function closeWithdrawalByUser(int $userId, int $messageId, array $userData, int $withdrawalId): void
    {
        $w = Database::getWithdrawal($withdrawalId);
        if (!$w || (int)$w['user_id'] !== $userId) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('withdraw_not_found'), true);
            return;
        }
        // vNEXT: user-close must REFUND the held funds (card toman / stars
        // were debited up-front). Withdrawal::userClose does the guarded status
        // change + refund; the old Database::closeWithdrawal was a bare status
        // update that silently lost the held funds.
        $closeResult = Withdrawal::userClose($withdrawalId, $userId);
        if (!$closeResult['ok']) {
            // vNEXT (G2): userClose returns machine keys — map ALL of them to
            // Persian instead of showing raw English keys in the alert.
            $errorKey = (string)($closeResult['error'] ?? '');
            $errorText = match ($errorKey) {
                'not_found', 'not_owner' => 'درخواست یافت نشد.',
                'not_closable' => 'این درخواست در وضعیت فعلی قابل بسته شدن نیست.',
                default => $this->msg('error_generic'),
            };
            $this->bot->answerCallback($this->currentCallbackId, $errorText, true);
            return;
        }
        Database::deleteSetting('active_wdraw_' . $userId);
        $userData['status'] = 'main';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);

        $openTime = date('Y-m-d H:i', strtotime($w['question_time']));
        $closeTime = date('Y-m-d H:i');
        $messages = Database::getWithdrawalMessages($withdrawalId);
        $userCount = count(array_filter($messages, fn($m) => $m['sender_type'] === 'user'));
        $adminCount = count(array_filter($messages, fn($m) => $m['sender_type'] === 'admin'));
        $text = $this->fillTemplate($this->msg('withdraw_closed_details'), $withdrawalId, $openTime, $closeTime, $userCount, $adminCount);
        $text .= $this->msg('withdraw_closed_by_user');
        $this->bot->editMessage($userId, $messageId, $text, backToMainMenuKeyboard());

        // Strip buttons from other messages in this withdrawal
        $others = Database::getWithdrawalMessagesWithTgId($withdrawalId, $userId);
        foreach ($others as $m) {
            $this->bot->editMessageReplyMarkup($userId, (int)$m['tg_message_id'], backToMainMenuKeyboard());
            usleep(35000);
        }
    }

    private function claimWithdrawal(int $adminChatId, int $messageId, int $withdrawalId): void
    {
        $w = Database::getWithdrawal($withdrawalId);
        if (!$w || $w['status'] !== 'open') {
            $this->bot->sendMessage($adminChatId, $this->msg('withdraw_not_found'), backToMainMenuKeyboard());
            return;
        }
        $adminInfo = null;
        foreach (Database::getAdminsWithPermission('support') as $a) {
            if ((int)$a['user_id'] === $adminChatId) { $adminInfo = $a; break; }
        }
        if (!$adminInfo) {
            $this->bot->sendMessage($adminChatId, $this->msg('error_no_permission'), backToMainMenuKeyboard());
            return;
        }
        // v2.9.45 (R6-95/LOW): the claim is a guarded CAS — a losing admin
        // (second tapper) used to be told «claimed» AND got the reply pointer,
        // so both admins replied into the same withdrawal.
        if (!Database::claimWithdrawal($withdrawalId, $adminChatId, $adminInfo['display_name'])) {
            $this->bot->sendMessage($adminChatId, emoji(10) . ' این درخواست قبلاً توسط ادمین دیگری برداشته شده است.', backToMainMenuKeyboard());
            return;
        }
        Database::setSetting('pending_wdraw_reply_' . $adminChatId, (string)$withdrawalId);
        $this->bot->sendMessage($adminChatId, $this->fillTemplate($this->msg('withdraw_claimed'), $withdrawalId), backToMainMenuKeyboard());
    }

    // ─── NTX callbacks ──────────────────────────────────────────────

    private function showNtxMenu(int $userId, int $messageId, array $userData): void
    {
        $convertible = NtxLedger::getConvertibleAmount($userId, $this->config);
        $text = $this->fillTemplate($this->msg('ntx_menu'), $userData['ntxBalance'] ?? 0, $convertible, $convertible * (int)$this->config['currency']['ntx_toman_value']);
        $this->bot->editMessage($userId, $messageId, emoji(50) . ' ' . $text, ntxMenuKeyboard($userId, $this->config));
    }

    private function showNtxConvertConfirm(int $userId, int $messageId, array $userData): void
    {
        $convertible = NtxLedger::getConvertibleAmount($userId, $this->config);
        if ($convertible <= 0) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('ntx_no_convertible'), true);
            return;
        }
        $this->bot->editMessage($userId, $messageId, emoji(50) . ' ' . $this->msg('ntx_convert_prompt'), ntxConvertConfirmKeyboard());
    }

    private function doNtxConvert(int $userId, int $messageId, array $userData): void
    {
        $result = NtxLedger::convertAll($userId, $this->config);
        if (!$result['success'] || $result['records_converted'] === 0) {
            $this->bot->editMessage($userId, $messageId, $this->msg('ntx_no_convertible'), backToMainMenuKeyboard());
            return;
        }
        // vNEXT (G-B12): the ntx_converted template is admin-editable — sprintf
        // on it garbles the message when an admin types a stray '%' (same class
        // as G13/F12). Placeholders are replaced literally, left to right; the
        // current '%d … %d' default keeps working.
        $text = $this->msg('ntx_converted');
        $text = preg_replace('/%d/', (string)(int)$result['total_ntx'], $text, 1);
        $text = preg_replace('/%d/', (string)(int)$result['total_toman'], $text, 1);
        $this->bot->editMessage($userId, $messageId, emoji(50) . ' ' . $text, backToMainMenuKeyboard());
    }

    // ─── Task callbacks ─────────────────────────────────────────────

    private function showTasksMenu(int $userId, int $messageId): void
    {
        $tasks = Database::getActiveTasks();
        if (empty($tasks)) {
            $this->bot->editMessage($userId, $messageId, emoji(49) . ' ' . $this->msg('tasks_empty'), noTasksKeyboard());
            return;
        }
        $text = emoji(49) . ' ' . $this->msg('tasks_menu') . "\n\n";
        foreach ($tasks as $t) {
            // v2.9.45 (R6-89/MEDIUM): TaskSystem::formatTaskRow renders the
            // 3-currency rewards + the bot-type label — the global helper only
            // read the legacy reward_amount (always 0 for panel tasks) so every
            // task showed «پاداش: 0».
            $text .= TaskSystem::formatTaskRow($t, $this->config) . "\n";
        }
        $this->bot->editMessage($userId, $messageId, $text, taskListKeyboard($tasks));
    }

    // ─── Daily reward (skill-based: fixed daily engagement reward) ───

    private function claimReward(int $userId, int $messageId, array $userData): void
    {
        $diff = time() - (int)$userData['last_reward'];
        $cooldown = (int)$this->config['reward']['cooldown_hours'] * 3600;
        if ($diff < $cooldown) {
            $remaining = $cooldown - $diff;
            $this->bot->editMessage($userId, $messageId, formatRewardCooldown($remaining, $this->config), backToMainMenuKeyboard());
            return;
        }

        $mode = (string)($this->config['reward']['mode'] ?? 'game');

        // In 'game' mode, show a game-selection keyboard first
        if ($mode === 'game') {
            $text = emoji(8) . ' ' . $this->msg('reward_game_prompt');
            $this->bot->editMessage($userId, $messageId, $text, gameSelectKeyboard());
            return;
        }

        // 'random' or single-game mode
        $reward = $this->rollReward($userId, $mode);
        $this->finishReward($userId, $messageId, $userData, $reward, $mode);
    }

    /**
     * Handle the user's game choice in 'game' mode.
     * Sends the dice and pays out the reward if the user hits the winning value.
     *
     * Winning values:
     *   dice(6), dart(6), basketball(5), football(5), bowling(6), slot(64=jackpot)
     */
    private function playRewardGame(int $userId, int $messageId, array $userData, string $gameEmoji): void
    {
        $diff = time() - (int)$userData['last_reward'];
        $cooldown = (int)$this->config['reward']['cooldown_hours'] * 3600;
        if ($diff < $cooldown) {
            $remaining = $cooldown - $diff;
            $this->bot->editMessage($userId, $messageId, formatRewardCooldown($remaining, $this->config), backToMainMenuKeyboard());
            return;
        }

        // Send the dice and read the value
        $diceResult = $this->bot->sendDice($userId, $gameEmoji);
        $value = 0;
        if ($diceResult && isset($diceResult->ok) && $diceResult->ok) {
            $value = (int)($diceResult->result->dice->value ?? 0);
        }

        // Determine if the value is a winning value for this game
        $winningValues = [
            '🎲' => [6],
            '🎯' => [6],
            '🏀' => [5],
            '⚽' => [5],
            '🎳' => [6],
            '🎰' => [64],
        ];
        $wins = $winningValues[$gameEmoji] ?? [6];
        $minReward = (int)($this->config['reward']['game_min_reward'] ?? 1);
        $maxReward = (int)($this->config['reward']['game_max_reward'] ?? 5);
        $reward = in_array($value, $wins, true) ? random_int($minReward, $maxReward) : 0;

        $this->finishReward($userId, $messageId, $userData, $reward, 'game', $gameEmoji, $value);
    }

    /**
     * Roll a reward in 'random' or single-game modes.
     */
    private function rollReward(int $userId, string $mode): int
    {
        $currency = (string)($this->config['reward']['reward_currency'] ?? 'game_token');

        if ($mode === 'random') {
            if (random_int(1, 100) <= (int)$this->config['reward']['win_chance']) {
                $jackpotChance = (int)$this->config['reward']['jackpot_chance'];
                $roll = random_int(1, max(2, $jackpotChance + 1));
                if ($roll > $jackpotChance) {
                    return (int)$this->config['reward']['jackpot_reward'];
                }
                // v2.9.48 (R10): normalize bounds — random_int() throws when
                // min > max, so a bad admin combo (min>max) used to kill the
                // whole claim with the generic «خطایی رخ داد».
                [$minR, $maxR] = [(int)$this->config['reward']['min_reward'], (int)$this->config['reward']['max_reward']];
                return random_int(min($minR, $maxR), max($minR, $maxR));
            }
            return 0;
        }

        // Single-game mode: send dice, check winning value
        $modeEmojiMap = [
            'dice' => '🎲', 'dart' => '🎯', 'basketball' => '🏀',
            'football' => '⚽', 'bowling' => '🎳', 'slot' => '🎰',
        ];
        $winningValues = [
            'dice' => [6], 'dart' => [6], 'basketball' => [5],
            'football' => [5], 'bowling' => [6], 'slot' => [64],
        ];
        $emoji = $modeEmojiMap[$mode] ?? '🎲';
        $diceResult = $this->bot->sendDice($userId, $emoji);
        $value = 0;
        if ($diceResult && isset($diceResult->ok) && $diceResult->ok) {
            $value = (int)($diceResult->result->dice->value ?? 0);
        }
        $wins = $winningValues[$mode] ?? [6];
        $minReward = (int)($this->config['reward']['game_min_reward'] ?? 1);
        $maxReward = (int)($this->config['reward']['game_max_reward'] ?? 5);
        // v2.9.48 (R10): bounds normalized (see rollReward) — min>max must not
        // throw and void the user's daily claim.
        return in_array($value, $wins, true)
            ? random_int(min($minReward, $maxReward), max($minReward, $maxReward))
            : 0;
    }

    /**
     * Finish the reward: credit wallet, update user, show result.
     */
    private function finishReward(int $userId, int $messageId, array $userData, int $reward, string $mode, string $gameEmoji = '', int $diceValue = 0): void
    {
        $currency = (string)($this->config['reward']['reward_currency'] ?? 'game_token');
        // v2.9.45 (R6-62/L2): validate the configured currency BEFORE the
        // cooldown claim — an invalid admin value used to burn the user's
        // daily cooldown while the credit silently failed.
        if (!in_array($currency, ['game_token', 'toman', 'ntx'], true)) {
            $currency = 'game_token';
        }

        // vNEXT (G3): atomic claim BEFORE crediting. claimReward/playRewardGame
        // read the cooldown from a stale snapshot, so two rapid taps on the
        // dice buttons could BOTH pass and BOTH credit (reward can be toman!).
        // claimDailyReward() flips last_reward with a guarded UPDATE
        // (expected = the snapshot value); the loser gets the alert and
        // nothing is credited. With the claim done, the old post-credit
        // saveUser(last_reward) writes are removed — they would clobber the
        // atomically-set value.
        $claimed = Database::claimDailyReward($userId, $userData['last_reward'] ?? null, time());
        if (!$claimed) {
            $this->bot->answerCallback($this->currentCallbackId, 'پاداش امروز قبلاً دریافت شده است.', true);
            return;
        }

        if ($reward > 0) {
            try {
                Wallet::credit($userId, $currency, $reward, 'daily_reward', null, null);
            } catch (\Throwable $e) {
                error_log('[claimReward] Wallet::credit failed: ' . $e->getMessage());
                // v2.9.45 (R6-62/L2): credit failed — give the cooldown back so
                // the user can retry instead of losing the day.
                try {
                    Database::restoreDailyReward($userId, $userData['last_reward'] ?? null);
                } catch (\Throwable $e2) {
                    error_log('[claimReward] cooldown restore failed: ' . $e2->getMessage());
                }
                $this->bot->answerCallback($this->currentCallbackId, 'خطا در اعطای پاداش — بعداً دوباره تلاش کنید.', true);
                return;
            }
            $rewardText = formatReward($reward, $currency, $this->config);
            $this->bot->editMessage($userId, $messageId, emoji(8) . ' ' . $this->fillTemplate($this->msg('reward_won'), $reward) . ' ' . $rewardText, backToMainMenuKeyboard());
        } else {
            $this->bot->editMessage($userId, $messageId, emoji(10) . ' ' . $this->msg('reward_lost'), backToMainMenuKeyboard());
        }

        Database::logEvent('reward_claimed', $userId, [
            'reward' => $reward, 'mode' => $mode, 'game' => $gameEmoji, 'value' => $diceValue,
        ]);
    }

    // ─── Referral (T46: full «کسب درآمد رفرال» submenu) ────────────────

    /**
     * v3.3 (T46): the referral HUB — the «کسب درآمد رفرال» submenu.
     * Shows the referral hub: teaches the two invite types with the CURRENT
     * reward amounts (live-computed from the admin settings) and routes —
     * T46-h: share removed, T46-i: trailing friends/earnings stats removed
     * (that detail lives in the refstats screen).
     */
    private function showReferralHub(int $userId, int $messageId, array $userData): void
    {
        // T46-h: $cashLink removed — the hub no longer carries a share button
        // or any raw link; links live only in the two per-type views.
        // T46-i (owner): the trailing friends/earnings stats block is REMOVED
        // from the hub text («کل مشخصات دوستان دعوت‌شده رو در آخر مینویسه که
        // نباید بنویسه») — that detail is exactly what the «درآمد و دوستان
        // من» screen (refstats) is for; duplicating it here made the button
        // pointless. The hub now teaches + routes only. Reward numbers stay
        // live-computed from the admin settings (Invite::compute*).
        $cashAmount = Invite::computeTomanReward($this->config);
        $tokenAmount = Invite::computeTokenReward($this->config);

        // T46-c (owner): the hub must TEACH the difference between the two
        // invite types — the old hub dumped both raw links + stats at once
        // (it read like a second main menu, with zero explanation). Links now
        // live inside the two per-type views; the hub explains and routes.
        $text = emoji(17) . " <b>کسب درآمد رفرال</b>\n\n"
            . "لینک دعوتت را برای دوستانت بفرست؛ هر کسی که با لینک تو وارد ربات شود، بلافاصله پاداش می‌گیری — به دو شکل:\n\n"
            . emoji(44) . " <b>دعوت نقدی یعنی چی؟</b>\n"
            . "به ازای هر عضو " . number_format($cashAmount) . " <b>تومان واقعی</b> می‌گیری. این پول مستقیم به کیف پولت می‌رود و از بخش «برداشت‌های من» قابل نقد شدن است.\n\n"
            . emoji(8) . " <b>دعوت توکنی یعنی چی؟</b>\n"
            . "به ازای هر عضو {$tokenAmount} <b>توکن بازی</b> می‌گیری. توکن یعنی بلیت ورود به بازی‌ها؛ با توکن بدون پرداخت پول وارد بازی شو، شرط‌بندی کن و جایزه ببر.\n\n"
            . emoji(12) . " <b>فرق اصلی:</b> نقدی = پول نقد برای برداشت | توکنی = اعتبار بازی برای شرط‌بندی. پس هر دو لینک را پخش کن!\n\n"
            . emoji(42) . " برای گرفتن لینک اختصاصی و دیدن درآمد و دوستانت، روی یکی از دکمه‌های پایین بزن:";

        $this->bot->editMessage($userId, $messageId, $text, referralHubKeyboard());
    }

    /**
     * v3.3 (T46): one invite-link detail view (toman | token) with the
     * native share sheet for that exact link.
     */
    private function showReferralView(int $userId, int $messageId, string $rewardType): void
    {
        $botUsername = $this->bot->getBotUsername();
        $link = Invite::buildLink($userId, $rewardType, $botUsername);

        // T46-c (owner): each invite type gets its OWN full guide — steps,
        // exact reward, what it is worth, then the link (the old view was a
        // generic one-liner promo + raw link, explaining nothing).
        if ($rewardType === 'token') {
            $amount = Invite::computeTokenReward($this->config);
            $amountStr = number_format($amount) . ' توکن';
            $title  = emoji(8) . ' <b>دعوت توکنی — راهنما و لینک</b>';
            $reward = emoji(8) . " به ازای هر عضو، <b>{$amountStr}</b> فوری به کیف پولت اضافه می‌شود.";
            $worth  = emoji(20) . " <b>توکن به چه دردی می‌خورد؟</b> بلیت ورود به بازی‌هاست؛ با توکن بدون پرداخت تومان وارد بازی شو، شرط‌بندی کن و جایزه ببر.";
        } else {
            $amount = Invite::computeTomanReward($this->config);
            $amountStr = number_format($amount) . ' تومان';
            $title  = emoji(44) . ' <b>دعوت نقدی — راهنما و لینک</b>';
            $reward = emoji(44) . " به ازای هر عضو، <b>{$amountStr}</b> فوری به کیف پولت اضافه می‌شود.";
            $worth  = emoji(1) . " <b>این پول واقعی و قابل برداشت است؛</b> هر وقت خواستی از بخش «برداشت‌های من» نقدش کن.";
        }

        $text = $title . "\n\n"
            . "<b>مراحل کار:</b>\n"
            . "۱. لینک اختصاصی‌ات را بردار (همین پایین).\n"
            . "۲. با دکمه «ارسال این لینک به دوستان» یا کپی، آن را در گروه‌ها و چت دوستانت پخش کن.\n"
            . "۳. هر کس با لینک تو وارد ربات شود، پاداش {$amountStr} بلافاصله برای تو حساب می‌شود.\n\n"
            . $reward . "\n"
            . $worth . "\n\n"
            . emoji(35) . " <b>لینک اختصاصی تو:</b>\n"
            . "<code>{$link}</code>";

        $this->bot->editMessage($userId, $messageId, $text, referralViewKeyboard($rewardType, $link));
    }

    /**
     * v3.3 (T46): referral stats screen — friends + earnings detail, plus
     * the matured-NTX hint so users see the full money picture.
     */
    private function showReferralStats(int $userId, int $messageId, array $userData): void
    {
        $friends = (int)($userData['sub_count'] ?? 0);
        $earnings = Database::getReferralEarnings($userId);
        $tomanEarned = (int)($earnings['toman']['total'] ?? 0);
        $tokenEarned = (int)($earnings['game_token']['total'] ?? 0);

        $text = emoji(23) . " <b>درآمد و دوستان من</b>\n\n"
            . emoji(35) . " دوستان دعوت‌شده: {$friends} نفر\n"
            . emoji(44) . " پول نقدی گرفته‌شده از رفرال: " . number_format($tomanEarned) . " تومان\n"
            . emoji(20) . " توکن بازی گرفته‌شده از رفرال: " . number_format($tokenEarned) . " توکن\n\n"
            . emoji(21) . " هر دوست تازه = پول یا توکن فوری برای تو؛ هرچه بیشتر دعوت کنی، بیشتر می‌گیری!";

        $this->bot->editMessage($userId, $messageId, $text, referralStatsKeyboard());
    }

    // ─── Profile ────────────────────────────────────────────────────

    private function showProfile(int $userId, int $messageId, array $userData): void
    {
        $userData['user_id'] = $userId;
        $text = formatProfile($userData, $this->config);
        // Profile keyboard: change-gender button + back-to-main button
        $kb = json_encode(['inline_keyboard' => [
            [makeButton('change_gender', 'تغییر جنسیت', 'change_gender', 'primary', 24)],
            [makeButton('back_to_main', 'برگشت به منوی اصلی', 'back_to_main_menu', 'danger', 11)],
        ]]);
        $this->bot->editMessage($userId, $messageId, $text, $kb);
    }

    // ─── League ─────────────────────────────────────────────────────

    private function showLeague(int $userId, int $messageId, bool $fromHub = false): void
    {
        $adminId = (int)$this->config['referral']['admin_chat_id'];
        $top = Database::getTopReferrals(10, $adminId);
        $text = formatLeague($top, $this->config);
        // v3.3 (T46): from the referral hub, keep the user inside the referral
        // funnel — back returns to the hub instead of the main menu.
        // T46-i (owner): the league screen no longer re-offers «لیگ رفرال» on
        // its own keyboard — from the hub the only button is the way back.
        $kb = $fromHub
            ? referralLeagueKeyboard()
            : backToMainMenuKeyboard();
        $this->bot->editMessage($userId, $messageId, $text, $kb);
    }

    // ─── Support (preserved from v1) ────────────────────────────────

    private function startSupport(int $userId, int $messageId, array $userData): void
    {
        $tickets = Database::getActiveTicketsForUser($userId);
        if (!empty($tickets)) {
            $text = $this->msg('support_list_open');
            foreach ($tickets as $t) {
                $preview = mb_substr($t['question'] ?? '', 0, 40);
                $text .= " تیکت #{$t['id']}: {$preview}...\n";
            }
            $this->bot->editMessage($userId, $messageId, $text, supportListKeyboard($tickets));
        } else {
            $userData['status'] = 'waiting_for_support_message';
            // T46-p: targeted status write — never round-trip the stale
            // request-start snapshot over concurrent flag/balance changes.
            Database::updateUserColumns($userId, ['status' => $userData['status']]);
            $this->bot->editMessage($userId, $messageId, emoji(12) . ' ' . $this->msg('support_prompt'), backToMainMenuKeyboard());
        }
    }

    private function newSupportTicket(int $userId, int $messageId, array $userData): void
    {
        $userData['status'] = 'waiting_for_support_message';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);
        $this->bot->editMessage($userId, $messageId, emoji(12) . ' ' . $this->msg('support_prompt'), backToMainMenuKeyboard());
    }

    private function userReplyToTicket(int $userId, int $messageId, array $userData, int $ticketId): void
    {
        $ticket = Database::getTicket($ticketId);
        if (!$ticket || (int)$ticket['user_id'] !== $userId) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('support_not_found'), true);
            return;
        }
        if ($ticket['status'] === 'closed') {
            Database::reopenTicket($ticketId);
        }
        Database::setSetting('active_support_ticket_' . $userId, (string)$ticketId);
        $userData['status'] = 'waiting_for_support_reply';
        // T46-p: targeted status write — never round-trip the stale
        // request-start snapshot over concurrent flag/balance changes.
        Database::updateUserColumns($userId, ['status' => $userData['status']]);

        $adminName = $ticket['admin_name'] ?? 'پشتیبانی';
        $messages = Database::getSupportMessages($ticketId);
        $summary = " تیکت #{$ticketId}\n زمان باز شدن: " . date('Y-m-d H:i', strtotime($ticket['question_time'])) . "\n پاسخ‌دهنده: {$adminName}\n تعداد پیام‌ها: " . count($messages) . "\n\n────────────\n\n";
        foreach ($messages as $m) {
            $senderEmoji = $m['sender_type'] === 'user' ? emoji(37) : emoji(28);
            $senderName = $m['sender_type'] === 'user' ? 'شما' : ($m['sender_name'] ?? 'پشتیبانی');
            $time = date('H:i', strtotime($m['created_at']));
            $body = $m['text'] ?? '';
            if ($m['content_type'] !== 'text') {
                $labels = ['photo' => '[عکس]', 'video' => '[ویدیو]', 'audio' => '[صدا]', 'document' => '[فایل]'];
                $body = ($labels[$m['content_type']] ?? '[رسانه]') . ($body !== '' ? "\n" . $body : '');
            }
            $summary .= "{$senderEmoji} {$senderName} ({$time}):\n{$body}\n\n";
        }
        $summary .= "────────────\n\n" . $this->msg('support_reply_prompt');
        // vNEXT (G6): clamp summary to Telegram's 4096-char hard limit.
        $summary = mb_substr($summary, 0, 3900);
        $this->bot->editMessage($userId, $messageId, $summary, supportAnswerKeyboard($adminName, $ticketId));
    }

    private function closeTicketByUser(int $userId, int $messageId, array $userData, int $ticketId): void
    {
        $ticket = Database::getTicket($ticketId);
        if (!$ticket || (int)$ticket['user_id'] !== $userId) {
            $this->bot->answerCallback($this->currentCallbackId, $this->msg('support_not_found'), true);
            return;
        }
        $messages = Database::getSupportMessages($ticketId);
        $userCount = count(array_filter($messages, fn($m) => $m['sender_type'] === 'user'));
        $adminCount = count(array_filter($messages, fn($m) => $m['sender_type'] === 'admin'));
        Database::closeTicket($ticketId);
        Database::deleteSetting('active_support_ticket_' . $userId);
        // v2.9.45 (R6-93): targeted status write (was stale-snapshot saveUser).
        Database::updateUserColumns($userId, ['status' => 'main']);

        $text = $this->fillTemplate($this->msg('support_closed_details'), $ticketId, date('Y-m-d H:i', strtotime($ticket['question_time'])), date('Y-m-d H:i'), $userCount, $adminCount);
        $text .= "\n\n بسته شده توسط: کاربر";
        $this->bot->editMessage($userId, $messageId, $text, backToMainMenuKeyboard());

        $others = Database::getSupportMessagesWithTgId($ticketId, $userId);
        foreach ($others as $m) {
            $this->bot->editMessageReplyMarkup($userId, (int)$m['tg_message_id'], backToMainMenuKeyboard());
            usleep(35000);
        }
    }

    private function claimSupportTicket(int $adminChatId, int $messageId, int $ticketId): void
    {
        $ticket = Database::getTicket($ticketId);
        if (!$ticket || $ticket['status'] !== 'open') {
            $this->bot->sendMessage($adminChatId, $this->msg('support_not_found'), backToMainMenuKeyboard());
            return;
        }
        $adminInfo = null;
        foreach (Database::getAdminsWithPermission('support') as $a) {
            if ((int)$a['user_id'] === $adminChatId) { $adminInfo = $a; break; }
        }
        if (!$adminInfo) {
            $this->bot->sendMessage($adminChatId, ' شما دسترسی پاسخ به تیکت ندارید.', backToMainMenuKeyboard());
            return;
        }
        // v2.9.45 (R6-95/LOW): same claim-race feedback (see claimWithdrawal).
        if (!Database::claimTicket($ticketId, $adminChatId, $adminInfo['display_name'])) {
            $this->bot->sendMessage($adminChatId, emoji(10) . ' این تیکت قبلاً توسط ادمین دیگری برداشته شده است.', backToMainMenuKeyboard());
            return;
        }
        Database::setSetting('pending_support_reply_' . $adminChatId, (string)$ticketId);
        $this->bot->sendMessage($adminChatId, $this->fillTemplate($this->msg('support_claimed'), $ticketId), backToMainMenuKeyboard());
    }

    private function registerNewUser(int $userId): void
    {
        // vNEXT (X-BONUS): INSERT-only via Database::createUser — the old
        // saveUser upsert here could CLOBBER a row a concurrent /start just
        // created (same lost-race class as G-B13 in MessageHandler:
        // referral_id reset to default, is_first_time re-armed, sub_count
        // zeroed). On a lost race the winner's row is authoritative.
        $newUserData = [
            'user_id' => $userId,
            'status' => 'main',
            'referralId' => (int)$this->config['referral']['default_referral_id'],
            'gameTokens' => 0,
            'toman' => 0,
            'ntxBalance' => 0,
            'sub_count' => 0,
            'last_reward' => 0,
            'isFirstTime' => true,
            'displayName' => 'user',
            'satisfactionPending' => false,
            'pendingWithdrawalId' => null,
        ];
        if (!Database::createUser($newUserData)) {
            // Lost a registration race (or insert failed) — reload the
            // winner's row; if it is not visible yet, continue with the
            // in-memory defaults and write nothing.
            Database::loadUser($userId);
        }
        if ($this->checkMembership($userId, 0)) {
            // Send gender prompt — user must set gender before using any feature.
            // The welcome message is sent by the gender_boy/gender_girl callback
            // (via the "gender_set" text + main menu) after they pick a gender.
            $this->bot->sendMessage($userId, emoji(40) . ' ' . $this->msg('gender_prompt'), genderSelectionKeyboard());
        }
    }
}
