<?php

declare(strict_types=1);

/**
 * MessageHandler — processes plain-text/media messages from users.
 *
 * v2: VPN/server flow removed. New state machine handles:
 *   - satisfaction submission (user is blocked until valid satisfaction)
 *   - text-task answer submission
 *   - withdrawal description input (stars/card)
 *   - support ticket flow (preserved from v1)
 *   - NTX/game flows are callback-driven (no text input needed)
 *
 * The handler accepts messages from private chats, groups, supergroups.
 * Group messages are only acted on if they mention the bot or are commands.
 */

class MessageHandler
{
    private TelegramBot $bot;
    private array $config;
    private string $botUsername;

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

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

        $chatId = (int)$message['chat']['id'];
        $chatType = $message['chat']['type'] ?? 'private';
        $text = trim((string)($message['text'] ?? ''));
        $userId = (int)($message['from']['id'] ?? 0);
        if ($userId === 0) return;

        // vNEXT (G8): Ignore messages sent on behalf of a chat (anonymous
        // group admins / channel posts → sender_chat set) and Telegram's
        // system bots (GroupAnonymousBot 1087968824, Channel_Bot 136817688).
        // They must never be auto-registered, stored as group_sender_, or
        // trigger registration-side flows.
        if (isset($message['sender_chat']) || $userId === 1087968824 || $userId === 136817688) {
            return;
        }

        $displayName = $this->extractDisplayName($message);

        // ── In groups, only respond to commands (/start, /help, /game, /dare,
        //    /tictactoe, /resume, /close) or messages that mention the bot.
        //    v2.9.18: Group commands now work — /start@bot sets up commands and
        //    shows a direct game menu (no main menu, like anonymous PV).
        if ($chatType !== 'private') {
            // v3.3 (T46-l): configurable-task group hook (group_add progress,
            // group_msgs counting, race finishers). Runs BEFORE command
            // routing so service messages (new_chat_members) are seen; the
            // hook itself ignores commands and never throws into game routing.
            try {
                TaskSystem::onGroupMessage(
                    $chatId,
                    (string)($message['chat']['username'] ?? ''),
                    $userId,
                    $message,
                    $this->bot,
                    $this->config
                );
            } catch (\Throwable $e) {
                error_log('[MessageHandler] task group hook exception: ' . $e->getMessage());
            }
            // Strip @botusername suffix from commands (e.g., /start@mybot → /start)
                        // ── T46-y: in-group mafia settings (round 41) — the game is
            //    configured from the group (admins only), not the panel.
            //    Plain Persian text must be intercepted BEFORE the
            //    non-command group-text drop below.
            if ($text === 'تنظیمات مافیا' || str_starts_with($text, '/mafiasettings')) {
                try {
                    MafiaGame::openGroupSettings($userId, $chatId, $this->bot, $this->config);
                } catch (\Throwable $e) {
                    error_log('[MessageHandler] mafia group-settings exception: ' . $e->getMessage());
                }
                return;
            }
$cleanText = preg_replace('/^\/(\w+)@\w+/', '/$1', $text);
            $isCommand = (
                str_starts_with($cleanText, '/start') ||
                str_starts_with($cleanText, '/help') ||
                str_starts_with($cleanText, '/resume') ||
                str_starts_with($cleanText, '/close') ||
                str_starts_with($cleanText, '/game') ||
                str_starts_with($cleanText, '/dare') ||
                str_starts_with($cleanText, '/tictactoe')
            );
            $mentionsBot = !empty($message['entities']) && $this->messageMentionsBot($message);
            // v3.2 (T42-c): حدس کلمه — the turn player types the word INTO the
            // group chat; route it to the group's active wordguess session
            // BEFORE dropping non-command group text entirely.
            if (!$isCommand && !$mentionsBot) {
                try {
                    if (Game::handleGroupTextInput($userId, $chatId, $text, $this->bot, $this->config)) return;
                } catch (\Throwable $e) {
                    error_log('[MessageHandler] group text-input game exception: ' . $e->getMessage());
                }
                return;
            }
            // Handle group commands
            if ($isCommand) {
                $this->handleGroupCommand($userId, $chatId, $cleanText, $displayName);
                return;
            }
            // v2.9.44 (T19-2): a plain @bot mention in a group must NOT fall
            // through into the private state machine — the "message" here is a
            // GROUP text. Falling through made satisfaction-blocked members
            // submit GROUP content as their satisfaction (which approval then
            // FORWARDS from the group into the public channel — privacy leak +
            // permanent stuck-pending when the bot later leaves the group),
            // and let support/task/withdrawal-reply states consume group text.
            return;
        }

        // ── Admin pending withdrawal-reply? (admin is replying to a withdrawal
        //    in their private chat with the bot)
        $pendingWdrawReply = Database::getSetting('pending_wdraw_reply_' . $userId);
        if ($pendingWdrawReply !== null && $text !== '' && !str_starts_with($text, '/start')) {
            $this->handleWithdrawalAdminReply($userId, (int)$pendingWdrawReply, $text, $message);
            return;
        }

        // ── Admin pending support reply (preserved from v1)
        $pendingSupReply = Database::getSetting('pending_support_reply_' . $userId);
        if ($pendingSupReply !== null && $text !== '' && !str_starts_with($text, '/start')) {
            $this->handleSupportReply($userId, (int)$pendingSupReply, $text);
            return;
        }

        // ── Extract referral payload from /start
        $referralData = ['referrer_id' => null, 'reward_type' => null];
        $joinGameSessionId = null;
        if (str_starts_with($text, '/start')) {
            $payload = trim(substr($text, 6)); // strip "/start"
            // Check for friends-game join deep-link: "joingame_<sessionId>"
            if (preg_match('/^joingame_(\d+)$/', $payload, $jm)) {
                $joinGameSessionId = (int)$jm[1];
            } else {
                $referralData = Invite::parsePayload($payload);
            }
        }
        $referralId = $referralData['referrer_id'] ?? null;
        $rewardType = $referralData['reward_type'] ?? null;

        $userData = Database::loadUser($userId);

        // v2.9.32: Check if user is banned — only allow /start to show ban message
        if ($userData !== null && !empty($userData['banned'])) {
            if ($text === '/start' || str_starts_with($text, '/start ')) {
                // v2.9.45 (R6-35): plain 🚫 → custom emoji slot 53 (PV outgoing text sweep).
                $this->bot->sendMessage($userId, emoji(53) . " شما از ربات بن شده‌اید.\n\nدر صورت اشتباه با پشتیبانی تماس بگیرید.");
            }
            return;
        }

        // Update display name if changed
        // v2.9.44 (T19-5): targeted single-column write — the full-row
        // saveUser() here round-tripped the load-time snapshot and could
        // resurrect satisfaction_pending / banned / pending_withdrawal_id
        // flags an admin had just changed.
        if ($userData !== null && $displayName !== '' && $displayName !== ($userData['displayName'] ?? '')) {
            $userData['displayName'] = $displayName;
            Database::updateUserColumns($userId, ['display_name' => $displayName]);
        }

        // ── Register new user
        if ($userData === null) {
            $this->registerNewUser($userId, $referralId, $rewardType, $displayName);
            return;
        }

        // ── Gender gate — must be set before using ANY feature.
        //    /start (with or without a referral/joingame payload) is exempt so
        //    deep-links and re-entry still work; the rules message will be
        //    sent by handleStartCommand, but the next button-tap will be blocked
        //    by the callback-side gender gate until they pick a gender.
        if ((int)($userData['gender'] ?? 0) === 0 && !str_starts_with($text, '/start')) {
            // v2.9.22: Show rules message (which leads to gender selection after confirm)
            $this->sendRulesMessage($userId);
            return;
        }

        // ── Satisfaction gate: if user is blocked for satisfaction, EVERY
        //    message (except /start) is treated as a satisfaction submission.
        if (!empty($userData['satisfactionPending']) && !str_starts_with($text, '/start')) {
            Satisfaction::receive($userId, $message, $this->config, $this->bot);
            return;
        }

        // ── /resume — recover an active game by re-sending the board as a
        //    new message. Placed BEFORE the state machine so it always takes
        //    precedence over any waiting_* status.
        //    v3.2 (T42-d) OWNER DIRECTIVE: /game NO LONGER resumes — /game
        //    opens the game-selection menu; /resume is the ONLY way to bring
        //    back an active game («بازی های فعال باید با /resume باشه»).
        //    @botname suffixes (/game@NetDropbot) are tolerated here too.
        $pvCmd = preg_replace('/^\/(\w+)@\w+$/', '/$1', $text);
        if ($pvCmd === '/resume') {
            error_log('[T42d-flow] PV /resume user=' . $userId);
            if (!$this->checkMembership($userId, null)) return;
            $resumed = Game::resumeGame($userId, $this->bot, $this->config);
            if (!$resumed) {
                $this->bot->sendMessage($userId, $this->msg('game_resume_none'), mainMenuKeyboard());
            }
            return;
        }

        // ── /game (+ /games alias) — open the games-mode chooser. T46-m (owner):
        //    the games section first asks صوتی/متنی; the old two-mode menu lives
        //    behind «بازی متنی (مثل قبل)».
        if ($pvCmd === '/game' || $pvCmd === '/games') {
            error_log('[T42d-flow] PV /game user=' . $userId);
            if (!$this->checkMembership($userId, null)) return;
            $this->bot->sendMessage(
                $userId,
                gameModeChooserText($this->config),
                gameModeChooserKeyboard(),
                'HTML'
            );
            return;
        }

        // ── /close — cancel active game
        if ($text === '/close') {
            if (!$this->checkMembership($userId, null)) return;
            $session = Game::getActiveSessionForUser($userId);
            if ($session === null) {
                $this->bot->sendMessage($userId, $this->msg('game_resume_none'), mainMenuKeyboard());
                return;
            }
            if ($session['status'] === 'waiting') {
                // v3.3 (T45) OWNER DIRECTIVE: group waiting boards no longer
                // carry «خروج از صف» (inline-parity buttons), so /close must
                // actually WORK for queues now: the effective owner cancels the
                // queue; TOD joiners leave it individually (cancelWaitingSession
                // routes both). Anonymous quick-game PV boards keep the button.
                $wSid = (int)$session['id'];
                $isOwner = (Game::sessionOwnerId($session) === $userId);
                $isTodJoiner = ((string)($session['game_type'] ?? '') === 'truthordare' && Database::isPlayerInGame($wSid, $userId));
                if (!$isOwner && !$isTodJoiner) {
                    $this->bot->sendMessage($userId, emoji(10) . ' فقط مالک بازی می‌تواند صف را لغو کند.');
                    return;
                }
                Game::cancelWaitingSession($wSid, $userId, $this->bot, $this->config, 0);
                return;
            }

            // v2.9.22: For TOD games — ask the creator "are you sure?" via button
            // For TTT games — tag the opponent and send a new message with cancel request
            $gameType = (string)($session['game_type'] ?? 'tictactoe');
            $sessionId = (int)$session['id'];

            if ($gameType === 'truthordare') {
                // TOD: ask creator for confirmation via inline button
                // v3.3 (T45): EFFECTIVE owner (ownership transfer aware).
                $isCreator = (Game::sessionOwnerId($session) === $userId);
                if (!$isCreator) {
                    $this->bot->sendMessage($userId, emoji(10) . ' فقط مالک بازی می‌تواند آن را پایان دهد.');
                    return;
                }
                $kb = json_encode(['inline_keyboard' => [[
                    ['text' => 'بله، بازی رو تمام کن', 'callback_data' => "tod_force_end_{$sessionId}", 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
                    ['text' => 'خیر، ادامه بده', 'callback_data' => 'noop', 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(40)],
                ]]]);
                $this->bot->sendMessage($userId, emoji(12) . ' آیا مطمئن هستید که می‌خواهید بازی جرات یا حقیقت را پایان دهید؟', $kb);
                return;
            }

            // TTT: tag opponent and send a new message with cancel request
            $p1Id = (int)$session['player1_id'];
            $p2Id = (int)($session['player2_id'] ?? 0);
            $opponentId = ($userId === $p1Id) ? $p2Id : $p1Id;
            $opponentLink = "<a href=\"tg://user?id={$opponentId}\">کاربر</a>";
            $userLink = "<a href=\"tg://user?id={$userId}\">کاربر</a>";

            // Send a new message to the user with the cancel request button
            $kb = json_encode(['inline_keyboard' => [[
                ['text' => 'درخواست لغو بازی', 'callback_data' => "gcancelreq_{$sessionId}", 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
            ]]]);
            $this->bot->sendMessage($userId, emoji(32) . " درخواست لغو بازی برای حریف ارسال شد. روی دکمه زیر بزنید تا درخواست لغو به حریف ارسال شود:", $kb, 'HTML');

            // Also notify the opponent
            if ($opponentId > 0) {
                $notifyText = emoji(10) . " {$userLink} درخواست لغو بازی رو داد. اگر موافقی، از دکمه‌های بازی استفاده کن.";
                $this->bot->sendMessage($opponentId, $notifyText, null, 'HTML');
            }
            return;
        }

        // ── State machine for users with a special status
        $status = $userData['status'] ?? 'main';
        if (!str_starts_with($text, '/start')) {
            if ($status === 'waiting_for_support_message') {
                // vNEXT: media-only messages carry no text — fall back to the
                // caption so photo/voice tickets aren't created empty.
                $supportText = $text !== '' ? $text : trim((string)($message['caption'] ?? ''));
                $this->handleSupportMessage($userId, $userData, $supportText, $displayName);
                return;
            }
            if ($status === 'waiting_for_support_reply') {
                $this->handleSupportUserReply($userId, $userData, $text);
                return;
            }
            // vNEXT: dead branch removed — it called $this->handleWithdrawalUserMessage()
            // which was NEVER defined (latent fatal), and the 'waiting_withdrawal_message'
            // status was never set anywhere in the codebase (grep-verified), so the
            // branch was unreachable. Real withdrawal states: waiting_withdrawal_amount,
            // waiting_withdrawal_card_info, waiting_withdrawal_reply (all handled below).
            if ($status === 'waiting_withdrawal_reply') {
                $this->handleWithdrawalUserReply($userId, $userData, $text);
                return;
            }
            if (str_starts_with($status, 'waiting_task_')) {
                $taskId = (int)substr($status, strlen('waiting_task_'));
                if ($taskId > 0) {
                    $this->handleTaskAnswer($userId, $taskId, $text, $displayName);
                    return;
                }
            }
            if ($status === 'waiting_withdrawal_amount') {
                $this->handleWithdrawalAmount($userId, $userData, $text, $displayName);
                return;
            }
            if ($status === 'waiting_withdrawal_card_info' || $status === 'waiting_stars_message') {
                // T46-p: media-only messages carry no text — the card/stars
                // flows used to discard the pending amount and force the user
                // back to main on a photo (no caption fallback like support).
                $wdrawText = $text !== '' ? $text : trim((string)($message['caption'] ?? ''));
                if ($status === 'waiting_withdrawal_card_info') {
                    $this->handleWithdrawalCardInfo($userId, $userData, $wdrawText, $displayName);
                } else {
                    $this->handleStarsMessage($userId, $userData, $wdrawText, $displayName);
                }
                return;
            }
            if ($status === 'waiting_stars_amount') {
                $this->handleStarsAmount($userId, $userData, $text, $displayName);
                return;
            }
        }

        // ── /start command
        if (str_starts_with($text, '/start')) {
            // Membership check (mandatory channels)
            if (!$this->checkMembership($userId, null)) return;

            // vNEXT (G10): late-pay referral — a user who registered while
            // FAILING the membership gate kept isFirstTime=true (the old flow
            // never paid). The first /start that passes the gate pays the
            // stored referrer once, then the flag is consumed.
            // vNEXT (11-a contract): the flag is now consumed ATOMICALLY via
            // Database::consumeFirstTimeFlag and the referral is paid ONLY
            // when THIS request wins the consume — two concurrent /start
            // deliveries can no longer both pay (the old blind
            // "isFirstTime=false; saveUser()" clear-point allowed that).
            if (!empty($userData['isFirstTime'])) {
                if (Database::consumeFirstTimeFlag($userId)) {
                    $lateReferrerId = (int)($userData['referralId'] ?? 0);
                    // vNEXT (G-B3): the reward currency must come from the
                    // link the referral actually came from. The invitee could
                    // re-send a DIFFERENT link type later — honor the current
                    // payload's rewardType only when its referrer id matches
                    // the stored one; otherwise default to 'toman'.
                    $lateRewardType = 'toman';
                    if ($referralId !== null && $rewardType !== null && (int)$referralId === $lateReferrerId) {
                        $lateRewardType = $rewardType;
                    }
                    // vNEXT (G-B2): self-referral never pays (silent skip,
                    // same as the existing admin-referral handling).
                    if ($lateReferrerId > 0
                        && $lateReferrerId !== (int)$this->config['referral']['admin_chat_id']
                        && $lateReferrerId !== $userId) {
                        // T46-ap (owner): the referrer is notified the moment
                        // the referral pays (was silent before).
                        $paidAmount = Invite::processNewUserReferral($userId, $lateReferrerId, $lateRewardType, $this->config);
                        if ($paidAmount > 0) {
                            Invite::notifyReferrer($lateReferrerId, $lateRewardType, $paidAmount, $this->config, $this->bot);
                        }
                    }
                }
                // Keep the in-memory snapshot consistent either way —
                // handleStartCommand's saveUser below must NOT re-arm the
                // consumed flag.
                $userData['isFirstTime'] = false;
            }

            Database::logEvent('start', $userId);

            // Friends-game join deep-link: /start joingame_<sessionId>
            if ($joinGameSessionId !== null && $joinGameSessionId > 0) {
                // Satisfaction gate: a satisfaction-blocked user must not
                // re-enter games through the deep-link (every other game
                // path is gated — the callback satisfaction gate and
                // gateTODJoin). The /start exemption only keeps the menu
                // reachable; it never re-opened the join + entry-fee path.
                if (!empty($userData['satisfactionPending'])) {
                    $this->bot->sendMessage($userId, emoji(48) . ' ' . $this->msg('satisfaction_prompt'));
                    return;
                }
                $this->handleJoinGameStart($userId, $joinGameSessionId);
                return;
            }

            $this->handleStartCommand($userId, $userData);
            return;
        }

        if ($text === '/help') {
            $helpText =
                emoji(12) . " <b>راهنمای کامل ربات نت‌دروپ</b>\n" .
                "━━━━━━━━━━━━━━━━━━\n\n" .
                emoji(41) . " <b>حالت‌ها:</b>\n" .
                emoji(42) . " <b>ناشناس:</b> دوز — حریف تصادفی از صف انتظار | جرات‌وحقیقت — صفحه انتظار می‌سازید و با دکمه «ارسال به چت» دوستان را دعوت می‌کنید | ۴۰ بازی دیگر — حریف تصادفی از صف انتظار\n" .
                emoji(42) . " <b>دوستان:</b> بازی رو توی چت/گروه ارسال کن\n\n" .
                emoji(44) . " <b>برداشت:</b> شارژ، استارز، کارت به کارت\n" .
                emoji(49) . " <b>تسک‌ها:</b> عضویت در کانال/ربات → پاداش\n" .
                emoji(8) . " <b>جایزه روزانه:</b> روزی یک بار\n" .
                emoji(17) . " <b>کسب درآمد رفرال:</b> دعوت دوستان → پول نقدی/توکن + لیگ\n" .
                emoji(20) . " <b>NTX:</b> از بازی‌ها → بعد از ۲۴ ساعت به تومان\n\n" .
                "━━━━━━━━━━━━━━━━━━\n" .
                emoji(13) . " <b>کامندها:</b>\n" .
                "/start — منوی اصلی\n" .
                "/game — منوی انتخاب بازی‌ها (ناشناس / با دوستان)\n" .
                "/resume — بازیابی بازی فعلی (بازگرداندن صفحه بازی)\n" .
                "/close — لغو بازی فعلی\n" .
                "/help — این راهنما";
            // v3.2 (T42): the FULL games list with descriptions follows as the
            // second+third messages (Telegram 4096-char limit → 2 parts).
            $this->bot->sendMessage($userId, $helpText);
            $this->bot->sendMessage($userId, Game::gamesHelpText(1));
            $this->bot->sendMessage($userId, Game::gamesHelpText(2), mainMenuKeyboard());
            return;
        }

        // ── v3.2 (T42-c): text-input games (حدس کلمه) — a typed private text
        // that is not a command belongs to the sender's active wordguess
        // session; handle it BEFORE the stray-text reset swallows it.
        if ($chatType === 'private' && $text !== '' && !str_starts_with($text, '/')) {
            try {
                if (Game::handleTextInput($userId, $text, $this->bot, $this->config)) return;
            } catch (\Throwable $e) {
                error_log('[MessageHandler] text-input game exception: ' . $e->getMessage());
            }
            // v3.3 (T46-m): Mafia night consult relay — a living mafia typing in
            // PV during the night relays to his teammates (invisible to the
            // group). Consumes the text only when the sender is an acting mafia.
            try {
                if (MafiaGame::handlePvText($userId, $text, $this->bot, $this->config)) return;
            } catch (\Throwable $e) {
                error_log('[MessageHandler] mafia pv-relay exception: ' . $e->getMessage());
            }
        }

        // Default: reset to main menu silently (ignore stray text in private chat)
        // v2.9.45 (R6-75/M2): targeted column update — the full-row saveUser()
        // here wrote back the line-119 snapshot and could resurrect is_first_time
        // (double referral pay) / revert last_reward, satisfactionPending, banned.
        if ($chatType === 'private') {
            Database::updateUserColumns($userId, ['status' => 'main']);
        }
    }

    private function messageMentionsBot(array $message): bool
    {
        $entities = $message['entities'] ?? [];
        $text = $message['text'] ?? '';
        foreach ($entities as $e) {
            if (($e['type'] ?? '') === 'mention') {
                $mention = mb_substr($text, $e['offset'], $e['length']);
                $clean = preg_replace('/^@+/', '', $this->botUsername);
                if (mb_strtolower($mention) === mb_strtolower('@' . $clean)) return true;
            }
        }
        return false;
    }

    private function extractDisplayName(array $message): string
    {
        $from = $message['from'] ?? [];
        $first = trim((string)($from['first_name'] ?? ''));
        $last = trim((string)($from['last_name'] ?? ''));
        $username = trim((string)($from['username'] ?? ''));
        $name = '';
        if ($first !== '' && $last !== '') $name = $first . ' ' . $last;
        elseif ($first !== '') $name = $first;
        elseif ($last !== '') $name = $last;
        elseif ($username !== '') $name = '@' . $username;
        else $name = 'user';
        return sanitizeDisplayName($name);
    }

    /**
     * Handle /start joingame_<sessionId> — the friends-game invite flow.
     * The user (player2) opens the invite link, which sends them to the bot
     * with this payload. We join the game session and send the board to both
     * players.
     */
    private function handleJoinGameStart(int $userId, int $sessionId): void
    {
        $session = Database::getGameSession($sessionId);
        if ($session === null || $session['status'] !== 'waiting') {
            $this->bot->sendMessage($userId, emoji(10) . ' ' . $this->msg('game_cancelled'), mainMenuKeyboard());
            return;
        }
        // v2.9.44 (T19-3): gender gate — /start is exempt from the global
        // gender gate, so a genderless user could deep-link-join and become
        // player2 (fees debited from BOTH). Every other join path enforces
        // gender; this one now does too.
        $joiner = Database::loadUser($userId);
        if ((int)($joiner['gender'] ?? 0) === 0) {
            $botUsername = Database::getSetting('bot_username') ?? '';
            $cleanUsername = preg_replace('/^@+/', '', $botUsername);
            $kb = [];
            if ($cleanUsername !== '') {
                $kb[] = [['text' => 'استارت ربات و تنظیم جنسیت', 'url' => "https://t.me/{$cleanUsername}?start=1", 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(40)]];
            }
            $this->bot->sendMessage($userId, emoji(40) . ' ابتدا ربات را استارت کنید و جنسیت خود را تنظیم کنید.', json_encode(['inline_keyboard' => $kb]));
            return;
        }
        $result = Game::joinSession($sessionId, $userId, $this->bot, $this->config);
        if (!$result['success']) {
            $msg = $result['message'] ?? 'error_generic';
            // v2.9.44 (T19-8): specific alerts instead of a blanket
            // "بازی کنسل شده" for keys like game_just_filled / fee failures.
            $joinAlerts = [
                'insufficient_entry_fee' => emoji(10) . ' موجودی توکن بازی شما کافی نیست.',
                'opponent_insufficient_fee' => emoji(10) . ' حریف دیگر موجودی کافی ندارد. بازی لغو شد.',
                'game_just_filled' => emoji(10) . ' متأسفانه بازیکن دیگری زودتر از شما به بازی پیوست.',
                'already_started_or_finished' => emoji(10) . ' ' . $this->msg('game_cancelled'),
                'wrong_game_type' => emoji(10) . ' ' . $this->msg('game_cancelled'),
                'not_found' => emoji(10) . ' ' . $this->msg('game_cancelled'),
            ];
            if ($msg === 'already_in_game') {
                $this->bot->sendMessage($userId, ' شما در حال حاضر در یک بازی فعال هستید.', mainMenuKeyboard());
            } else {
                $this->bot->sendMessage($userId, $joinAlerts[$msg] ?? (emoji(10) . ' ' . $this->msg('game_cancelled')), mainMenuKeyboard());
            }
            return;
        }
        $session = $result['session'];
        $p1Id = (int)$session['player1_id'];
        $p2Id = $userId;

        // Player1 — send a NEW board message.
        $render1 = Game::renderBoardMessage($session, $p1Id, $this->config);
        $this->bot->sendMessage($p1Id, $render1['text'], $render1['keyboard']);

        // Player2 (the joiner) — send a NEW board message.
        $render2 = Game::renderBoardMessage($session, $p2Id, $this->config);
        $this->bot->sendMessage($p2Id, $render2['text'], $render2['keyboard']);
    }

    private function registerNewUser(int $userId, ?int $referralId, ?string $rewardType, string $displayName): void
    {
        $referralId = $referralId ?? (int)$this->config['referral']['default_referral_id'];
        $rewardType = $rewardType ?? 'toman';

        $userData = [
            'user_id' => $userId,
            'status' => 'main',
            'referralId' => $referralId,
            'gameTokens' => 0,
            'toman' => 0,
            'ntxBalance' => 0,
            'sub_count' => 0,
            'last_reward' => 0,
            'isFirstTime' => true,
            'displayName' => $displayName !== '' ? $displayName : 'user',
            'satisfactionPending' => false,
            'pendingWithdrawalId' => null,
        ];

        // vNEXT (G10): INSERT-FIRST registration. The old loadUser()===null →
        // saveUser() (upsert) check-then-act let a duplicate webhook (two rapid
        // /start taps) BOTH see "no row" and BOTH pay the referrer.
        // Database::createUser() returns true ONLY for the request that actually
        // inserted the row; the loser is treated as an existing user (welcome-
        // back path, NO referral payment).
        if (!Database::createUser($userData)) {
            $existing = Database::loadUser($userId);
            if ($existing === null) return; // insert won elsewhere but row not visible yet — safe bail
            if (!$this->checkMembership($userId, null)) return;
            $this->handleStartCommand($userId, $existing);
            return;
        }

        if (!$this->checkMembership($userId, null)) return;

        // vNEXT (11-a contract): consume the first-time flag ATOMICALLY and pay
        // the referrer ONLY when THIS request won the consume (G10 race harden).
        // vNEXT (G-B2): self-referral never pays (silent skip, same as the
        // existing admin-referral handling).
        $firstTimeWon = Database::consumeFirstTimeFlag($userId);
        if ($firstTimeWon) {
            if ($referralId !== (int)$this->config['referral']['admin_chat_id'] && $referralId !== $userId) {
                // T46-ap (owner): the referrer is notified the moment the
                // referral pays (was silent before).
                $paidAmount = Invite::processNewUserReferral($userId, $referralId, $rewardType, $this->config);
                if ($paidAmount > 0) {
                    Invite::notifyReferrer($referralId, $rewardType, $paidAmount, $this->config, $this->bot);
                }
                // v3.3 (T46-l): referral-count tasks auto-complete for the
                // referrer the moment the threshold is reached.
                try {
                    TaskSystem::onNewReferral($referralId, $this->bot, $this->config);
                } catch (\Throwable $e) {
                    error_log('[MessageHandler] referral task hook exception: ' . $e->getMessage());
                }
            }
            $userData['isFirstTime'] = false;
        }

        // v2.9.22: Send the rules message FIRST (before gender selection).
        // The user must read it for 15 seconds, then tap "تأیید خواندن قوانین".
        // That callback edits THIS message to show gender buttons.
        $this->sendRulesMessage($userId);
    }

    /**
     * v2.9.22: Send the rules/welcome message with a "confirm reading" button.
     * The button's callback_data includes the send timestamp so the handler
     * can enforce a 15-second reading delay.
     */
    private function sendRulesMessage(int $userId): void
    {
        // T46-j: the rules text is fully dynamic — pass the effective config so
        // every number (referral rewards, token/NTX rates, NTX lock, daily
        // reward range, withdrawal minimum) renders from the live settings.
        $text = getRulesText($this->config);
        $keyboard = rulesConfirmKeyboard(time());
        $this->bot->sendMessage($userId, $text, $keyboard);
    }

    private function handleStartCommand(int $userId, array $userData): void
    {
        // v2.9.44 (T19-5): targeted status write instead of full-row saveUser
        // (stale snapshot could resurrect admin-cleared flags).
        $userData['status'] = 'main';
        Database::updateUserColumns($userId, ['status' => 'main']);

        // v2.9.22: If gender is not set, show rules message (before gender selection).
        // This happens on EVERY /start until gender is set.
        if ((int)($userData['gender'] ?? 0) === 0) {
            $this->sendRulesMessage($userId);
            return;
        }

        // Gender already set — show main menu directly.
        // T46-c: shared legend text (explains what «شروع بازی» + referral do).
        $text = mainMenuText($this->config);
        $this->bot->sendMessage($userId, $text, mainMenuKeyboard());
    }

    /**
     * v3.2 (T42-e): the inline جاسوس card's «افزودن به گروه» deep-link
     * (t.me/<bot>?startgroup=spy) adds the bot to a group; the adding member's
     * client posts «/start spy» there and handleGroupCommand routes it here.
     * Mirrors CallbackHandler::handleGroupSpyStart but SENDS a new message
     * (there is no board message to edit yet). Fee starts at 0 — the group
     * sender can finish/end via the board; a config screen would need an
     * editable message, which this entry path doesn't have.
     */
    private function handleGroupSpyDeepLink(int $userId, int $chatId): 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->sendMessage($chatId, emoji(10) . ' شما در حال حاضر در یک بازی فعال هستید.');
            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, 0);
        } catch (\Throwable $e) {
            error_log('[spy deeplink] exception: ' . $e->getMessage());
            $this->bot->sendMessage($chatId, $this->msg('error_generic'));
            return;
        }
        if (empty($res['success'])) {
            $this->bot->sendMessage($chatId, emoji(10) . ' شما در حال حاضر در یک بازی فعال هستید.');
            return;
        }
        Database::setSetting('group_sender_' . $chatId, (string)$userId);
        $session = Database::getGameSession((int)$res['session']['id']);
        if ($session === null) return;
        $impl = Game::load('spy');
        $this->bot->sendMessage($chatId, $impl->getStatusText($session, -1, $this->config), $impl->renderKeyboard($session, -1));
    }

    /**
     * v2.9.18: Handle group commands. In groups, the bot shows a direct game
     * menu (no main menu). Only the sender can change settings and start games.
     * Commands: /start, /game, /dare, /tictactoe, /help, /resume, /close
     */
    private function handleGroupCommand(int $userId, int $chatId, string $text, string $displayName): void
    {
        // Ensure the user is registered
        $userData = Database::loadUser($userId);
        if ($userData === null) {
            // Auto-register the user in the group
            // vNEXT (G-B13): INSERT-only via Database::createUser — the old
            // saveUser upsert here could CLOBBER a row a concurrent private
            // /start just created (10-c finding 5: referral_id reset to
            // default, is_first_time re-armed, sub_count zeroed). On a lost
            // race fall back to the real row and continue the flow.
            $referralId = (int)$this->config['referral']['default_referral_id'];
            $newUserData = [
                'user_id' => $userId,
                'status' => 'main',
                'referralId' => $referralId,
                'gameTokens' => 0,
                'toman' => 0,
                'ntxBalance' => 0,
                'sub_count' => 0,
                'last_reward' => 0,
                'isFirstTime' => true,
                'displayName' => $displayName !== '' ? $displayName : 'user',
                'satisfactionPending' => false,
                'pendingWithdrawalId' => null,
            ];
            if (Database::createUser($newUserData)) {
                $userData = $newUserData;
            } else {
                // 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.
                $userData = Database::loadUser($userId) ?? $newUserData;
            }
        }

        // Check if banned
        if (!empty($userData['banned'])) {
            $this->bot->sendMessage($chatId, emoji(32) . ' شما از ربات بن شده‌اید.');
            return;
        }

        // Check gender — must be set before playing
        if ((int)($userData['gender'] ?? 0) === 0) {
            $botUsername = Database::getSetting('bot_username') ?? '';
            $cleanUsername = preg_replace('/^@+/', '', $botUsername);
            $kb = [];
            if ($cleanUsername !== '') {
                $kb[] = [['text' => 'استارت ربات و تنظیم جنسیت', 'url' => "https://t.me/{$cleanUsername}?start=1", 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(40)]];
            }
            $this->bot->sendMessage($chatId, emoji(40) . ' برای بازی در گروه، ابتدا ربات را استارت کنید و جنسیت خود را تنظیم کنید.', json_encode(['inline_keyboard' => $kb]));
            return;
        }

        // Update display name
        // v2.9.44 (T19-5): targeted write (see handle() — no full-row saveUser).
        if ($displayName !== '' && $displayName !== ($userData['displayName'] ?? '')) {
            $userData['displayName'] = $displayName;
            Database::updateUserColumns($userId, ['display_name' => $displayName]);
        }

        // ── /start or /game → show group game menu + set up commands ──
        // v3.2 (T42): /games is an alias of /game. /game now sends the SAME
        // category-first list as anonymous mode (gamePickerKeyboard already
        // powers it). Command sync also runs on /game so groups that only ever
        // used /game still get the up-to-date command menu (key bumped to
        // bot_commands_synced_v32 → one re-sync after deploy).
        // v3.2 (T42-e): the inline spy card's «افزودن به گروه» deep-link lands
        // here as «/start spy» (sent by the member who added the bot) → create
        // the group spy waiting session directly instead of the generic menu.
        if ($text === '/start' || str_starts_with($text, '/start ') || $text === '/game' || $text === '/games') {
            error_log('[T42d-flow] group /start|/game chat=' . $chatId . ' user=' . $userId . ' text=' . $text);
            // v2.9.44 (T19-1): the config-ownership marker is set ONLY when a
            // config/menu command actually runs. It used to be set for EVERY
            // group command, so any member typing /help silently stole the
            // ownership and the real setupper could no longer start games.
            Database::setSetting('group_sender_' . $chatId, (string)$userId);
            // v3.2 (T42-e): spy deep-link payload — bot just added via
            // t.me/<bot>?startgroup=spy.
            $startPayload = str_starts_with($text, '/start ') ? trim(substr($text, 7)) : '';
            if ($startPayload === 'spy') {
                $this->handleGroupSpyDeepLink($userId, $chatId);
                return;
            }
            // Set up bot commands for groups (once per lifetime-version)
            if ($text === '/start' || str_starts_with($text, '/start ') || $text === '/game' || $text === '/games') {
                // vNEXT (G9): setupCommands is 2 API calls — run it once per
                // lifetime, not on EVERY group command. v3.2: key bumped so the
                // updated command descriptions re-sync exactly once.
                if (!Database::getSetting('bot_commands_synced_v32')) {
                    $this->bot->setupCommands();
                    Database::setSetting('bot_commands_synced_v32', '1');
                }
            }
            $text2 = emoji(40) . ' یک بازی را برای شروع در گروه انتخاب کنید:';
            $this->bot->sendMessage($chatId, $text2, groupGameMenuKeyboard());
            return;
        }

        // ── /dare → start TOD config directly ──
        if ($text === '/dare') {
            // v2.9.44 (T19-1): only /dare transfers config ownership.
            Database::setSetting('group_sender_' . $chatId, (string)$userId);
            $defaultFee = (int)($this->config['game']['tod_default_fee'] ?? 1);
            $defaultMax = (int)($this->config['game']['tod_default_max_changes'] ?? 3);
            $text2 = emoji(0) . " جرات یا حقیقت\n\n" .
                emoji(20) . " جریمه هر تغییر اضافی و " . emoji(13) . " تعداد تغییر مجاز را انتخاب کنید:\n\n" .
                "۰ = بازی رایگان (تغییر نامحدود رایگان)\n" .
                "بیشتر از ۰ = هر تغییر بیش از حد مجاز، جریمه می‌شود و توکن بازی بین بقیه بازیکنان تقسیم می‌شود\n" .
                "تعداد تغییر مجاز = تعداد دفعاتی که هر بازیکن می‌تواند سوال را رایگان تغییر دهد";
            $this->bot->sendMessage($chatId, $text2, gameTODConfigKeyboard('group', 'truthordare', $defaultFee, $defaultMax));
            return;
        }

        // ── /tictactoe → start TTT config directly ──
        if ($text === '/tictactoe') {
            // v2.9.44 (T19-1): only /tictactoe transfers config ownership.
            Database::setSetting('group_sender_' . $chatId, (string)$userId);
            // vNEXT (14-b/U10b retry): the panel's game_tictactoe_entry_fee is
            // the stepper's default starting value here too (was hardcoded 0 —
            // same wiring as the CallbackHandler steppers). Default 0 when
            // unset; clamped to the stepper's range.
            // T46-k: Game::FEE_MAX clamp + the explanation renders LIVE from
            // the shared dynamic block with THIS default as the worked example.
            $defaultFee = (int)(Settings::getSetting('game_tictactoe_entry_fee') ?? '0');
            $defaultFee = max(0, min(Game::FEE_MAX, $defaultFee));
            $text2 = emoji(0) . ' هزینه هر دور و تعداد دور را انتخاب کنید:' . "\n\n" .
                Game::feeEconomicsBlock(false, $this->config, true, $defaultFee, 1);
            $this->bot->sendMessage($chatId, $text2, gameEntryFeeKeyboard('group', 'tictactoe', $defaultFee, 1));
            return;
        }

        // ── /help → show help ──
        // v3.2 (T42): two messages — guide (commands + rules) and the FULL
        // games list with a meaningful one-line description of EVERY game
        // (Game::gamesHelpText, all custom emoji). (/games is handled above.)
        if ($text === '/help') {
            $text2 = emoji(12) . " <b>راهنمای بازی در گروه</b>\n" .
                "━━━━━━━━━━━━━━━━━━\n\n" .
                emoji(40) . " <b>کامندها:</b>\n" .
                "/game — نمایش لیست کامل بازی‌ها (همان لیست حالت ناشناس)\n" .
                "/tictactoe — شروع مستقیم بازی دوز\n" .
                "/dare — شروع مستقیم بازی جرات یا حقیقت\n" .
                "/resume — بازگرداندن صفحه بازی فعلی\n" .
                "/close — درخواست لغو بازی فعلی\n" .
                "/help — این راهنما\n\n" .
                emoji(13) . " <b>قوانین:</b>\n" .
                emoji(42) . " فقط فرستنده بازی می‌تونه تنظیمات رو تغییر بده و بازی رو شروع کنه\n" .
                emoji(42) . " سایر اعضا می‌تونن به بازی بپیوندن\n" .
                emoji(42) ." تا وقتی در صف انتظار هستید می‌تونید در چند بازی ثبت‌نام کنید، ولی وقتی یکی از بازی‌ها شروع شد تا پایانش نمی‌تونید به بازی دیگه‌ای بپیوندید\n" .
                emoji(42) . " می‌تونید چندین بازی همزمان در گروه داشته باشید (هر کدوم با بازیکنان متفاوت)\n\n" .
                emoji(0) . " همه‌ی بازی‌ها تنظیمات دارند (هزینه هر دور، تعداد دور و…) و فقط فرستنده بازی می‌تواند تنظیمات را عوض کند و بازی را شروع کند\n" .
                emoji(0) . " اگر /close بزنی، حریف یا اعضای بازی تگ می‌شوند و لغو بازی تأیید می‌شود\n\n" .
                emoji(20) . " توضیحات همه‌ی بازی‌ها در پیام بعدی آمده است";
            $this->bot->sendMessage($chatId, $text2);
            // v3.2 (T42): full games list in 2 parts (Telegram 4096-char limit).
            $this->bot->sendMessage($chatId, Game::gamesHelpText(1));
            $this->bot->sendMessage($chatId, Game::gamesHelpText(2));
            return;
        }

        // ── /resume → resume active group game ──
        if ($text === '/resume') {
            $session = Game::getActiveSessionForUser($userId, 'group');
            if ($session !== null) {
                $render = Game::renderGroupBoardMessage($session, $this->config);
                $this->bot->sendMessage($chatId, $render['text'], $render['keyboard']);
            } else {
                $this->bot->sendMessage($chatId, emoji(10) . ' بازی فعالی برای بازیابی وجود ندارد.');
            }
            return;
        }

        // ── /close → cancel active group game ──
        if ($text === '/close') {
            $session = Game::getActiveSessionForUser($userId, 'group');
            if ($session === null) {
                $this->bot->sendMessage($chatId, emoji(10) . ' بازی فعالی برای لغو وجود ندارد.');
                return;
            }

            $gameType = (string)($session['game_type'] ?? 'tictactoe');
            $sessionId = (int)$session['id'];

            if ($session['status'] === 'waiting') {
                // v3.3 (T45): owner cancels the queue; TOD joiners leave it
                // individually; anyone else gets honest feedback (group boards
                // no longer carry «خروج از صف» — inline-parity, T45).
                $isOwner = (Game::sessionOwnerId($session) === $userId);
                $isTodJoiner = ($gameType === 'truthordare' && Database::isPlayerInGame($sessionId, $userId));
                if (!$isOwner && !$isTodJoiner) {
                    $this->bot->sendMessage($chatId, emoji(10) . ' فقط مالک بازی می‌تواند صف را لغو کند.');
                    return;
                }
                // Waiting game — just cancel it directly
                Game::cancelWaitingSession($sessionId, $userId, $this->bot, $this->config, 0);
                return;
            }

            // v2.9.22: For TOD games — ask creator "are you sure?" via button
            if ($gameType === 'truthordare') {
                // v3.3 (T45): EFFECTIVE owner (ownership transfer aware).
                $isCreator = (Game::sessionOwnerId($session) === $userId);
                if (!$isCreator) {
                    $this->bot->sendMessage($chatId, emoji(10) . ' فقط مالک بازی می‌تواند آن را پایان دهد.');
                    return;
                }
                $kb = json_encode(['inline_keyboard' => [[
                    ['text' => 'بله، بازی رو تمام کن', 'callback_data' => "tod_force_end_{$sessionId}", 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
                    ['text' => 'خیر، ادامه بده', 'callback_data' => 'noop', 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(40)],
                ]]]);
                $this->bot->sendMessage($chatId, emoji(12) . ' آیا مطمئن هستید که می‌خواهید بازی جرات یا حقیقت را پایان دهید؟', $kb);
                return;
            }

            // TTT: tag opponent and send a new message with cancel request
            $p1Id = (int)$session['player1_id'];
            $p2Id = (int)($session['player2_id'] ?? 0);
            $opponentId = ($userId === $p1Id) ? $p2Id : $p1Id;
            $userLink = "<a href=\"tg://user?id={$userId}\">کاربر</a>";

            // Send a new message in the group with the cancel request button
            $kb = json_encode(['inline_keyboard' => [[
                ['text' => 'درخواست لغو بازی', 'callback_data' => "gcancelreq_{$sessionId}", 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
            ]]]);
            $cancelText = emoji(32) . " {$userLink} درخواست لغو بازی رو داد. روی دکمه زیر بزنید تا درخواست لغو به حریف ارسال شود:";
            $this->bot->sendMessage($chatId, $cancelText, $kb, 'HTML');
            return;
        }
    }

    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 msg(string $key): string
    {
        return Settings::getMessage($key, $this->config);
    }

    // ─── Task answer handler ────────────────────────────────────────

    private function handleTaskAnswer(int $userId, int $taskId, string $text, string $displayName): void
    {
        // vNEXT: empty answer (media-only message) — ask for text and KEEP the
        // waiting_task_ state (no empty submission, no state clear).
        if (trim($text) === '') {
            $this->bot->sendMessage($userId, 'لطفاً متن پیام را بنویسید.');
            return;
        }
        // v2.9.45 (R6-93/LOW): targeted status write (full-row saveUser on a
        // stale snapshot could revert a concurrent claim/ban/flag change).
        Database::updateUserColumns($userId, ['status' => 'main']);
        TaskSystem::submitTextAnswer($userId, $taskId, $text, null, $this->bot, $this->config);
    }

    // ─── Withdrawal flow handlers ───────────────────────────────────

    /**
     * vNEXT: Normalize Persian (۰-۹) and Arabic-Indic (٠-٩) digits to ASCII
     * and strip thousands separators (',' '،' '٬' and spaces) so amounts
     * typed on Persian keyboards parse correctly instead of becoming 0.
     */
    private function normalizeDigits(string $input): string
    {
        return strtr($input, [
            '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
            '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
            '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
            '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
            ',' => '', '،' => '', '٬' => '', ' ' => '',
        ]);
    }

    /**
     * T46-ao (owner): honest «موجودی کافی نیست» message — shows the REAL
     * balance plus the owner's three actions as buttons: withdraw the
     * available balance (wdquick), games, cash referrals.
     */
    private function sendInsufficientBalance(int $userId): void
    {
        $bal = Wallet::balance($userId, 'toman');
        $minToman = (int)($this->config['withdrawal']['min_toman'] ?? 30000);
        $this->bot->sendMessage(
            $userId,
            emoji(10) . ' ' . interpolate($this->msg('withdraw_insufficient'), $bal)
                . "\n" . emoji(42) . ' یا همین مقدار موجودی‌ات را برداشت کن، یا با بازی و زیرمجموعه‌گیری نقدی موجودی‌ات را بیشتر کن:',
            insufficientBalanceKeyboard($bal, $minToman)
        );
    }

    private function handleWithdrawalAmount(int $userId, array $userData, string $text, string $displayName): void
    {
        $amount = (int)preg_replace('/[^0-9]/', '', $this->normalizeDigits($text));
        // vNEXT: reject empty/invalid AND absurd amounts (sane cap 500,000,000)
        if ($amount <= 0 || $amount > 500000000) {
            $this->bot->sendMessage($userId, $this->msg('error_invalid_input'), backToMainMenuKeyboard());
            return;
        }
        // T46-ao (owner): the minimum is admin-configurable
        // (پنل مدیریت → تنظیمات → withdrawal_min_toman).
        $minToman = (int)($this->config['withdrawal']['min_toman'] ?? 30000);
        if ($amount < $minToman) {
            $this->bot->sendMessage($userId, sprintf(' حداقل مبلغ برداشت: %d تومان', $minToman), backToMainMenuKeyboard());
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            return;
        }
        // T46-ao (owner): balance check BEFORE the card-info step — the user
        // hears «موجودی‌ات کم است» HERE, not after typing card details.
        if (Wallet::balance($userId, 'toman') < $amount) {
            $this->sendInsufficientBalance($userId);
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            return;
        }
        // Store the amount in bot_settings, move to card info input
        Database::setSetting('pending_wdraw_amount_' . $userId, (string)$amount);
        $userData['status'] = 'waiting_withdrawal_card_info';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'waiting_withdrawal_card_info']);
        $this->bot->sendMessage($userId, emoji(47) . ' ' . $this->msg('withdraw_prompt_card'), backToMainMenuKeyboard());
    }

    private function handleWithdrawalCardInfo(int $userId, array $userData, string $text, string $displayName): void
    {
        $amount = (int)(Database::getSetting('pending_wdraw_amount_' . $userId) ?? '0');
        Database::deleteSetting('pending_wdraw_amount_' . $userId);
        $userData['status'] = 'main';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'main']);

        if ($amount <= 0 || trim($text) === '') {
            $this->bot->sendMessage($userId, $this->msg('error_invalid_input'), backToMainMenuKeyboard());
            return;
        }

        try {
            $wid = Withdrawal::createCardRequest($userId, $amount, $text, $this->config, $displayName);
            $this->bot->sendMessage($userId, interpolate($this->msg('withdraw_created'), $wid), backToMainMenuKeyboard());
        } catch (\RuntimeException $e) {
            $msgKey = $e->getMessage();
            if ($msgKey === 'withdraw_insufficient') {
                // T46-ao (owner): honest balance + action buttons on the deep guard too.
                $this->sendInsufficientBalance($userId);
            } else {
                $this->bot->sendMessage($userId, $this->msg('error_generic'), backToMainMenuKeyboard());
            }
        }
    }

    private function handleStarsAmount(int $userId, array $userData, string $text, string $displayName): void
    {
        // T46-ap (owner): the stars flow is STARS-denominated now — the user
        // types a STAR COUNT; the toman hold = stars × the admin-set per-star
        // rate. Minimum is withdrawal_min_star (admin-editable, default 50).
        $stars = (int)preg_replace('/[^0-9]/', '', $this->normalizeDigits($text));
        $minStar = max(1, (int)($this->config['withdrawal']['min_star'] ?? 50));
        $rate = max(1, (int)($this->config['withdrawal']['star_toman_value'] ?? 10000));
        // vNEXT: reject absurd amounts (sane cap 1,000,000 stars)
        if ($stars <= 0 || $stars > 1000000) {
            $this->bot->sendMessage($userId, $this->msg('error_invalid_input'), backToMainMenuKeyboard());
            return;
        }
        if ($stars < $minStar) {
            $this->bot->sendMessage($userId, emoji(10) . ' حداقل برداشت استارز: ' . number_format($minStar) . ' استارز', backToMainMenuKeyboard());
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            return;
        }
        $costToman = $stars * $rate;
        if (Wallet::balance($userId, 'toman') < $costToman) {
            // T46-ao (owner): honest balance + action buttons (same as card flow).
            $this->sendInsufficientBalance($userId);
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            return;
        }
        Database::setSetting('pending_stars_amount_' . $userId, (string)$stars);
        $userData['status'] = 'waiting_stars_message';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'waiting_stars_message']);
        $this->bot->sendMessage(
            $userId,
            emoji(46) . ' ' . number_format($stars) . ' استارز (معادل ' . number_format($costToman) . " تومان) ثبت شد.\n"
                . emoji(46) . ' ' . $this->msg('withdraw_prompt_stars'),
            backToMainMenuKeyboard()
        );
    }

    private function handleStarsMessage(int $userId, array $userData, string $text, string $displayName): void
    {
        $amount = (int)(Database::getSetting('pending_stars_amount_' . $userId) ?? '0');
        Database::deleteSetting('pending_stars_amount_' . $userId);
        $userData['status'] = 'main';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'main']);

        if ($amount <= 0 || trim($text) === '') {
            $this->bot->sendMessage($userId, $this->msg('error_invalid_input'), backToMainMenuKeyboard());
            return;
        }

        try {
            $wid = Withdrawal::createStarsRequest($userId, $amount, $text, $this->config, $displayName);
            $this->bot->sendMessage($userId, interpolate($this->msg('withdraw_created'), $wid), backToMainMenuKeyboard());
        } catch (\RuntimeException $e) {
            $msgKey = $e->getMessage();
            if ($msgKey === 'withdraw_insufficient') {
                // T46-ao (owner): honest balance + action buttons on the deep guard too.
                $this->sendInsufficientBalance($userId);
            } elseif ($msgKey === 'withdraw_min') {
                // T46-ap: stars flow — the minimum is in STARS now.
                $minStar = max(1, (int)($this->config['withdrawal']['min_star'] ?? 50));
                $this->bot->sendMessage($userId, emoji(10) . ' حداقل برداشت استارز: ' . number_format($minStar) . ' استارز', backToMainMenuKeyboard());
            } else {
                $this->bot->sendMessage($userId, $this->msg('error_generic'), backToMainMenuKeyboard());
            }
        }
    }

    private function handleWithdrawalUserReply(int $userId, array $userData, string $text): void
    {
        // vNEXT (G6): cap user-supplied text (support path already caps) so
        // echoes/summaries never blow Telegram's 4096-char limit.
        $text = mb_substr($text, 0, 4000);
        $wid = (int)(Database::getSetting('active_wdraw_' . $userId) ?? '0');
        if ($wid <= 0) {
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            return;
        }
        $w = Database::getWithdrawal($wid);
        if (!$w || (int)$w['user_id'] !== $userId) {
            $userData['status'] = 'main';
            // T46-p: targeted write — never round-trip the stale snapshot
            Database::updateUserColumns($userId, ['status' => 'main']);
            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 → text-reply 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($wid, $userId);
            if (!$reopen['ok']) {
                $error = (string)($reopen['error'] ?? '');
                if ($error === 'not_found') {
                    $this->bot->sendMessage($userId, 'درخواست یافت نشد.', backToMainMenuKeyboard());
                } elseif ($error === 'not_reopenable') {
                    $this->bot->sendMessage($userId, 'این درخواست در وضعیت فعلی قابل بازگشایی نیست.', backToMainMenuKeyboard());
                } else {
                    // Persian insufficient-funds message (or any future
                    // Persian error) — show it verbatim.
                    $this->bot->sendMessage($userId, $error !== '' ? $error : $this->msg('error_generic'), backToMainMenuKeyboard());
                }
                $userData['status'] = 'main';
                // T46-p: targeted write — never round-trip the stale snapshot
                Database::updateUserColumns($userId, ['status' => 'main']);
                return;
            }
        }
        $msgId = Database::addWithdrawalMessage($wid, 'user', $userId, $userData['displayName'] ?? 'user', 'text', $text);
        $userData['status'] = 'main';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'main']);

        // Send the conversation summary to the user
        $adminName = $w['admin_name'] ?? 'پشتیبانی';
        $summary = Withdrawal::buildConversationSummary($wid, $adminName, $this->config);
        $summary .= "\n────────────\n\n" . $this->msg('withdraw_msg_sent');
        // vNEXT (G6): Telegram's hard limit is 4096 chars — clamp long
        // conversations so the send never 400s and silently loses the message.
        $summary = mb_substr($summary, 0, 3900);
        $result = $this->bot->sendMessage($userId, $summary, withdrawalAnswerKeyboard($adminName, $wid));
        if ($result && isset($result->result->message_id)) {
            Database::updateWithdrawalMessageTgId($msgId, (int)$result->result->message_id, $userId);
        }

        // Notify the admin who claimed the withdrawal
        if (!empty($w['admin_id'])) {
            // vNEXT (23-a): user text goes out in Markdown mode (converted via
            // markdownToHtml tag-protection) — neutralize it so a withdrawal
            // reply can never plant a live <a>/<b> tag or [label](url) link
            // in the ADMIN's chat. displayName is DB-sanitized (G5).
            $echoText = neutralizeUserEcho($text);
            $notifyText = sprintf(" پاسخ کاربر به برداشت #%d\n\nکاربر: %s\n\n> %s", $wid, $userData['displayName'] ?? 'user', $echoText);
            $kb = json_encode(['inline_keyboard' => [
                [
                    ['text' => ' پاسخ', 'callback_data' => 'wdrawanswer_' . $wid, 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(24)],
                    ['text' => ' برگشت به منوی اصلی', 'callback_data' => 'back_to_main_menu', 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(11)],
                ],
            ]]);
            $this->bot->sendMessage((int)$w['admin_id'], $notifyText, $kb);
        }
    }

    private function handleWithdrawalAdminReply(int $adminChatId, int $withdrawalId, string $text, array $message): void
    {
        $w = Database::getWithdrawal($withdrawalId);
        if (!$w || !in_array($w['status'], ['claimed', 'answered', 'open'], true)) {
            Database::deleteSetting('pending_wdraw_reply_' . $adminChatId);
            $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; }
        }
        $adminName = $adminInfo['display_name'] ?? ($w['admin_name'] ?? 'پشتیبانی');

        if ($w['status'] === 'open') {
            Database::claimWithdrawal($withdrawalId, $adminChatId, $adminName);
        }

        $msgId = Database::addWithdrawalMessage($withdrawalId, 'admin', $adminChatId, $adminName, 'text', $text);
        Database::answerWithdrawal($withdrawalId, $text);
        Database::deleteSetting('pending_wdraw_reply_' . $adminChatId);

        $userId = (int)$w['user_id'];
        // vNEXT (G6/G13): the echo text is user-bound and the template is
        // admin-editable — cap the echo (stray huge admin input) and replace
        // the placeholder manually so a stray '%' in the template can't
        // corrupt the output (sprintf would misparse it).
        $userEchoText = mb_substr($text, 0, 3000);
        $answerText = str_replace('%s', $userEchoText, $this->msg('withdraw_answered'));
        $result = $this->bot->sendMessage($userId, $answerText, withdrawalAnswerKeyboard($adminName, $withdrawalId));
        if ($result && isset($result->result->message_id)) {
            Database::updateWithdrawalMessageTgId($msgId, (int)$result->result->message_id, $userId);
        }

        // vNEXT (G6): best-effort send-result check — tell the admin when the
        // user echo actually failed instead of claiming success.
        if ($result && isset($result->ok) && $result->ok === true) {
            $this->bot->sendMessage($adminChatId, ' پاسخ ارسال شد.', backToMainMenuKeyboard());
        } else {
            $this->bot->sendMessage($adminChatId, 'ارسال پاسخ ناموفق بود.', backToMainMenuKeyboard());
        }
    }

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

    private function handleSupportMessage(int $userId, array $userData, string $text, string $displayName): void
    {
        // vNEXT: empty message (photo/video WITHOUT caption) — ask for text and
        // KEEP the waiting state; do NOT create an empty ticket / admin broadcast.
        if (trim($text) === '') {
            $this->bot->sendMessage($userId, 'لطفاً متن پیام را بنویسید.');
            return;
        }
        $text = mb_substr($text, 0, 4000);
        $userData['status'] = 'main';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'main']);

        $ticketId = Database::createSupportTicket($userId, $text, $displayName);
        $ticket = Database::getTicket($ticketId);
        $openTime = $ticket ? date('Y-m-d H:i', strtotime($ticket['question_time'])) : '';

        // vNEXT (23-a): the ticket text is echoed below (user + ADMIN notify)
        // in Markdown mode (converted via markdownToHtml tag-protection) —
        // neutralize it so a ticket can never plant a live <a>/<b> tag or
        // [label](url) link in the recipient's chat.
        $echoText = neutralizeUserEcho($text);
        $confirm = " تیکت #{$ticketId}\n زمان باز شدن: {$openTime}\n\n پیام شما:\n{$echoText}\n\n" . $this->msg('support_msg_sent');
        $kb = json_encode(['inline_keyboard' => [[[
            'text' => ' برگشت به پشتیبانی', 'callback_data' => 'support', 'style' => 'danger',
            'icon_custom_emoji_id' => emoji_id(11),
        ]]]]);
        $this->bot->sendMessage($userId, $confirm, $kb);

        // Notify admins
        $admins = Database::getAdminsWithPermission('support');
        $notifyText = " تیکت پشتیبانی جدید #{$ticketId}\n\nکاربر: {$displayName}\nشناسه: {$userId}\n\n> {$echoText}";
        $kbAdmin = json_encode(['inline_keyboard' => [[[
            'text' => ' پاسخ دادن', 'callback_data' => "support_answer_{$ticketId}", 'style' => 'success',
            'icon_custom_emoji_id' => emoji_id(24),
        ]]]]);
        foreach ($admins as $admin) {
            $this->bot->sendMessage((int)$admin['user_id'], $notifyText, $kbAdmin);
            usleep(35000);
        }
        Database::logEvent('support_ticket', $userId, ['ticket_id' => $ticketId]);
    }

    private function handleSupportReply(int $adminChatId, int $ticketId, string $text): void
    {
        $ticket = Database::getTicket($ticketId);
        if (!$ticket || !in_array($ticket['status'], ['claimed', 'answered'], true)) {
            Database::deleteSetting('pending_support_reply_' . $adminChatId);
            $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; }
        }
        $adminName = $adminInfo['display_name'] ?? ($ticket['admin_name'] ?? 'پشتیبانی');

        $msgId = Database::addSupportMessage($ticketId, 'admin', $adminChatId, $adminName, 'text', $text);
        Database::answerTicket($ticketId, $text);
        Database::deleteSetting('pending_support_reply_' . $adminChatId);

        $userId = (int)$ticket['user_id'];
        // vNEXT (G6/G13): see handleWithdrawalAdminReply — cap echo + manual
        // placeholder replacement for the admin-editable template.
        $userEchoText = mb_substr($text, 0, 3000);
        $answerText = str_replace('%s', $userEchoText, $this->msg('support_answered'));
        $result = $this->bot->sendMessage($userId, $answerText, supportAnswerKeyboard($adminName, $ticketId));
        if ($result && isset($result->result->message_id)) {
            Database::updateSupportMessageTgId($msgId, (int)$result->result->message_id, $userId);
        }

        // vNEXT (G6): best-effort send-result check (see handleWithdrawalAdminReply).
        if ($result && isset($result->ok) && $result->ok === true) {
            $this->bot->sendMessage($adminChatId, ' پاسخ ارسال شد.', backToMainMenuKeyboard());
        } else {
            $this->bot->sendMessage($adminChatId, 'ارسال پاسخ ناموفق بود.', backToMainMenuKeyboard());
        }
        Database::logEvent('support_answered', $userId, ['ticket_id' => $ticketId]);
    }

    private function handleSupportUserReply(int $userId, array $userData, string $text): void
    {
        $text = mb_substr($text, 0, 4000);
        $ticketId = (int)(Database::getSetting('active_support_ticket_' . $userId) ?? '0');
        $ticket = $ticketId > 0 ? Database::getTicket($ticketId) : null;
        if (!$ticket || (int)$ticket['user_id'] !== $userId) {
            $this->handleSupportMessage($userId, $userData, $text, $userData['displayName'] ?? 'user');
            return;
        }
        if ($ticket['status'] === 'closed') {
            Database::reopenTicket($ticketId);
        }
        $msgId = Database::addSupportMessage($ticketId, 'user', $userId, $userData['displayName'] ?? 'user', 'text', $text);
        $userData['status'] = 'main';
        // T46-p: targeted write — never round-trip the stale snapshot
        Database::updateUserColumns($userId, ['status' => 'main']);

        $adminName = $ticket['admin_name'] ?? 'پشتیبانی';
        $summary = " تیکت #{$ticketId}\n زمان باز شدن: " . date('Y-m-d H:i', strtotime($ticket['question_time'])) . "\n پاسخ‌دهنده: {$adminName}\n تعداد پیام‌ها: " . count(Database::getSupportMessages($ticketId)) . "\n\n────────────\n\n";
        $summary .= $this->msg('support_msg_sent');
        // vNEXT (G6): clamp summary to Telegram's 4096-char hard limit.
        $summary = mb_substr($summary, 0, 3900);
        $result = $this->bot->sendMessage($userId, $summary, supportAnswerKeyboard($adminName, $ticketId));
        if ($result && isset($result->result->message_id)) {
            Database::updateSupportMessageTgId($msgId, (int)$result->result->message_id, $userId);
        }

        // Notify admin
        if (!empty($ticket['admin_id'])) {
            // v2.9.45 (R6-90/MEDIUM): the raw reply went to the ADMIN chat with
            // Markdown parse mode — '[label](https://evil)' rendered as a LIVE
            // link there (same class as the fixed ticket/withdrawal echoes).
            $safeReply = function_exists('neutralizeUserEcho') ? neutralizeUserEcho((string)$text) : (string)$text;
            $notifyText = " پاسخ کاربر به تیکت #{$ticketId}\n\nکاربر: " . ($userData['displayName'] ?? 'user') . "\n\n> {$safeReply}";
            $kb = json_encode(['inline_keyboard' => [[[
                'text' => ' پاسخ', 'callback_data' => "support_answer_{$ticketId}", 'style' => 'success',
                'icon_custom_emoji_id' => emoji_id(24),
            ]]]]);
            $this->bot->sendMessage((int)$ticket['admin_id'], $notifyText, $kb);
        }
    }
}
