<?php

declare(strict_types=1);

class TelegramBot
{
    private string $apiUrl;
    private string $botToken;

    /**
     * v2.9.42: Telegram allows exactly ONE answerCallbackQuery per
     * callback_query_id — later calls fail with QUERY_ID_INVALID.
     * Track the ids already answered during this request so duplicate
     * answerCallback() calls get a synthetic ok response instead of
     * hitting the API.
     */
    private static array $answeredCallbackIds = [];

    public function __construct(string $botToken)
    {
        $this->botToken = $botToken;
        $this->apiUrl = self::apiBase() . "/bot{$botToken}/";
    }

    /** T46-aq: release the reused curl handle when the bot instance dies. */
    public function __destruct()
    {
        if ($this->curlHandle !== null) {
            curl_close($this->curlHandle);
            $this->curlHandle = null;
        }
    }

    /** v2.9.45 (R6-76): per-request curl timeout override (seconds).
     *  T46-aq: default 15->10 — api.telegram.org RTT is ~15-90ms from this
     *  host; 10s still absorbs 100x degradation without pinning a worker. */
    private int $timeoutSeconds = 10;

    /** T46-ar: cached API-origin override (bot_settings api_base). */
    private static ?string $apiBaseCache = null;

    /**
     * T46-ar: single switch point for the API origin. Default (and current
     * live behaviour) is the official cloud API. Setting bot_settings
     * api_base = e.g. "http://VPS-IP:8081" routes EVERY TelegramBot call —
     * including file downloads and the getChatMember batch — to a self-hosted
     * Local Bot API server without touching config.php (validated http(s)
     * URL only; any invalid/absent value falls back to the official API).
     */
    private static function apiBase(): string
    {
        if (self::$apiBaseCache === null) {
            $b = '';
            try {
                if (class_exists('Settings')) {
                    $b = trim((string)(Settings::getSetting('api_base') ?? ''));
                }
            } catch (\Throwable $t) {
                $b = '';
            }
            if ($b !== '' && !preg_match('#^https?://#i', $b)) {
                $b = '';
            }
            self::$apiBaseCache = ($b !== '' ? rtrim($b, '/') : 'https://api.telegram.org');
        }
        return self::$apiBaseCache;
    }

    /**
     * T46-aq: per-instance reused curl handle — sequential API calls inside
     * one update (edit + answerCallback + …) no longer redo the TLS
     * handshake. Closed in __destruct().
     */
    private $curlHandle = null;

    /**
     * v2.9.45 (R6-76): raise the curl timeout for the NEXT send(s) on this
     * instance — used by the DB-backup upload (sendDocument of a dump up to
     * 45MB cannot fit the 15s default; the abort made cron retry a full
     * mysqldump+upload every single minute for hours).
     */
    public function withTimeout(int $seconds): self
    {
        $this->timeoutSeconds = max(1, $seconds);
        return $this;
    }

    public function send(string $method, array $params = []): ?object
    {
        $url = $this->apiUrl . $method;

        // v2.9.5: Pre-encode `results` and `media` to JSON strings BEFORE
        // processParseModeRecursive runs. This prevents the recursive
        // function from descending into the results array and accidentally
        // modifying fields like `message_text` or `parse_mode` inside
        // InlineQueryResult objects (which would corrupt the inline menu).
        if (isset($params['results']) && is_array($params['results'])) {
            $encoded = json_encode($params['results'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if ($encoded === false) {
                error_log('[TelegramBot::send] json_encode failed for results: ' . json_last_error_msg());
                $params['results'] = '[]';
            } else {
                $params['results'] = $encoded;
            }
        } elseif (isset($params['results']) && is_string($params['results'])) {
            // Already a JSON string — validate it's decodable; if not, replace with []
            $decoded = json_decode($params['results'], true);
            if (!is_array($decoded)) {
                error_log('[TelegramBot::send] results string is not valid JSON, replacing with []');
                $params['results'] = '[]';
            }
            // Keep it as a string — don't decode and re-encode (avoids corruption)
        }

        if (isset($params['media']) && is_string($params['media'])) {
            $decoded = json_decode($params['media'], true);
            if (is_array($decoded)) {
                // keep as string for now, processParseModeRecursive won't touch strings
            }
        }

        // processParseModeRecursive only modifies arrays that contain a
        // 'parse_mode' key. Since `results` is now a JSON string (not an
        // array), it won't be touched. This is critical for answerInlineQuery.
        self::processParseModeRecursive($params);

        // media needs to be encoded if it's still an array
        if (isset($params['media']) && is_array($params['media'])) {
            $params['media'] = json_encode($params['media']);
        }

        // T46-aq: reuse one handle per bot instance — sequential sends inside
        // one update no longer redo the TLS handshake. CONNECTTIMEOUT 10->4:
        // a hung connect must not pin the worker (the update is already
        // 200-acked to Telegram since the T46-aq early-ack in index.php).
        if ($this->curlHandle === null) {
            $this->curlHandle = curl_init();
            curl_setopt_array($this->curlHandle, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 4,
            ]);
        }
        curl_setopt_array($this->curlHandle, [
            CURLOPT_URL            => $url,
            CURLOPT_POSTFIELDS     => $params,
            CURLOPT_TIMEOUT        => $this->timeoutSeconds,
        ]);

        $res = curl_exec($this->curlHandle);

        if (curl_error($this->curlHandle)) {
            error_log('TelegramBot cURL Error: ' . curl_error($this->curlHandle) . ' (method=' . $method . ')');
        }

        // T46-p: curl_exec returns FALSE on transport failure — under
        // strict_types json_decode(false) is a TypeError that killed the
        // rest of the update's handler (and turned every backup send that
        // hit a network blip into a crash-retry loop). Decode only strings;
        // non-2xx/HTML bodies decode to null below as before.
        $result = is_string($res) && $res !== '' ? json_decode($res) : null;

        // Log answerInlineQuery failures specifically (for inline mode debugging)
        if ($method === 'answerInlineQuery' && ($result === null || (isset($result->ok) && $result->ok === false))) {
            error_log('[TelegramBot::send] answerInlineQuery FAILED — response: ' . substr((string)$res, 0, 500));
        }

        // v3.2 (T42-d): log EVERY other ok=false API response. Until now only
        // cURL-level errors and answerInlineQuery were logged, so Telegram 400s
        // (e.g. "expected an Array of InlineKeyboardButton" from the malformed
        // game-picker keyboards — the whole /game menu tree was silently dead
        // for days) vanished without a trace. Two exemptions keep the log sane:
        //   - answerInlineQuery (already logged above),
        //   - "message is not modified" (benign double-tap / no-op edits).
        if ($method !== 'answerInlineQuery' && ($result === null || (isset($result->ok) && $result->ok === false))) {
            $failDesc = is_object($result) && isset($result->description) ? (string)$result->description : 'non-JSON response';
            if (stripos($failDesc, 'message is not modified') === false) {
                error_log('[TelegramBot::send] ' . $method . ' FAILED' .
                    (is_object($result) && isset($result->error_code) ? ' (' . (int)$result->error_code . ')' : '') .
                    ' — ' . substr($failDesc, 0, 300));
            }
        }

        return $result;
    }

    public function sendMessage(int $chatId, string $text, ?string $replyMarkup = null, string $parseMode = 'Markdown'): ?object
    {
        $params = [
            'chat_id'    => $chatId,
            'text'       => $text,
        ];

        if ($parseMode !== '') {
            $params['parse_mode'] = $parseMode;
        }

        if ($replyMarkup !== null) {
            $params['reply_markup'] = $replyMarkup;
        }

        $result = $this->send('sendMessage', $params);

        // Fallback: retry as plain text ONLY when Telegram rejected the
        // FORMATTED text ("can't parse entities"). Other errors (bot blocked,
        // chat not found, 429, ...) would just be duplicated pointlessly and
        // burn API calls (v2.9.43 — the fallback used to fire on EVERY error).
        if ($parseMode !== '' && self::isParseError($result)) {
            $plainParams = [
                'chat_id' => $chatId,
                'text'    => self::toPlainText($text),
            ];
            if ($replyMarkup !== null) {
                $plainParams['reply_markup'] = $replyMarkup;
            }
            $result = $this->send('sendMessage', $plainParams);
        }

        return $result;
    }

    /**
     * Send a media message (sendPhoto / sendVideo / sendAudio / sendDocument)
     * with a caption.
     *
     * This is the media equivalent of sendMessage(): it defaults parse_mode to
     * 'Markdown' so that [[emoji_id]] placeholders and markdown formatting in
     * the caption are converted to HTML (processParseModeRecursive handles the
     * 'caption' field the same way it handles 'text'). If Telegram rejects the
     * formatted caption (e.g. unmatched markdown entities), it retries as plain
     * text — mirroring sendMessage()'s fallback so the user always receives the
     * file even if the caption's markdown is broken.
     *
     * @param string $apiMethod  One of: sendPhoto, sendVideo, sendAudio, sendDocument
     * @param array  $params     Must contain 'chat_id' and the media file/key.
     *                           May contain 'caption', 'reply_markup', etc.
     */
    public function sendMedia(string $apiMethod, array $params): ?object
    {
        if (!isset($params['parse_mode'])) {
            $params['parse_mode'] = 'Markdown';
        }

        $result = $this->send($apiMethod, $params);

        // Fallback 1: retry the caption as plain text ONLY when Telegram
        // rejected the FORMATTED caption ("can't parse entities") — any other
        // failure class is handled below or is pointless to retry (v2.9.43).
        $caption = $params['caption'] ?? '';
        if ($caption !== '' && self::isParseError($result)) {
            $plainParams = $params;
            unset($plainParams['parse_mode']);
            $plainParams['caption'] = self::toPlainText($caption);
            $result = $this->send($apiMethod, $plainParams);
        }

        // Fallback 2: if sendPhoto/sendVideo/sendAudio FAILED (e.g. the file
        // wasn't actually a photo/video/audio, or Telegram rejected it for
        // being the wrong format), retry as sendDocument. sendDocument accepts
        // ANY file type, so this guarantees the file always reaches the user
        // even if the admin picked the wrong type or the auto-detection was
        // wrong. The caption + reply_markup are preserved.
        // v2.9.43: skip the retry when the recipient is unreachable (bot
        // blocked / chat gone / rate-limited) — resending is pointless.
        if (self::isError($result) && !self::isNotModifiedError($result) && !self::isDeliveryHopeless($result)
            && in_array($apiMethod, ['sendPhoto', 'sendVideo', 'sendAudio'], true)) {
            // Determine the file key used in $params (photo/video/audio).
            $origKey = str_replace('send', '', strtolower($apiMethod));
            $curlFile = $params[$origKey] ?? null;
            if ($curlFile !== null) {
                $docParams = $params;
                unset($docParams[$origKey]);
                $docParams['document'] = $curlFile;
                // Retry as sendDocument (with the markdown caption first).
                $result = $this->send('sendDocument', $docParams);
                // If that ALSO fails on the caption, retry as plain text.
                if ($caption !== '' && self::isParseError($result)) {
                    $plainDocParams = $docParams;
                    unset($plainDocParams['parse_mode']);
                    $plainDocParams['caption'] = self::toPlainText($caption);
                    $result = $this->send('sendDocument', $plainDocParams);
                }
            }
        }

        return $result;
    }

    /**
     * Send a document (file) to a chat. Used for backups and other file
     * downloads. The file is uploaded via a CURLFile. An optional caption and
     * reply_markup can be attached.
     *
     * @param int         $chatId       Telegram chat ID to send to
     * @param string      $filePath     Absolute path to the file on disk
     * @param string      $filename     Filename shown to the user (optional)
     * @param string      $caption      Optional caption (markdown supported)
     * @param string|null $replyMarkup  Optional JSON reply_markup
     * @return object|null  Telegram API response
     */
    public function sendDocument(int $chatId, string $filePath, string $filename = '', string $caption = '', ?string $replyMarkup = null): ?object
    {
        if (!is_file($filePath)) return null;
        $curlFile = new CURLFile($filePath, '', ($filename !== '' ? $filename : basename($filePath)));
        $params = [
            'chat_id'  => $chatId,
            'document' => $curlFile,
        ];
        if ($caption !== '') $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        return $this->sendMedia('sendDocument', $params);
    }

    /**
     * Resolve a Telegram file_id to a direct HTTPS download URL.
     *
     * Uses the getFile API to fetch the file_path, then constructs the
     * direct URL: https://api.telegram.org/file/bot<token>/<file_path>
     *
     * The URL is valid for at least 1 hour after getFile is called.
     * Returns null on failure (invalid file_id, network error, file too big).
     *
     * @param string $fileId  The file_id from a sendPhoto/sendVideo/... result
     * @return string|null    Direct download URL, or null if it couldn't be resolved
     */
    public function getFileUrl(string $fileId): ?string
    {
        if ($fileId === '') return null;
        $result = $this->send('getFile', ['file_id' => $fileId]);
        if ($result === null || !isset($result->ok) || !$result->ok) return null;
        $filePath = $result->result->file_path ?? null;
        if (!is_string($filePath) || $filePath === '') return null;
        return self::apiBase() . "/file/bot{$this->botToken}/{$filePath}";
    }

    public function editMessage(int $chatId, int $messageId, string $text, ?string $replyMarkup = null, string $parseMode = 'Markdown'): ?object
    {
        $params = [
            'chat_id'    => $chatId,
            'message_id' => $messageId,
            'text'       => $text,
        ];

        if ($parseMode !== '') {
            $params['parse_mode'] = $parseMode;
        }

        if ($replyMarkup !== null) {
            $params['reply_markup'] = $replyMarkup;
        }

        $result = $this->send('editMessageText', $params);

        // Fallback to plain text ONLY on a parse failure ("can't parse
        // entities"). We deliberately do NOT fall back on other errors —
        // notably "message is not modified" (the message already shows exactly
        // this content; a plain-text retry would DAMAGE it by dropping the
        // custom emoji / formatting) and unreachable-recipient errors, where a
        // retry is pointless (v2.9.43 — the fallback used to fire on every
        // non-modified error and duplicated requests).
        if ($parseMode !== '' && self::isParseError($result)) {
            $plainParams = [
                'chat_id'    => $chatId,
                'message_id' => $messageId,
                'text'       => self::toPlainText($text),
            ];
            if ($replyMarkup !== null) {
                $plainParams['reply_markup'] = $replyMarkup;
            }
            $result = $this->send('editMessageText', $plainParams);
        }

        return $result;
    }

    /**
     * Edit ONLY the reply markup (buttons) of a message — text stays the same.
     * Used for removing ticket buttons when a ticket is deleted/closed.
     */
    public function editMessageReplyMarkup(int $chatId, int $messageId, ?string $replyMarkup): ?object
    {
        $params = [
            'chat_id'    => $chatId,
            'message_id' => $messageId,
        ];
        if ($replyMarkup !== null) {
            $params['reply_markup'] = $replyMarkup;
        }
        return $this->send('editMessageReplyMarkup', $params);
    }

    /**
     * v2.9.6: Edit the text + reply_markup of an INLINE message (a message
     * sent via inline mode). Telegram identifies such messages by
     * `inline_message_id` (a string), NOT by chat_id + message_id.
     *
     * Used by the friends-game inline flow: the user configures the game
     * (fee, rounds) directly on the inline message, then taps "start" →
     * the inline message is edited to show a "join game" button.
     */
    public function editInlineMessageText(string $inlineMessageId, string $text, ?string $replyMarkup = null, string $parseMode = 'Markdown'): ?object
    {
        $params = [
            'inline_message_id' => $inlineMessageId,
            'text'              => $text,
        ];
        if ($parseMode !== '') {
            $params['parse_mode'] = $parseMode;
        }
        if ($replyMarkup !== null) {
            $params['reply_markup'] = $replyMarkup;
        }
        // v2.9.48 (R11): inline messages never render custom emoji IDs
        // (vNEXT 17-a rule) — suppress the R11 unicode→[[slot]] translation
        // for this whole method (both the markdown attempt and the plain
        // fallback), so shared inline boards keep their literal emojis.
        self::$emojiTranslateEnabled = false;
        try {
            $result = $this->send('editMessageText', $params);

            // Fallback to plain text ONLY on a parse failure (same logic as
            // editMessage — v2.9.43: was every error).
            if ($parseMode !== '' && self::isParseError($result)) {
                error_log('[editInlineMessageText] markdown edit failed — trying plain text');
                $plainParams = [
                    'inline_message_id' => $inlineMessageId,
                    'text'              => self::toPlainText($text),
                ];
                if ($replyMarkup !== null) {
                    $plainParams['reply_markup'] = $replyMarkup;
                }
                $result = $this->send('editMessageText', $plainParams);
            }
        } finally {
            self::$emojiTranslateEnabled = true;
        }
        return $result;
    }

    /**
     * v2.9.6: Edit ONLY the reply_markup of an inline message (text stays
     * the same). Used for the fee/rounds steppers on the inline game-setup
     * message — only the buttons change, the descriptive text is static.
     */
    public function editInlineMessageReplyMarkup(string $inlineMessageId, ?string $replyMarkup): ?object
    {
        $params = [
            'inline_message_id' => $inlineMessageId,
        ];
        if ($replyMarkup !== null) {
            $params['reply_markup'] = $replyMarkup;
        }
        return $this->send('editMessageReplyMarkup', $params);
    }

    public function answerCallback(string $callbackId, string $text = '', bool $showAlert = false): ?object
    {
        // v2.9.42: one answer per callback_query_id — replay synthetic ok for duplicates.
        if (isset(self::$answeredCallbackIds[$callbackId])) {
            if (defined('NETREX_DEBUG') && NETREX_DEBUG) {
                error_log('[TelegramBot::answerCallback] duplicate answerCallbackQuery suppressed (already answered): ' . substr($callbackId, 0, 16));
            }
            return (object)['ok' => true, 'result' => true, 'description' => 'Already answered (suppressed duplicate)'];
        }

        // T46-p: popup texts have no parse mode and never pass through the
        // emoji translator — emoji(N)/[[id]] placeholders leaked RAW
        // («[[58098…]]») into user-visible popups. Resolve every [[digits]]
        // placeholder to the pack's plain fallback glyph, and clamp to
        // Telegram's 200-char answerCallbackQuery limit (admin-editable
        // templates could 400 the whole answer → infinite spinner).
        if ($text !== '') {
            $text = preg_replace('/\[\[\d+\]\]/u', '▫', $text);
            $text = mb_substr($text, 0, 200);
        }

        $result = $this->send('answerCallbackQuery', [
            'callback_query_id' => $callbackId,
            'text'              => $text,
            'show_alert'        => $showAlert,
        ]);

        if ($result !== null && isset($result->ok) && $result->ok === true) {
            self::$answeredCallbackIds[$callbackId] = true;
        }

        return $result;
    }

    /**
     * v2.9.42: Whether this callback_query_id has already been answered
     * successfully during the current request lifecycle.
     */
    public static function hasAnsweredCallback(string $callbackQueryId): bool
    {
        return isset(self::$answeredCallbackIds[$callbackQueryId]);
    }

    /**
     * v2.9.18: Set up bot commands for private chats and group chats.
     * Called when /start@bot is used in a group (and can be called any time).
     * Commands have readable, meaningful Persian descriptions.
     */
    public function setupCommands(): void
    {
        // Private chat commands
        // v3.2 (T42): /help now covers every game's description.
        $privateCommands = [
            ['command' => 'start', 'description' => 'شروع ربات و نمایش منوی اصلی'],
            ['command' => 'game', 'description' => 'بازیابی بازی فعلی یا شروع بازی جدید'],
            ['command' => 'resume', 'description' => 'بازگرداندن صفحه بازی فعلی'],
            ['command' => 'close', 'description' => 'درخواست لغو بازی فعلی'],
            ['command' => 'help', 'description' => 'راهنمای کامل و توضیح همه بازی‌ها'],
        ];
        $this->send('setMyCommands', [
            'commands' => json_encode($privateCommands, JSON_UNESCAPED_UNICODE),
            'scope' => json_encode(['type' => 'all_private_chats']),
        ]);

        // Group chat commands — direct game start, no main menu
        // v3.2 (T42): /game sends the same full list as anonymous mode;
        // /resume and /close added (they were handled but not advertised).
        $groupCommands = [
            ['command' => 'start', 'description' => 'شروع بازی در گروه — انتخاب نوع بازی'],
            ['command' => 'game', 'description' => 'نمایش لیست کامل بازی‌ها (مثل حالت ناشناس)'],
            ['command' => 'tictactoe', 'description' => 'شروع مستقیم بازی دوز در گروه'],
            ['command' => 'dare', 'description' => 'شروع مستقیم بازی جرات یا حقیقت در گروه'],
            ['command' => 'resume', 'description' => 'بازگرداندن صفحه بازی فعلی در گروه'],
            ['command' => 'close', 'description' => 'درخواست لغو بازی فعلی در گروه'],
            ['command' => 'help', 'description' => 'راهنمای کامل و توضیح همه بازی‌ها'],
        ];
        $this->send('setMyCommands', [
            'commands' => json_encode($groupCommands, JSON_UNESCAPED_UNICODE),
            'scope' => json_encode(['type' => 'all_group_chats']),
        ]);
    }

    /**
     * Send an animated dice (🎲 🎯 🏀 ⚽ 🎳 🎰) and return the full API result.
     * The random value chosen by Telegram is in result.dice.value.
     */
    public function sendDice(int $chatId, string $emoji = '🎲'): ?object
    {
        return $this->send('sendDice', [
            'chat_id' => $chatId,
            'emoji'   => $emoji,
        ]);
    }

    /**
     * Forward a message (with original sender attribution) to another chat.
     * Used by the satisfaction system to forward the user's satisfaction
     * message to the satisfactions channel.
     */
    public function forwardMessage(int $fromChatId, int $messageId, int $toChatId, bool $disableNotification = false): ?object
    {
        return $this->send('forwardMessage', [
            'chat_id'              => $toChatId,
            'from_chat_id'         => $fromChatId,
            'message_id'           => $messageId,
            'disable_notification' => $disableNotification,
        ]);
    }

    /**
     * Copy a message to another chat (like forward but WITHOUT the original
     * sender attribution header). Useful when we want to re-publish content
     * cleanly.
     */
    public function copyMessage(int $fromChatId, int $messageId, int $toChatId, ?string $caption = null, ?string $replyMarkup = null): ?object
    {
        $params = [
            'chat_id'      => $toChatId,
            'from_chat_id' => $fromChatId,
            'message_id'   => $messageId,
        ];
        if ($caption !== null) $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        return $this->send('copyMessage', $params);
    }

    /**
     * Reply to an inline query with a list of results. Used by the friends
     * game: when a user types @bot in a chat, we offer a "start tic-tac-toe"
     * result they can send to that chat.
     *
     * @param array $results  Array of InlineQueryResult objects (already built).
     */
    public function answerInlineQuery(string $inlineQueryId, array $results, int $cacheTime = 5, bool $isPersonal = false): ?object
    {
        return $this->send('answerInlineQuery', [
            'inline_query_id' => $inlineQueryId,
            'results'         => $results,
            'cache_time'      => $cacheTime,
            'is_personal'     => $isPersonal,
        ]);
    }

    /**
     * Promote a chat member to administrator (used for the friends-game
     * feature where the bot needs admin rights in a group to manage the
     * game — e.g., pin the board, delete stale messages).
     *
     * The caller must already be an admin with the "can_promote_members"
     * right; the bot must itself be an admin in the chat. Returns true on
     * success.
     */
    public function promoteChatMember(int $chatId, int $userId, array $perms = []): bool
    {
        $params = [
            'chat_id'      => $chatId,
            'user_id'      => $userId,
            'can_manage_chat'          => $perms['can_manage_chat'] ?? true,
            'can_post_messages'        => $perms['can_post_messages'] ?? false,
            'can_edit_messages'        => $perms['can_edit_messages'] ?? false,
            'can_delete_messages'      => $perms['can_delete_messages'] ?? true,
            'can_invite_users'         => $perms['can_invite_users'] ?? false,
            'can_restrict_members'     => $perms['can_restrict_members'] ?? false,
            'can_pin_messages'         => $perms['can_pin_messages'] ?? true,
            'can_promote_members'      => $perms['can_promote_members'] ?? false,
            'can_change_info'          => $perms['can_change_info'] ?? false,
        ];
        $res = $this->send('promoteChatMember', $params);
        return $res !== null && isset($res->ok) && $res->ok;
    }

    /**
     * Get a chat member's status (creator / administrator / member / left /
     * kicked / restricted). Used to verify group admin rights and channel
     * join verification in task mode.
     */
    public function getChatMember(int $chatId, int $userId): ?array
    {
        $res = $this->send('getChatMember', [
            'chat_id' => $chatId,
            'user_id' => $userId,
        ]);
        if ($res === null || !isset($res->ok) || !$res->ok) return null;
        return json_decode(json_encode($res->result), true);
    }

    /**
     * Pin a message in a chat (the bot must be an admin with can_pin_messages).
     */
    public function pinChatMessage(int $chatId, int $messageId, bool $disableNotification = false): bool
    {
        $res = $this->send('pinChatMessage', [
            'chat_id'              => $chatId,
            'message_id'           => $messageId,
            'disable_notification' => $disableNotification,
        ]);
        return $res !== null && isset($res->ok) && $res->ok;
    }

    /**
     * Delete a message (the bot must be an admin in groups; in private chats
     * the bot can delete its own messages within 48h).
     */
    public function deleteMessage(int $chatId, int $messageId): bool
    {
        $res = $this->send('deleteMessage', [
            'chat_id'    => $chatId,
            'message_id' => $messageId,
        ]);
        return $res !== null && isset($res->ok) && $res->ok;
    }

    /**
     * Send a photo by file_id (the simplest path — used when forwarding a
     * user's photo satisfaction to the channel if forwardMessage fails).
     */
    public function sendPhotoByFileId(int $chatId, string $fileId, string $caption = '', ?string $replyMarkup = null, ?int $replyToMessageId = null): ?object
    {
        $params = [
            'chat_id'  => $chatId,
            'photo'    => $fileId,
        ];
        if ($caption !== '') $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        if ($replyToMessageId !== null) $params['reply_to_message_id'] = $replyToMessageId;
        return $this->sendMedia('sendPhoto', $params);
    }

    /**
     * Send a video by file_id.
     */
    public function sendVideoByFileId(int $chatId, string $fileId, string $caption = '', ?string $replyMarkup = null, ?int $replyToMessageId = null): ?object
    {
        $params = [
            'chat_id'  => $chatId,
            'video'    => $fileId,
        ];
        if ($caption !== '') $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        if ($replyToMessageId !== null) $params['reply_to_message_id'] = $replyToMessageId;
        return $this->sendMedia('sendVideo', $params);
    }

    /**
     * Send a document by file_id.
     */
    public function sendDocumentByFileId(int $chatId, string $fileId, string $caption = '', ?string $replyMarkup = null, ?int $replyToMessageId = null): ?object
    {
        $params = [
            'chat_id'   => $chatId,
            'document'  => $fileId,
        ];
        if ($caption !== '') $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        if ($replyToMessageId !== null) $params['reply_to_message_id'] = $replyToMessageId;
        return $this->sendMedia('sendDocument', $params);
    }

    /**
     * Send an audio by file_id.
     */
    public function sendAudioByFileId(int $chatId, string $fileId, string $caption = '', ?string $replyMarkup = null, ?int $replyToMessageId = null): ?object
    {
        $params = [
            'chat_id'  => $chatId,
            'audio'    => $fileId,
        ];
        if ($caption !== '') $params['caption'] = $caption;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        if ($replyToMessageId !== null) $params['reply_to_message_id'] = $replyToMessageId;
        return $this->sendMedia('sendAudio', $params);
    }

    /**
     * Send a message that replies to another message (used by the
     * satisfaction system: admin's comment is sent as a reply to the
     * forwarded satisfaction message in the channel).
     */
    public function sendMessageReply(int $chatId, string $text, int $replyToMessageId, ?string $replyMarkup = null, string $parseMode = 'Markdown'): ?object
    {
        $params = [
            'chat_id'              => $chatId,
            'text'                 => $text,
            'reply_to_message_id'  => $replyToMessageId,
        ];
        if ($parseMode !== '') $params['parse_mode'] = $parseMode;
        if ($replyMarkup !== null) $params['reply_markup'] = $replyMarkup;
        $result = $this->send('sendMessage', $params);
        // v2.9.43: plain-text retry ONLY on a parse failure (was every error).
        if ($parseMode !== '' && self::isParseError($result)) {
            $plainParams = [
                'chat_id'             => $chatId,
                'text'                => self::toPlainText($text),
                'reply_to_message_id' => $replyToMessageId,
            ];
            if ($replyMarkup !== null) $plainParams['reply_markup'] = $replyMarkup;
            $result = $this->send('sendMessage', $plainParams);
        }
        return $result;
    }

    /**
     * Get the bot's @username via getMe, cached in bot_settings so we only pay
     * the round-trip once. Returns '@NetrexvpnBot' as a hard-coded fallback if
     * the API call fails.
     */
    public function getBotUsername(): string
    {
        $cached = Database::getSetting('bot_username');
        if ($cached !== null && $cached !== '') {
            return $cached;
        }
        $me = $this->send('getMe');
        if ($me && isset($me->ok) && $me->ok && isset($me->result->username)) {
            $username = '@' . $me->result->username;
            Database::setSetting('bot_username', $username);
            return $username;
        }
        return '@NetrexvpnBot';
    }

    /**
     * Check whether the given user is a member of all required channels.
     *
     * Each entry in $channels may be either a bare string (legacy, e.g.
     * "@netrex") OR an associative array (as returned by
     * Database::getActiveChannels()):
     *   - channel_handle: unique key (@username for public, internal id for private)
     *   - display_name:   human-friendly name shown on the join button
     *   - channel_type:   'public' or 'private'
     *   - numeric_id:     numeric chat id used for the getChatMember call
     *   - join_link:      invite link for private channels (e.g. https://t.me/+AbCdEf)
     *
     * Only the channels the user has NOT joined are shown in the inline
     * keyboard. When the user taps "تأیید عضویت", this method is invoked again
     * and the keyboard is rebuilt with only the still-missing channels.
     */
    public function checkChannelMembership(int $userId, ?int $messageId, string $botToken, array $channels, bool $quiet = false): bool
    {
        if (empty($channels)) {
            return true;
        }

        // Normalise every channel into a structured record. Backwards-compatible:
        // a bare string (e.g. "@netrex") is treated as a public channel.
        $normalised = [];
        foreach ($channels as $channel) {
            if (is_array($channel)) {
                $normalised[] = $channel;
            } else {
                $normalised[] = [
                    'channel_handle' => (string)$channel,
                    'display_name'   => null,
                    'channel_type'   => 'public',
                    'numeric_id'     => null,
                    'join_link'      => null,
                ];
            }
        }

        // Determine the chat_id to use for the getChatMember call:
        //  - private channel -> numeric_id (the numeric chat id)
        //  - public channel  -> @username (channel_handle)
        // Special-case the legacy "@netrex" hard-coded channel which used a
        // fixed numeric id (-1001676284133).
        $refs = [];
        foreach ($normalised as $i => $ch) {
            $handle = $ch['channel_handle'];
            if ($ch['channel_type'] === 'private' && !empty($ch['numeric_id'])) {
                $chatIdParam = $ch['numeric_id'];
            } elseif ($handle === '@netrex') {
                $chatIdParam = '-1001676284133';
            } else {
                $chatIdParam = $handle;
            }
            // T46-p: (string) cast — under strict_types urlencode(int) is a
            // TypeError if PDO ever returns native int columns.
            $refs[$i] = (string)$chatIdParam;
        }

        // ── T46-aq: membership verdict cache (20k-user readiness) ────────
        // Every update used to pay a getChatMember round-trip per mandatory
        // channel (3 today) BEFORE anything else ran. member_cache keeps the
        // verdict per (user, chat): POSITIVE for 10 minutes (a member who
        // leaves is re-checked minutes later — acceptable), NEGATIVE for only
        // 10 seconds so a user who just joined passes on their very next
        // «تأیید عضویت» tap (T46-ap referral flow keeps its zero friction).
        // Any cache trouble falls back to the full API check (old behaviour).
        $cached = [];
        $need = array_keys($refs);
        try {
            $aqPdo = Database::getConnection();
            $aqIn = implode(',', array_fill(0, count($refs), '?'));
            $aqSt = $aqPdo->prepare("SELECT chat_ref, is_member, checked_at FROM member_cache WHERE user_id = ? AND chat_ref IN ($aqIn)");
            $aqSt->execute(array_merge([$userId], array_values($refs)));
            $aqRows = [];
            foreach ($aqSt->fetchAll(\PDO::FETCH_ASSOC) as $aqRow) {
                $aqRows[(string)$aqRow['chat_ref']] = $aqRow;
            }
            $need = [];
            $aqNow = time();
            foreach ($refs as $i => $ref) {
                if (isset($aqRows[$ref])) {
                    $aqIsMember = ((int)$aqRows[$ref]['is_member'] === 1);
                    $aqTtl = $aqIsMember ? 600 : 10;
                    if (($aqNow - (int)$aqRows[$ref]['checked_at']) <= $aqTtl) {
                        $cached[$i] = $aqIsMember;
                        continue;
                    }
                }
                $need[] = $i;
            }
        } catch (\Throwable $aqCacheE) {
            $cached = [];
            $need = array_keys($refs); // cache unavailable — full API check
        }

        $handles = [];
        $mh = curl_multi_init();

        foreach ($need as $i) {
            $ch = $normalised[$i];

            $url = self::apiBase() . "/bot{$botToken}/getChatMember?chat_id=" . urlencode($refs[$i]) . "&user_id={$userId}";

            $curl = curl_init();
            curl_setopt_array($curl, [
                CURLOPT_URL            => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT        => 5,
                CURLOPT_CONNECTTIMEOUT => 3,
            ]);
            curl_multi_add_handle($mh, $curl);
            $handles[$i] = [$ch, $curl];
        }

        do {
            $status = curl_multi_exec($mh, $active);
            if ($active) {
                curl_multi_select($mh, 1.0);
            }
        } while ($active && $status === CURLM_OK);

        $notJoined = [];
        $apiVerdicts = [];
        foreach ($handles as $i => [$ch, $curl]) {
            $response = curl_multi_getcontent($curl);
            $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
            curl_multi_remove_handle($mh, $curl);
            curl_close($curl);

            $joined = false;
            if ($httpCode === 200) {
                $result = json_decode($response, true);
                if (is_array($result) && !empty($result['ok']) && isset($result['result']['status'])) {
                    if (in_array($result['result']['status'], ['member', 'administrator', 'creator'], true)) {
                        $joined = true;
                    }
                    // T46-p: a restricted-but-present member (muted, is_member
                    // true) IS in the channel — treating them as not-joined
                    // made the join gate unsatisfiable for those users.
                    elseif ($result['result']['status'] === 'restricted'
                        && !empty($result['result']['is_member'])) {
                        $joined = true;
                    }
                }
            }

            $apiVerdicts[$i] = $joined;
            $cached[$i] = $joined;

            if (!$joined) {
                $notJoined[] = $ch;
            }
        }

        curl_multi_close($mh);

        // T46-aq: persist the fresh API verdicts (best-effort; a cache-write
        // failure never influences the gate decision above) and merge the
        // CACHED negative verdicts into the not-joined list so the join menu
        // stays accurate for non-members without any API call.
        if (!empty($apiVerdicts)) {
            try {
                $aqUp = Database::getConnection()->prepare("INSERT INTO member_cache (user_id, chat_ref, is_member, checked_at) VALUES (?, ?, ?, UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE is_member = VALUES(is_member), checked_at = UNIX_TIMESTAMP()");
                foreach ($apiVerdicts as $i => $aqJoined) {
                    $aqUp->execute([$userId, $refs[$i], $aqJoined ? 1 : 0]);
                }
            } catch (\Throwable $aqCacheW) {
                // ignore — the verdict is already decided
            }
        }
        foreach ($refs as $i => $ref) {
            if (!array_key_exists($i, $apiVerdicts) && isset($cached[$i]) && $cached[$i] === false) {
                $notJoined[] = $normalised[$i];
            }
        }
        // 1% probabilistic purge so the table cannot grow without bound
        if (random_int(1, 100) === 1) {
            try {
                Database::getConnection()->prepare("DELETE FROM member_cache WHERE checked_at < UNIX_TIMESTAMP() - 604800")->execute();
            } catch (\Throwable $aqCacheP) {
                // ignore
            }
        }

        if (empty($notJoined)) {
            return true;
        }

        $this->sendMembershipRequired($userId, $messageId, $notJoined, $quiet);
        return false;
    }

    private function sendMembershipRequired(int $userId, ?int $messageId, array $channels, bool $quiet = false): void
    {
        $keyboard = [];
        foreach ($channels as $ch) {
            $handle = is_array($ch) ? ($ch['channel_handle'] ?? '') : (string)$ch;
            $type   = is_array($ch) ? ($ch['channel_type']   ?? 'public') : 'public';
            $name   = is_array($ch) ? ($ch['display_name']  ?? null) : null;
            $link   = is_array($ch) ? ($ch['join_link']     ?? null) : null;

            // Button text: prefer the human display name, fall back to handle.
            $label = $name ?: $handle;

            // Build the join URL:
            //  - private channel with an invite link -> use the link directly
            //    (e.g. https://t.me/+klwTyA-6QmQxYmZk). The link already
            //    contains everything Telegram needs.
            //  - public channel -> https://t.me/<username-without-@>
            //  - legacy @netrex  -> the hard-coded invite link
            if ($handle === '@netrex' && empty($link)) {
                $url = 'https://t.me/+klwTyA-6QmQxYmZk';
            } elseif ($type === 'private' && !empty($link)) {
                $url = $link;
            } else {
                $clean = preg_replace('/^@+/', '', $handle);
                $url = "https://t.me/{$clean}";
            }

            // 'style' = 'success' (green) makes the join buttons coloured,
            // matching the rest of the bot's coloured inline keyboards.
            $button = [
                'text'                  => "عضویت در {$label}",
                'url'                   => $url,
                'style'                 => 'success',
                'icon_custom_emoji_id'  => emoji_id(16),
            ];
            // Only add icon_custom_emoji_id when we actually have one (the
            // Telegram API rejects the field when it is null).
            if ($button['icon_custom_emoji_id'] === null) {
                unset($button['icon_custom_emoji_id']);
            }
            $keyboard[] = [$button];
        }

        $confirmButton = [
            'text'          => 'تأیید عضویت',
            'callback_data' => 'back_to_main_menu',
            // v2.9.47 (R9): confirm actions are positive → success (design system).
            'style'         => 'success',
            'icon_custom_emoji_id' => emoji_id(13),
        ];
        if ($confirmButton['icon_custom_emoji_id'] === null) {
            unset($confirmButton['icon_custom_emoji_id']);
        }
        $keyboard[] = [$confirmButton];

        $replyMarkup = json_encode(['inline_keyboard' => $keyboard]);
        $text = emoji(18) . " برای ادامه، لطفاً در کانال‌های زیر عضو شوید و سپس دکمه تأیید را بزنید:";

        // T46-an FIX (owner report: non-members got NO response at all from
        // the bot once a mandatory channel was enabled): quiet mode is now an
        // EXPLICIT opt-in ($quiet). The old "messageId === null means quiet"
        // assumption was wrong — the PV webhook flows
        // (MessageHandler::checkMembership) pass null too, so the
        // mandatory-join menu was silently swallowed there. Only callers that
        // render their own membership card (the inline flows) pass quiet=true.
        if ($quiet) {
            return;
        }
        if ($messageId === null || $messageId <= 0) {
            $this->sendMessage($userId, $text, $replyMarkup);
        } else {
            $this->editMessage($userId, $messageId, $text, $replyMarkup);
        }
    }

    /**
     * Convert a (possibly markdown) text into Telegram HTML.
     *
     * IMPORTANT: HTML special characters (&, <, >) are escaped FIRST, before any
     * markdown syntax is translated to HTML tags. This is critical because config
     * links (vless://, vmess://, ...) contain '&' query separators which would
     * otherwise break Telegram's HTML parser and cause the whole message to be
     * rejected ("can't parse entities").
     */
    private static function markdownToHtml(string $text): string
    {
        // v2.9.22: Protect existing HTML tags (like <a href="tg://user?id=...">name</a>)
        // from being escaped by htmlspecialchars. We extract them first, then reinsert
        // after escaping. This fixes the "raw link shown instead of linked name" bug.
        $htmlTags = [];
        $text = preg_replace_callback('/<(a|b|i|u|s|code|pre|tg-emoji|blockquote|\/a|\/b|\/i|\/u|\/s|\/code|\/pre|\/blockquote)([^>]*)>/i', function($m) use (&$htmlTags) {
            $placeholder = "\x00TAG" . count($htmlTags) . "\x00";
            $htmlTags[$placeholder] = $m[0];
            return $placeholder;
        }, $text);

        // 1) Escape HTML special characters for the whole text.
        $text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        // 2) Translate markdown syntax to HTML tags. The markdown delimiter
        //    characters (`*`, `_`, `` ` ``, `[`, `]`, `(`, `)`, `~`, `+`, `|`, `>`)
        //    are NOT HTML-special, so they survived step 1 untouched.

        // Quote: lines starting with "> " become <blockquote>.
        // Telegram supports <blockquote> in HTML mode. We process line-by-line
        // so that consecutive quoted lines are merged into a single block.
        $lines = preg_split('/\n/', $text);
        $out = [];
        $inQuote = false;
        foreach ($lines as $line) {
            if (preg_match('/^&gt;\s?(.*)$/', $line, $m)) {
                if (!$inQuote) {
                    $out[] = '<blockquote>';
                    $inQuote = true;
                }
                $out[] = $m[1];
            } else {
                if ($inQuote) {
                    $out[] = '</blockquote>';
                    $inQuote = false;
                }
                $out[] = $line;
            }
        }
        if ($inQuote) {
            $out[] = '</blockquote>';
        }
        $text = implode("\n", $out);

        $text = preg_replace('/```(.*?)```/s', '<pre><code>$1</code></pre>', $text);
        $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
        $text = preg_replace('/\*\*(.*?)\*\*/s', '<b>$1</b>', $text);
        $text = preg_replace('/__(.*?)__/s', '<i>$1</i>', $text);
        $text = preg_replace('/~~(.*?)~~/s', '<s>$1</s>', $text);
        $text = preg_replace('/\|\|(.*?)\|\|/s', '<tg-spoiler>$1</tg-spoiler>', $text);
        $text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);

        // NOTE: single-char variants of * and _ are intentionally NOT applied
        // because they greedily match underscores/dashes that commonly appear
        // inside config links, hostnames and identifiers, corrupting the output.

        // 3) Reinsert protected HTML tags (restore <a href="...">name</a> etc.)
        foreach ($htmlTags as $placeholder => $originalTag) {
            $text = str_replace($placeholder, $originalTag, $text);
        }

        return $text;
    }

    /**
     * v2.9.48 (R11): once-and-for-all unicode → custom-emoji translation.
     *
     * Owner directive: «هرجایی که ایموجی عادی استفاده شده باید کاستوم باشه» —
     * instead of hunting every literal emoji in ~20 files (and admin-typed DB
     * message overrides that source edits can never reach), every outgoing
     * PV/group message text now passes through this translator right before
     * convertEmoji(). Known unicode emojis become [[slot]] placeholders and
     * render as the owner's custom pack.
     *
     * Scope guards:
     *  - Whitelist-only: chars NOT in the map are untouched — TTT board marks
     *    (❌ ⭕) survive in message texts, dice-game emojis (🎲🎯🏀⚽🎳🎰) stay
     *    literal in inline labels, arrows/typography stay as-is.
     *  - Inline is exempt by architecture: answerInlineQuery results are
     *    pre-encoded to JSON before processParseModeRecursive runs, and
     *    editInlineMessageText() flips $emojiTranslateEnabled off — custom
     *    emoji IDs never render in inline messages (established vNEXT 17-a).
     *  - answerCallbackQuery alerts are plain text (Telegram API has no
     *    parse_mode there) — those popup strings can never render custom
     *    emoji and are untouched by design.
     *  - Existing <tg-emoji>…</tg-emoji> spans in HTML-mode texts are
     *    protected from double-wrapping.
     */
    private static bool $emojiTranslateEnabled = true;

    private const EMOJI_SLOT_MAP = [
        // ── identity: exact pack counterpart (VS16 variants first) ──
        '❤️' => 7, '❤' => 7,
        '🗂️' => 12, '🗂' => 12,
        '🖥️' => 15, '🖥' => 15,
        '✈️' => 17, '✈' => 17,
        '🏷️' => 22, '🏷' => 22,
        '✏️' => 24, '✏' => 24,
        '⌛️' => 26, '⌛' => 26,
        '🖼️' => 29, '🖼' => 29,
        '🖱️' => 30, '🖱' => 30,
        '🗑️' => 32, '🗑' => 32,
        '▶️' => 40, '▶' => 40,
        '⭐️' => 46, '⭐' => 46,
        '🏖️' => 5, '🏖' => 5,
        '⚠️' => 10, '⚠' => 10,   // warning → 🚫 error/warning slot (game_emoji 'warn')
        '⚧️' => 35, '⚧' => 35,   // gender marker → 🦖 player
        '🔥' => 0, '🏆' => 1, '💔' => 2, '💊' => 3, '🚘' => 4,
        '🥇' => 6, '🎁' => 8, '💤' => 9, '🚫' => 10, '🏠' => 11,
        '👍' => 13, '🆕' => 14, '📣' => 16, '💩' => 19, '🪙' => 20,
        '👓' => 21, '🔔' => 23, '💾' => 25, '👑' => 28, '👎' => 34,
        '🦖' => 35, '💎' => 36, '💵' => 44, '🔋' => 45, '📷' => 48,
        '🐝' => 49, '🆗' => 52,
        // ── semantic remaps: no exact pack counterpart ──
        '✅' => 52,   // done/approved → 🆗
        '🎮' => 0,   // game → 🔥 (game_emoji 'game' convention)
        '💰' => 20,  // fee/cost → 🪙 (game_emoji 'fee' convention)
        '🔄' => 24,  // change → ✏️ (round-9 convention)
        '⏳' => 26,  // wait/time → ⌛️ duration slot
        '⏰' => 26,  // time → ⌛️
        '⏰️' => 26,
        '📢' => 16,  // announce → 📣
        '🎉' => 1,   // celebration/win → 🏆 (slot 1 “win / party”)
        '😞' => 2,   // loss/sad → 💔 (the pack's loss icon)
        '👤' => 35,  // user → 🦖 player
        '👥' => 35,
        '📅' => 23,  // date/days → 🔔 (slot 23 “sub_count / days”)
        '🗄️' => 25, '🗄' => 25, // database/backup → 💾
        '💸' => 44,  // money-out/refund → 💵 toman
        '🤖' => 15,  // bot/tech → 🖥
        '🤝' => 13,  // handshake/draw → 👍 (slot 13 “confirm / draw”)
        '💞' => 7,   // love/couple → ❤️
        '📤' => 42,  // share → ▶️ (game_emoji 'share' convention)
        '📖' => 21,  // guide → 👓 (game_emoji 'guide' convention)
        '❓' => 12,  // question/info → 🗂 (slot 12 “rules / support”)
        '🤔' => 21,  // thinking/truth → 👓 (tod_truth button convention)
        '🚪' => 32,  // exit → 🗑 (slot 32 “exit queue”)
    ];

    private static function translateUnicodeEmojis(string $text): string
    {
        if (!self::$emojiTranslateEnabled || self::EMOJI_SLOT_MAP === []) {
            return $text;
        }
        // Protect existing <tg-emoji> spans (HTML-mode texts may already
        // contain them) — odd-index captures are re-inserted untouched.
        $parts = preg_split('/(<tg-emoji[^>]*>.*?<\/tg-emoji>)/us', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
        if ($parts === false || count($parts) === 1) {
            return self::translateUnicodeEmojisPlain($text);
        }
        foreach ($parts as $i => $part) {
            if ($i % 2 === 0) {
                $parts[$i] = self::translateUnicodeEmojisPlain($part);
            }
        }
        return implode('', $parts);
    }

    private static function translateUnicodeEmojisPlain(string $text): string
    {
        $keys = array_keys(self::EMOJI_SLOT_MAP);
        // Longest-first so VS16 variants (e.g. '▶️') win over bare chars ('▶').
        usort($keys, static fn(string $a, string $b): int => strlen($b) - strlen($a));
        $rx = '~(' . implode('|', array_map('preg_quote', $keys)) . ')~u';
        return (string)preg_replace_callback($rx, static function (array $m): string {
            $slot = self::EMOJI_SLOT_MAP[$m[1]] ?? null;
            if ($slot === null) {
                return $m[0];
            }
            // v3.3 (T46): emit the FULL custom-emoji ID, never the slot number.
            // convertEmoji() wraps [[<digits>]] verbatim as
            // <tg-emoji emoji-id="<digits>">, so a translator-produced
            // [[44]] became emoji-id="44" — an INVALID custom-emoji id — and
            // Telegram rejected the WHOLE message with "Bad Request:
            // DOCUMENT_INVALID" (latent since v2.9.48 R11; surfaced by the
            // T46 referral hub, the first screen whose text carries a mapped
            // literal emoji). Resolve the slot through the pack like
            // emoji_id() does; unknown slots keep the literal char.
            $ids = $GLOBALS['config']['emoji_ids'] ?? [];
            $id = $ids[$slot] ?? null;
            return $id ? "[[{$id}]]" : $m[0];
        }, $text);
    }

    private static function convertEmoji(string $text): string
    {
        return preg_replace_callback('/\[\[(\d+)\]\]/', function ($m) {
            return "<tg-emoji emoji-id=\"{$m[1]}\">▫</tg-emoji>";
        }, $text);
    }

    private static function processParseModeRecursive(array &$data): void
    {
        if (!isset($data['parse_mode'])) {
            return;
        }

        $mode = strtoupper($data['parse_mode']);
        $textFields = ['text', 'caption', 'message_text'];

        if ($mode === 'MARKDOWN' || $mode === 'MARKDOWNV2') {
            foreach ($textFields as $field) {
                if (isset($data[$field]) && is_string($data[$field])) {
                    $data[$field] = self::markdownToHtml($data[$field]);
                    // v2.9.48 (R11): unicode → [[slot]] BEFORE the [[id]] →
                    // <tg-emoji> pass, so freshly translated slots get wrapped
                    // exactly like hand-placed emoji(N) placeholders.
                    $data[$field] = self::translateUnicodeEmojis($data[$field]);
                    $data[$field] = self::convertEmoji($data[$field]);
                }
            }
            $data['parse_mode'] = 'HTML';
        } elseif ($mode === 'HTML') {
            foreach ($textFields as $field) {
                if (isset($data[$field]) && is_string($data[$field])) {
                    $data[$field] = self::translateUnicodeEmojis($data[$field]);
                    $data[$field] = self::convertEmoji($data[$field]);
                }
            }
        }

        foreach ($data as &$value) {
            if (is_array($value)) {
                self::processParseModeRecursive($value);
            }
        }
    }

    private static function isError(?object $result): bool
    {
        if ($result === null) {
            return true;
        }
        return isset($result->ok) && $result->ok === false;
    }

    /**
     * Telegram returns "Bad Request: message is not modified" when an
     * editMessage call doesn't actually change anything. This is NOT a real
     * error — the message is already correct — so we must NOT trigger the
     * plain-text fallback in that case (it would erase the existing emoji /
     * formatting from a perfectly good message).
     */
    private static function isNotModifiedError(?object $result): bool
    {
        if ($result === null) {
            return false;
        }
        $desc = $result->description ?? '';
        return is_string($desc)
            && (str_contains($desc, 'not modified') || str_contains($desc, 'message is not modified'));
    }

    /**
     * v2.9.43: Telegram rejected the FORMATTED (markdown/HTML) version of the
     * text. Only this class of failure justifies a plain-text retry; every
     * other error (recipient blocked, chat not found, 429, ...) would just be
     * duplicated pointlessly and burn API calls.
     */
    private static function isParseError(?object $result): bool
    {
        if ($result === null) {
            return false;
        }
        $desc = $result->description ?? '';
        return is_string($desc) && str_contains($desc, "can't parse entities");
    }

    /**
     * v2.9.43: the recipient is unreachable or we are rate-limited — retrying
     * the same content in any other representation is pointless.
     */
    private static function isDeliveryHopeless(?object $result): bool
    {
        if ($result === null) {
            return false;
        }
        if (isset($result->error_code) && (int)$result->error_code === 429) {
            return true;
        }
        $desc = $result->description ?? '';
        return is_string($desc)
            && (str_contains($desc, 'bot was blocked by the user')
                || str_contains($desc, 'chat not found'));
    }

    /**
     * Strip markdown/emoji placeholders so a message can be retried as plain
     * text when the HTML version is rejected by Telegram.
     *
     * IMPORTANT: we do NOT delete the [[emoji_id]] placeholders (that would make
     * the message look empty/broken — e.g. " به منوی اصلی خوش امدید" with a
     * leading space where the emoji used to be). Instead we replace them with a
     * neutral fallback glyph (▫) so the message remains readable when Telegram
     * rejects the HTML version (which happens for non-premium users receiving
     * <tg-emoji> tags, or when the message text is "too long" etc.).
     */
    public static function toPlainText(string $text): string
    {
        // Replace custom-emoji placeholders with a neutral fallback glyph.
        $text = preg_replace('/\[\[\d+\]\]/', '▫', $text);
        // v2.9.29: Strip HTML tags (but keep inner text) so <a href="...">name</a>
        // becomes just "name" instead of showing raw HTML tags.
        $text = preg_replace('/<a[^>]*>(.*?)<\/a>/i', '$1', $text);
        $text = preg_replace('/<b>(.*?)<\/b>/i', '$1', $text);
        $text = preg_replace('/<i>(.*?)<\/i>/i', '$1', $text);
        $text = preg_replace('/<tg-emoji[^>]*>(.*?)<\/tg-emoji>/i', '$1', $text);
        $text = preg_replace('/<[^>]+>/', '', $text);
        // Remove code-fence and inline-code markers.
        $text = str_replace(['```', '`'], '', $text);
        // Remove bold/italic/spoiler markers.
        $text = str_replace(['**', '__', '~~', '||'], '', $text);
        // Decode HTML entities that were added by markdownToHtml so the plain-
        // text fallback doesn't show "&apos;" / "&amp;" literally.
        $text = htmlspecialchars_decode($text, ENT_QUOTES | ENT_HTML5);
        return $text;
    }
}
