<?php

declare(strict_types=1);

/**
 * TaskSystem — unified task management for the bot.
 *
 * Three original task types live in the `tasks` table:
 *
 *   • channel  — the user joins a specific channel; the bot verifies via
 *                getChatMember and instantly grants the reward. Channel tasks
 *                are linked to a row in `channels` (channel_mode='task',
 *                task_id set).
 *
 *   • bot      — the user starts a previous bot; verified via its
 *                check_started API (bot_verifications link).
 *
 *   • text     — the user submits a free-form text answer; an admin reviews
 *                it (mini-ticket style); approval grants the reward, rejection
 *                lets the user retry.
 *
 * v3.3 (T46-l) added seven CONFIGURABLE task types (owner directive — the
 * admin panel creates them with per-type parameters + multi-currency rewards,
 * and every user-facing text renders dynamically from tasks.task_config):
 *
 *   • group_add   — add N members to the target group; auto-counted from
 *                   new_chat_members service messages in that group.
 *   • group_msgs  — send N messages in the target group; auto-counted
 *                   (commands/service messages never count).
 *   • race        — podium race: be the 1st/2nd/3rd… finisher of the goal
 *                   (send a message, or add N members) inside the window;
 *                   places share one finisher sequence via race_key.
 *   • referrals   — invite N friends to the bot (auto-completed from the
 *                   referrer's sub_count on every paid referral).
 *   • play_games  — finish N games (verified from game_sessions history).
 *   • win_games   — win N games (winner_id history).
 *   • manual      — free-form task: the user taps «تایید انجام تسک» when
 *                   done; an admin approves/rejects from the panel queue.
 *
 * Reward crediting goes through Wallet::credit() so the wallet ledger is
 * always in sync and the user's balance is updated atomically with the
 * task-completion state.
 */
class TaskSystem
{
    /**
     * All active tasks (sorted by id asc) — shown to users in the tasks menu.
     */
    public static function listActiveTasks(): array
    {
        return Database::getActiveTasks();
    }

    /**
     * All tasks including inactive ones (admin panel view).
     */
    public static function listAllTasks(): array
    {
        return Database::getAllTasks();
    }

    /**
     * Create a channel-type task and link the channel into task mode.
     *
     * The channel's handle is stored as `tasks.target` (so the bot can render
     * the join link even without a separate channel lookup), and the channel
     * row itself is moved into channel_mode='task' with the reward snapshot
     * and task_id back-reference.
     *
     * @return int the new task id
     */
    public static function createChannelTask(int $channelId, string $title, ?string $description, string $rewardType, int $rewardAmount, ?int $createdBy, int $rewardGameTokens = 0, int $rewardToman = 0, int $rewardNtx = 0): int
    {
        $channel = Database::getChannelById($channelId);
        $handle  = $channel !== null ? (string)($channel['channel_handle'] ?? '') : '';

        $taskId = Database::createTask(
            'channel',
            $title,
            $description,
            ($handle !== '' ? $handle : null),
            $channelId,
            $rewardType,
            $rewardAmount,
            $createdBy,
            $rewardGameTokens,
            $rewardToman,
            $rewardNtx
        );

        // Move the channel into task mode and back-link it to the new task.
        Database::updateChannelMode($channelId, 'task', $rewardType, $rewardAmount);
        Database::linkChannelTask($channelId, $taskId);

        Database::logEvent('task_created', null, [
            'task_id'     => $taskId,
            'type'        => 'channel',
            'channel_id'  => $channelId,
            'reward_game_tokens' => $rewardGameTokens,
            'reward_toman' => $rewardToman,
            'reward_ntx' => $rewardNtx,
            'created_by'  => $createdBy,
        ]);

        return $taskId;
    }

    /**
     * Create a text-type task (no channel link, no target).
     */
    public static function createTextTask(string $title, ?string $description, string $rewardType, int $rewardAmount, ?int $createdBy, int $rewardGameTokens = 0, int $rewardToman = 0, int $rewardNtx = 0): int
    {
        $taskId = Database::createTask(
            'text',
            $title,
            $description,
            null,
            null,
            $rewardType,
            $rewardAmount,
            $createdBy,
            $rewardGameTokens,
            $rewardToman,
            $rewardNtx
        );

        Database::logEvent('task_created', null, [
            'task_id'       => $taskId,
            'type'          => 'text',
            'reward_game_tokens' => $rewardGameTokens,
            'reward_toman' => $rewardToman,
            'reward_ntx' => $rewardNtx,
            'created_by'    => $createdBy,
        ]);

        return $taskId;
    }

    /**
     * Update a task's editable fields. If the task is a channel task, the
     * linked channel's reward snapshot is also refreshed.
     */
    public static function updateTask(int $taskId, string $title, ?string $description, string $rewardType, int $rewardAmount, bool $isActive, int $rewardGameTokens = 0, int $rewardToman = 0, int $rewardNtx = 0): void
    {
        Database::updateTask($taskId, $title, $description, $rewardType, $rewardAmount, $isActive, $rewardGameTokens, $rewardToman, $rewardNtx);

        $task = Database::getTask($taskId);
        if ($task !== null && !empty($task['channel_id'])) {
            Database::updateChannelMode(
                (int)$task['channel_id'],
                'task',
                $rewardType,
                $rewardAmount
            );
        }
    }

    /**
     * Delete a task. If it was a channel task, the linked channel is reset
     * back to mandatory mode (no reward, no task back-link) — i.e. it becomes
     * a plain mandatory-join channel again.
     */
    public static function deleteTask(int $taskId): void
    {
        $task = Database::getTask($taskId);
        Database::deleteTask($taskId);
        // T46-l: drop the task's progress events too (group adds / messages,
        // race finisher markers) so a recreated task starts from zero.
        Database::deleteTaskEvents($taskId);

        if ($task !== null && !empty($task['channel_id'])) {
            Database::updateChannelMode((int)$task['channel_id'], 'mandatory', null, null);
            Database::linkChannelTask((int)$task['channel_id'], null);
        }
        // If it was a bot-verification task, reset the bot_verification to mandatory mode.
        if ($task !== null && ($task['type'] ?? '') === 'bot' && !empty($task['bot_verification_id'])) {
            $bvId = (int)$task['bot_verification_id'];
            $bv = Database::getBotVerification($bvId);
            if ($bv !== null) {
                Database::updateBotVerification($bvId, $bv['bot_username'], $bv['api_url'], $bv['api_key'], 'mandatory', null, null);
                Database::linkBotVerificationTask($bvId, null);
            }
        }
    }

    /**
     * User tapped a task button. Behavior depends on task type:
     *
     *   channel  — if already completed, send 'tasks_completed'; otherwise
     *              show the join link + "تأیید عضویت" verify button.
     *              F2: the user_tasks row is created NOW (status='in_progress')
     *              so the guarded Database::completeUserTask has a row to
     *              claim when the user taps تأیید.
     *
     *   bot      — same as channel, with the bot-verify prompt.
     *
     *   text     — if a submission is already pending review, ask the user to
     *              wait; otherwise set the user's status to
     *              'waiting_task_<taskId>' and send 'task_text_prompt'.
     */
    public static function startTask(int $userId, int $taskId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $existing = Database::getUserTask($userId, $taskId);

        if (($task['type'] ?? '') === 'channel') {
            // Already completed?
            if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
                // F12: admin-editable template — str_replace, not sprintf.
                $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
                $bot->sendMessage($userId, $msg, null, 'Markdown');
                return;
            }
            self::sendChannelJoinPrompt($userId, $task, $bot, $config);
            // F2: create the user_tasks row (status='in_progress') NOW —
            // channel/bot tasks previously never created one, so the
            // completion UPDATE matched 0 rows while grantTaskReward ran on
            // EVERY verify tap (endless payout). INSERT IGNORE semantics:
            // an existing row (pending/completed) is never touched.
            Database::ensureUserTaskStarted($userId, $taskId);
            return;
        }

        if (($task['type'] ?? '') === 'bot') {
            // Bot-verification task: show the "start the bot" + verify buttons.
            if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
                // F12: admin-editable template — str_replace, not sprintf.
                $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
                $bot->sendMessage($userId, $msg, null, 'Markdown');
                return;
            }
            self::sendBotVerifyPrompt($userId, $task, $bot, $config);
            // F2: same row creation as the channel branch (see above).
            Database::ensureUserTaskStarted($userId, $taskId);
            return;
        }

        // T46-l: the seven configurable task types share one entry screen
        // (dynamic description + progress + type-specific action buttons).
        if (self::isNewType((string)($task['type'] ?? ''))) {
            self::startConfiguredTask($userId, $task, $bot, $config);
            return;
        }

        if (($task['type'] ?? '') === 'text') {
            // Pending submission? Tell the user to wait.
            if ($existing !== null && ($existing['status'] ?? '') === 'pending') {
                $bot->sendMessage(
                    $userId,
                    ' پاسخ شما برای این تسک در انتظار بررسی است. لطفاً تا پایان بررسی صبر کنید.',
                    null,
                    'Markdown'
                );
                return;
            }
            // Already completed? No re-submission — parity with the channel/bot
            // branches above. Without this gate the createUserTask upsert in
            // submitTextAnswer would reset the completed row back to 'pending'
            // and a fresh admin approval would re-pay the reward.
            if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
                $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
                $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
                return;
            }
            // Rejected before? Allow retry — the user_tasks row from a prior
            // rejection will be overwritten by createUserTask (UNIQUE INDEX).

            // Put the user into the "waiting for text answer" status. The
            // MessageHandler reads this status prefix and routes the next
            // text message to submitTextAnswer().
            // v2.9.45 (R6-93/LOW): targeted status write.
            Database::updateUserColumns($userId, ['status' => 'waiting_task_' . $taskId]);
            // F12: admin-editable template — str_replace-based interpolation.
            $prompt = self::interpolate(
                Settings::getMessage('task_text_prompt', $config),
                (string)$task['title'],
                (string)($task['description'] ?? '')
            );
            $bot->sendMessage($userId, $prompt, self::backKeyboard($config), 'Markdown');
            return;
        }

        // Unknown task type — defensive fallback.
        $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
    }

    /**
     * User tapped "تأیید" for a bot-verification task. Verify via the external
     * bot's check_started API and grant the reward on success.
     */
    public static function verifyBotTask(int $userId, int $taskId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null || ($task['type'] ?? '') !== 'bot') {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        // Already completed? Don't double-reward.
        $existing = Database::getUserTask($userId, $taskId);
        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            // F12: admin-editable template — str_replace, not sprintf.
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, null, 'Markdown');
            return;
        }

        $bvId = isset($task['bot_verification_id']) ? (int)$task['bot_verification_id'] : 0;
        if ($bvId <= 0) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $result = BotVerifier::checkById($userId, $bvId);

        if (!$result['ok']) {
            // API error — tell the user to retry, don't fail permanently.
            $bot->sendMessage($userId, Settings::getMessage('botverify_api_error', $config), null, 'Markdown');
            return;
        }

        if (!$result['started']) {
            $bot->sendMessage($userId, Settings::getMessage('botverify_still_not_started', $config), null, 'Markdown');
            return;
        }

        // Started — complete + reward.
        // F2: defensive row ensure (idempotent INSERT IGNORE — also covers
        // prompts rendered before the row-creation fix shipped), then the
        // GUARDED completion: the reward is granted only when THIS request
        // won the transition (rowCount===1); a loser gets the "already
        // completed" message instead of a second payout.
        Database::ensureUserTaskStarted($userId, $taskId);
        if (Database::completeUserTask($userId, $taskId) !== 1) {
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, null, 'Markdown');
            return;
        }
        $rewardText = self::grantTaskReward($userId, $task);

        Database::logEvent('task_completed', $userId, [
            'task_id'       => $taskId,
            'type'          => 'bot',
            'bot_verification_id' => $bvId,
        ]);

        // F12: fill the template's "%d %s" reward tail with the combined
        // reward string (the raw placeholders previously leaked into the
        // message); if the admin removed the placeholders, append the reward.
        $joinedTpl = Settings::getMessage('task_channel_joined', $config);
        $filled    = str_replace('%d %s', $rewardText, $joinedTpl);
        $msg       = ($filled === $joinedTpl)
            ? $filled . "\n\n" . emoji(1) . $rewardText
            : $filled;
        $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
    }

    /**
     * Send the bot-verification task prompt: "start this bot" URL button +
     * "تأیید" verify button (callback 'botverify_task_<taskId>').
     */
    private static function sendBotVerifyPrompt(int $userId, array $task, TelegramBot $bot, array $config): void
    {
        $bvId = isset($task['bot_verification_id']) ? (int)$task['bot_verification_id'] : 0;
        if ($bvId <= 0) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }
        $bv = Database::getBotVerification($bvId);
        if ($bv === null) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $username = preg_replace('/^@+/', '', $bv['bot_username']);
        $startUrl = 'https://t.me/' . $username . '?start=netrex';

        $text = Settings::getMessage('botverify_task_not_started', $config) . "\n\n";
        $text .= emoji(40) . ' ' . $bv['bot_username'];

        $kb = json_encode(['inline_keyboard' => [
            [[
                // F12: admin-editable template — str_replace, not sprintf.
                'text' => str_replace('%s', (string)$bv['bot_username'], Settings::getMessage('botverify_start_button', $config)),
                'url'  => $startUrl,
                'style'=> 'success',
                'icon_custom_emoji_id' => emoji_id(40),
            ]],
            [[
                'text' => ' تأیید',
                'callback_data' => 'botverify_task_' . $task['id'],
                'style' => 'primary',
                'icon_custom_emoji_id' => emoji_id(13),
            ]],
            [[
                'text' => 'برگشت به منوی اصلی',
                'callback_data' => 'back_to_main_menu',
                'style' => 'danger',
                'icon_custom_emoji_id' => emoji_id(11),
            ]],
        ]]);

        $bot->sendMessage($userId, $text, $kb, 'Markdown');
    }

    /**
     * Create a bot-verification task (type='bot') and link the bot_verification
     * row to it. Mirrors createChannelTask.
     */
    public static function createBotTask(int $botVerificationId, string $title, ?string $description, string $rewardType, int $rewardAmount, ?int $createdBy, int $rewardGameTokens = 0, int $rewardToman = 0, int $rewardNtx = 0): int
    {
        $bv = Database::getBotVerification($botVerificationId);
        $target = $bv !== null ? (string)($bv['bot_username'] ?? '') : '';

        // Insert the task with type='bot'.
        $taskId = Database::createTask(
            'bot',
            $title,
            $description,
            ($target !== '' ? $target : null),
            null,
            $rewardType,
            $rewardAmount,
            $createdBy,
            $rewardGameTokens,
            $rewardToman,
            $rewardNtx
        );

        // Set bot_verification_id (additive column).
        $db = Database::getConnection();
        $db->prepare("UPDATE `tasks` SET `bot_verification_id` = :bvid WHERE `id` = :id")
           ->execute([':bvid' => $botVerificationId, ':id' => $taskId]);

        // Move the bot_verification into task mode + reward snapshot + back-link.
        Database::updateBotVerification($botVerificationId, $bv['bot_username'] ?? '', $bv['api_url'] ?? '', $bv['api_key'] ?? '', 'task', $rewardType, $rewardAmount);
        Database::linkBotVerificationTask($botVerificationId, $taskId);

        Database::logEvent('task_created', null, [
            'task_id'     => $taskId,
            'type'        => 'bot',
            'bot_verification_id' => $botVerificationId,
            'reward_game_tokens' => $rewardGameTokens,
            'reward_toman' => $rewardToman,
            'reward_ntx' => $rewardNtx,
            'created_by'  => $createdBy,
        ]);

        return $taskId;
    }

    /**
     * User tapped "تأیید عضویت" for a channel task. Verify via getChatMember
     * and grant the reward on success.
     */
    public static function verifyChannelTask(int $userId, int $taskId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null || ($task['type'] ?? '') !== 'channel') {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        // Already completed? Don't double-reward.
        $existing = Database::getUserTask($userId, $taskId);
        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            // F12: admin-editable template — str_replace, not sprintf.
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, null, 'Markdown');
            return;
        }

        $channelId = isset($task['channel_id']) ? (int)$task['channel_id'] : 0;
        if ($channelId <= 0) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $channel = Database::getChannelById($channelId);
        if ($channel === null) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $member = self::getChatMember($bot, $channel, $userId);
        $status = is_array($member) ? (string)($member['status'] ?? '') : '';

        // 'creator' / 'administrator' / 'member' count as joined. 'left',
        // 'kicked', 'restricted' (treated conservatively) do NOT.
        $joined = in_array($status, ['creator', 'administrator', 'member'], true);

        if (!$joined) {
            $bot->sendMessage(
                $userId,
                Settings::getMessage('task_channel_not_joined', $config),
                null,
                'Markdown'
            );
            return;
        }

        // Joined — complete + reward.
        // F2: defensive row ensure (idempotent INSERT IGNORE — also covers
        // prompts rendered before the row-creation fix shipped), then the
        // GUARDED completion: the reward is granted only when THIS request
        // won the transition (rowCount===1); a loser gets the "already
        // completed" message instead of a second payout.
        Database::ensureUserTaskStarted($userId, $taskId);
        if (Database::completeUserTask($userId, $taskId) !== 1) {
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, null, 'Markdown');
            return;
        }
        $rewardText = self::grantTaskReward($userId, $task);

        Database::logEvent('task_completed', $userId, [
            'task_id'       => $taskId,
            'type'          => 'channel',
        ]);

        // F12: fill the template's "%d %s" reward tail with the combined
        // reward string (the raw placeholders previously leaked into the
        // message); if the admin removed the placeholders, append the reward.
        $joinedTpl = Settings::getMessage('task_channel_joined', $config);
        $filled    = str_replace('%d %s', $rewardText, $joinedTpl);
        $msg       = ($filled === $joinedTpl)
            ? $filled . "\n\n" . emoji(1) . $rewardText
            : $filled;
        $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
    }

    /**
     * Called by MessageHandler when a user (status='waiting_task_<taskId>')
     * sends a text message as their answer to a text task.
     *
     * Records the pending submission, confirms to the user, notifies admins,
     * and resets the user's conversation status back to the main menu so they
     * don't get stuck in the waiting state.
     */
    public static function submitTextAnswer(int $userId, int $taskId, string $answerText, ?string $answerFileId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null || ($task['type'] ?? '') !== 'text') {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        // createUserTask upserts on (user_id, task_id) UNIQUE INDEX, so a
        // prior rejected row is overwritten with the new pending submission.
        // Chokepoint guard: a COMPLETED row must never be reset — a stale
        // waiting_task_ status (prompt rendered before an admin approved)
        // would otherwise upsert completed→pending and a fresh approval
        // would re-pay the reward (startTask's own gate cannot close this
        // race because an old prompt may already be in flight).
        $existing = Database::getUserTask($userId, $taskId);
        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            // v2.9.45 (R6-93/LOW): targeted status write.
            Database::updateUserColumns($userId, ['status' => 'main']);
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }
        Database::createUserTask($userId, $taskId, 'pending', $answerText, $answerFileId);

        Database::logEvent('task_text_submitted', $userId, [
            'task_id' => $taskId,
        ]);

        // Reset the user's status back to the main menu.
        // v2.9.45 (R6-93/LOW): targeted status write (was double full-row save).
        Database::updateUserColumns($userId, ['status' => 'main']);

        // F12: admin-editable template — str_replace, not sprintf.
        $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('task_text_submitted', $config));
        $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');

        // Notify admins (tasks permission, fallback to support).
        self::notifyAdminsTextAnswer($userId, $task, $answerText, $bot);
    }

    /**
     * Admin approves or rejects a pending text-task answer.
     *
     * On approval: grant the reward via Wallet::credit and notify the user.
     * On rejection: notify the user (with the reason); the user may retry by
     * starting the task again.
     */
    public static function reviewTextAnswer(int $userTaskId, bool $approved, ?int $adminId, ?string $reason, TelegramBot $bot, array $config): void
    {
        $userTask = Database::getUserTaskById($userTaskId);
        if ($userTask === null || ($userTask['status'] ?? '') !== 'pending') {
            return;
        }

        // F2: guarded review — the row only transitions while still
        // 'pending'; on 0 another admin already reviewed this answer (the
        // old unguarded UPDATE double-approved = double reward payout). No
        // reward, log entry or notification on a lost race.
        if (Database::reviewUserTask($userTaskId, $approved, $adminId, $reason) !== 1) {
            return;
        }

        $userId = (int)$userTask['user_id'];
        $taskId = (int)$userTask['task_id'];
        $title  = (string)($userTask['title'] ?? '');

        if ($approved) {
            $rewardText = self::grantTaskReward($userId, $userTask);

            Database::logEvent('task_completed', $userId, [
                'task_id'        => $taskId,
                'type'           => 'text',
                'user_task_id'   => $userTaskId,
                'admin_id'       => $adminId,
            ]);

            // grantTaskReward() returns a pre-formatted, possibly
            // multi-currency reward string ("500 تومان + 10 توکن بازی"), so
            // the template's numeric "%d %s" tail cannot be filled with
            // separate amount/currency args. F12: str_replace-based
            // interpolation (admin-editable template — a stray % must not
            // corrupt sprintf output). The "%d %s" tail is replaced with the
            // reward text as ONE unit first, then the remaining lone "%s"
            // (task title); when the tail is absent the placeholders are
            // consumed left to right.
            $tpl = (string)Settings::getMessage('task_text_approved', $config);
            if (strpos($tpl, '%d %s') !== false) {
                $tpl = str_replace('%d %s', '%s', $tpl);
            }
            $msg = self::interpolate($tpl, $title, $rewardText);
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }

        Database::logEvent('task_text_rejected', $userId, [
            'task_id'      => $taskId,
            'user_task_id' => $userTaskId,
            'admin_id'     => $adminId,
            'reason'       => $reason,
        ]);

        $reasonText = ($reason === null || $reason === '') ? '—' : $reason;
        // F12: admin-editable template — str_replace-based interpolation.
        $msg = self::interpolate(
            Settings::getMessage('task_text_rejected', $config),
            $title,
            $reasonText
        );
        $bot->sendMessage($userId, $msg, null, 'Markdown');
    }

    /**
     * Pending text-task answers for the admin review panel.
     */
    public static function getPendingTextAnswers(): array
    {
        return Database::getPendingTextTasks();
    }

    /**
     * Completed tasks for a user (shown in their profile / history view).
     */
    public static function getCompletedForUser(int $userId): array
    {
        return Database::getCompletedTasksForUser($userId);
    }

    /**
     * Grant a task's multi-currency reward to a user. Credits all three
     * currencies (game_token, toman, ntx); amounts of 0 are skipped.
     * Returns a combined reward description string for display.
     */
    private static function grantTaskReward(int $userId, array $task): string
    {
        $parts = [];
        $gt = (int)($task['reward_game_tokens'] ?? 0);
        $to = (int)($task['reward_toman'] ?? 0);
        $nx = (int)($task['reward_ntx'] ?? 0);

        // Backward-compat: if all three are 0 but reward_amount > 0, use the
        // old single-currency field.
        if ($gt === 0 && $to === 0 && $nx === 0) {
            $legacyAmount = (int)($task['reward_amount'] ?? 0);
            if ($legacyAmount > 0) {
                $legacyType = (string)($task['reward_type'] ?? 'game_token');
                Wallet::credit($userId, $legacyType, $legacyAmount, 'task_reward', 'task', (int)$task['id']);
                $parts[] = formatReward($legacyAmount, $legacyType, []);
            }
        } else {
            if ($gt > 0) {
                Wallet::credit($userId, 'game_token', $gt, 'task_reward', 'task', (int)$task['id']);
                $parts[] = $gt . ' ' . Settings::getMessage('currency_game_token', []);
            }
            if ($to > 0) {
                Wallet::credit($userId, 'toman', $to, 'task_reward', 'task', (int)$task['id']);
                $parts[] = $to . ' ' . Settings::getMessage('currency_toman', []);
            }
            if ($nx > 0) {
                Wallet::credit($userId, 'ntx', $nx, 'task_reward', 'task', (int)$task['id']);
                $parts[] = $nx . ' ' . Settings::getMessage('currency_ntx', []);
            }
        }
        return $parts ? implode(' + ', $parts) : '—';
    }

    public static function formatTaskRow(array $task, array $config): string
    {
        $title  = (string)($task['title'] ?? '');
        $type   = (string)($task['type'] ?? '');
        // T46-l: labels for the seven configurable types.
        $typeLabels = ['channel' => 'کانال', 'text' => 'متنی', 'bot' => 'ربات',
            'group_add' => 'اد گروه', 'group_msgs' => 'پیام گروه', 'race' => 'مسابقه',
            'referrals' => 'دعوت', 'play_games' => 'بازی', 'win_games' => 'برد',
            'manual' => 'تاییدی'];
        $typeLabel = $typeLabels[$type] ?? 'متنی';

        // Build reward text from the three currency amounts.
        $parts = [];
        $gt = (int)($task['reward_game_tokens'] ?? 0);
        $to = (int)($task['reward_toman'] ?? 0);
        $nx = (int)($task['reward_ntx'] ?? 0);
        if ($gt > 0) $parts[] = $gt . ' ' . Settings::getMessage('currency_game_token', $config);
        if ($to > 0) $parts[] = $to . ' ' . Settings::getMessage('currency_toman', $config);
        if ($nx > 0) $parts[] = $nx . ' ' . Settings::getMessage('currency_ntx', $config);
        // Backward-compat
        if (empty($parts)) {
            $legacyAmount = (int)($task['reward_amount'] ?? 0);
            if ($legacyAmount > 0) {
                $parts[] = $legacyAmount . ' ' . self::currencyName((string)($task['reward_type'] ?? 'game_token'), $config);
            }
        }
        $rewardText = $parts ? implode(' + ', $parts) : 'بدون پاداش';

        // T46-l: one-line dynamic summary of what the task asks, rendered
        // from task_config (so menu rows explain the goal, not just the type).
        $summary = self::taskSummary($task);
        $sumText = $summary !== '' ? $summary . ' — ' : '';

        return "• {$title} — {$sumText}پاداش: {$rewardText} ({$typeLabel})";
    }

    /**
     * Format a reward as "{amount} {currencyName}" using Wallet::formatAmount.
     */
    public static function formatRewardText(string $rewardType, int $rewardAmount, array $config): string
    {
        return Wallet::formatAmount($rewardAmount, $rewardType, $config);
    }

    // ─── T46-l: configurable task types engine ───────────────────────────

    /** T46-l: types whose progress is watched inside the target group. */
    private const GROUP_TYPES = ['group_add', 'group_msgs', 'race'];

    /** T46-l: every type added in this round. */
    private const ALL_NEW_TYPES = [
        'group_add', 'group_msgs', 'race', 'referrals', 'play_games', 'win_games', 'manual',
    ];

    public static function isNewType(string $type): bool
    {
        return in_array($type, self::ALL_NEW_TYPES, true);
    }

    /**
     * T46-l: keep only the task_config keys a type actually understands,
     * clamped — the panel/API can post anything, the engine stores clean data.
     */
    private static function normalizeConfigParams(string $type, array $params): array
    {
        $countTypes = ['group_add', 'group_msgs', 'referrals', 'play_games', 'win_games', 'race'];
        $out = [];
        if (in_array($type, $countTypes, true)) {
            $out['count'] = max(1, min(100000, (int)($params['count'] ?? 1)));
        }
        if ($type === 'race') {
            $out['action'] = (($params['action'] ?? 'message') === 'add') ? 'add' : 'message';
            $out['hours'] = max(1, min(8760, (int)($params['hours'] ?? 24)));
            $out['place'] = max(1, min(10, (int)($params['place'] ?? 1)));
            $out['race_key'] = self::sanitizeRaceKey((string)($params['race_key'] ?? ''));
        }
        return $out;
    }

    private static function sanitizeRaceKey(string $key): string
    {
        $trimmed = substr($key, 0, 32);
        $clean = preg_replace('/[^A-Za-z0-9_-]/', '', $trimmed !== false ? $trimmed : '');
        return is_string($clean) ? $clean : '';
    }

    /**
     * T46-l: Persian ordinal for a podium place (1..10).
     */
    private static function placeOrdinal(int $place): string
    {
        $ords = [1 => 'اول', 2 => 'دوم', 3 => 'سوم', 4 => 'چهارم', 5 => 'پنجم',
            6 => 'ششم', 7 => 'هفتم', 8 => 'هشتم', 9 => 'نهم', 10 => 'دهم'];
        return $ords[$place] ?? faDigits($place);
    }

    /**
     * T46-l: one-line dynamic summary of what the task asks — rendered from
     * task_config, so tasks-menu rows and the admin table always describe
     * the real goal with the real numbers (never hardcoded).
     */
    public static function taskSummary(array $task): string
    {
        $type = (string)($task['type'] ?? '');
        if (!self::isNewType($type)) return '';
        $conf = self::taskConfig($task['task_config'] ?? null, $type);
        $n = faDigits((int)($conf['count'] ?? 1));
        switch ($type) {
            case 'group_add':
                return 'افزودن ' . $n . ' عضو به گروه هدف';
            case 'group_msgs':
                return 'ارسال ' . $n . ' پیام در گروه هدف';
            case 'race':
                $ord = self::placeOrdinal((int)($conf['place'] ?? 1));
                if (($conf['action'] ?? 'message') === 'add') {
                    return 'مسابقه اد گروه — جایگاه ' . $ord . ' (' . $n . ' عضو)';
                }
                return 'مسابقه پیام گروه — جایگاه ' . $ord;
            case 'referrals':
                return 'دعوت ' . $n . ' دوست به ربات';
            case 'play_games':
                return 'اتمام ' . $n . ' بازی';
            case 'win_games':
                return 'برد ' . $n . ' بازی';
            case 'manual':
                return 'انجام آزاد — تایید ادمین';
        }
        return '';
    }

    /**
     * T46-l: combined multi-currency reward text for the description screens
     * (same three-column source of truth the panel edits; faDigits output).
     */
    public static function rewardText(array $task, array $config): string
    {
        $parts = [];
        $gt = (int)($task['reward_game_tokens'] ?? 0);
        $to = (int)($task['reward_toman'] ?? 0);
        $nx = (int)($task['reward_ntx'] ?? 0);
        if ($gt > 0) $parts[] = faDigits($gt) . ' ' . Settings::getMessage('currency_game_token', $config);
        if ($to > 0) $parts[] = faDigits($to) . ' ' . Settings::getMessage('currency_toman', $config);
        if ($nx > 0) $parts[] = faDigits($nx) . ' ' . Settings::getMessage('currency_ntx', $config);
        if (empty($parts)) {
            $legacyAmount = (int)($task['reward_amount'] ?? 0);
            if ($legacyAmount > 0) {
                $parts[] = faDigits($legacyAmount) . ' ' . self::currencyName((string)($task['reward_type'] ?? 'game_token'), $config);
            }
        }
        return $parts ? implode(' + ', $parts) : 'بدون پاداش';
    }

    /**
     * T46-l: display name of the task's target group (channel row first,
     * then the stored target, then a neutral fallback).
     */
    private static function groupDisplayName(array $task): string
    {
        $channelId = isset($task['channel_id']) ? (int)$task['channel_id'] : 0;
        if ($channelId > 0) {
            $channel = Database::getChannelById($channelId);
            if ($channel !== null) {
                $name = trim((string)($channel['display_name'] ?? ''));
                if ($name !== '') return $name;
                $handle = trim((string)($channel['channel_handle'] ?? ''));
                if ($handle !== '') return $handle;
            }
        }
        $target = trim((string)($task['target'] ?? ''));
        return $target !== '' ? $target : 'گروه هدف';
    }

    /**
     * T46-l: resolvable t.me link for the target group (join_link first,
     * then the public handle). Empty string when only a numeric id exists.
     */
    private static function groupLink(array $task): string
    {
        $channelId = isset($task['channel_id']) ? (int)$task['channel_id'] : 0;
        if ($channelId > 0) {
            $channel = Database::getChannelById($channelId);
            if ($channel !== null) {
                $link = trim((string)($channel['join_link'] ?? ''));
                if ($link !== '') return $link;
                $handle = ltrim(trim((string)($channel['channel_handle'] ?? '')), '@');
                if ($handle !== '') return 'https://t.me/' . $handle;
            }
        }
        return '';
    }

    /**
     * T46-l: the full in-bot description screen of a configurable task:
     * what to do (per type, from task_config), how it is verified, the
     * multi-currency reward and the user's live progress. NOTHING hardcoded —
     * every number comes from the config the admin set (T46-i contract).
     */
    public static function describeTaskForUser(int $userId, array $task, array $config): string
    {
        $type   = (string)($task['type'] ?? '');
        $conf   = self::taskConfig($task['task_config'] ?? null, $type);
        $reward = self::rewardText($task, $config);
        $title  = (string)($task['title'] ?? '');
        $desc   = trim((string)($task['description'] ?? ''));
        $target = max(1, (int)($conf['count'] ?? 1));
        $groupName = self::groupDisplayName($task);

        $lines = [];
        $lines[] = emoji(49) . ' ' . $title;
        if ($desc !== '') {
            $lines[] = '';
            $lines[] = $desc;
        }
        $lines[] = '';

        switch ($type) {
            case 'group_add': {
                $done = Database::countTaskEvents((int)$task['id'], $userId);
                $lines[] = emoji(35) . ' شرح تسک:';
                $lines[] = '• ' . faDigits($target) . ' نفر به گروه «' . $groupName . '» اضافه کنید.';
                $lines[] = '• هر عضو جدید فقط یک بار شمرده می‌شود و ثبت پیشرفت کامل خودکار است.';
                $lines[] = '• پاداش به‌محض رسیدن به حد نصاب، خودکار واریز می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                $lines[] = emoji(35) . ' پیشرفت شما: ' . faDigits(min($done, $target)) . ' از ' . faDigits($target);
                break;
            }
            case 'group_msgs': {
                $done = Database::countTaskEvents((int)$task['id'], $userId);
                $lines[] = emoji(35) . ' شرح تسک:';
                $lines[] = '• ' . faDigits($target) . ' پیام در گروه «' . $groupName . '» بفرستید.';
                $lines[] = '• هر پیام متنی جدید یک بار شمرده می‌شود؛ دستورها و سیستم شمرده نمی‌شوند.';
                $lines[] = '• پاداش به‌محض رسیدن به حد نصاب، خودکار واریز می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                $lines[] = emoji(35) . ' پیشرفت شما: ' . faDigits(min($done, $target)) . ' از ' . faDigits($target);
                break;
            }
            case 'race': {
                $place = (int)($conf['place'] ?? 1);
                $hours = (int)($conf['hours'] ?? 24);
                $ord = self::placeOrdinal($place);
                $lines[] = emoji(0) . ' مسابقه سرعتی!';
                if (($conf['action'] ?? 'message') === 'add') {
                    $lines[] = '• ' . $ord . 'ین کسی باشید که ' . faDigits($target) . ' نفر را به گروه «' . $groupName . '» اضافه کند.';
                } else {
                    $lines[] = '• ' . $ord . 'ین کسی باشید که در گروه «' . $groupName . '» پیام بفرستد.';
                }
                $lines[] = '• مهلت: ' . faDigits($hours) . ' ساعت پس از انتشار تسک.';
                $lines[] = '• جایگاه شما به‌صورت خودکار ثبت می‌شود و پیام برد به صورت خصوصی برایتان می‌آید.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش این جایگاه: ' . $reward;
                break;
            }
            case 'referrals': {
                $done = Database::getReferralCount($userId);
                $lines[] = emoji(35) . ' شرح تسک:';
                $lines[] = '• ' . faDigits($target) . ' دوست را با لینک دعوت خودتان به ربات دعوت کنید.';
                $lines[] = '• همه دعوت‌های قبلی شما هم محاسبه می‌شوند.';
                $lines[] = '• پاداش به‌محض رسیدن به حد نصاب، خودکار واریز می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                $lines[] = emoji(35) . ' دعوت‌های شما: ' . faDigits($done) . ' — حد نصاب: ' . faDigits($target);
                break;
            }
            case 'play_games': {
                $done = Database::countFinishedGames($userId);
                $lines[] = emoji(0) . ' شرح تسک:';
                $lines[] = '• ' . faDigits($target) . ' بازی را تا پایان بازی کنید (تک‌نفره یا دونفره).';
                $lines[] = '• بررسی به‌صورت خودکار از روی تاریخچه بازی‌های شما انجام می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                $lines[] = emoji(0) . ' تاکنون: ' . faDigits(min($done, $target)) . ' از ' . faDigits($target) . ' بازی';
                break;
            }
            case 'win_games': {
                $done = Database::countWonGames($userId);
                $lines[] = emoji(1) . ' شرح تسک:';
                $lines[] = '• ' . faDigits($target) . ' بازی را ببرید (برنده شدن در بازی‌ها).';
                $lines[] = '• بررسی به‌صورت خودکار از روی تاریخچه بردهای شما انجام می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                $lines[] = emoji(1) . ' تاکنون: ' . faDigits(min($done, $target)) . ' از ' . faDigits($target) . ' برد';
                break;
            }
            case 'manual': {
                $lines[] = emoji(49) . ' شرح تسک:';
                $lines[] = '• طبق توضیح بالا عمل کنید، سپس دکمه «تایید انجام تسک» را بزنید.';
                $lines[] = '• پس از تایید ادمین، پاداش به حساب شما واریز می‌شود.';
                $lines[] = '';
                $lines[] = emoji(8) . ' پاداش: ' . $reward;
                break;
            }
        }

        return implode("\n", $lines);
    }

    /**
     * T46-l: entry screen for the seven configurable types (called from
     * startTask). manual shows the confirm button; the rest show the live
     * progress + a verify button, and group-based types add the group link.
     */
    private static function startConfiguredTask(int $userId, array $task, TelegramBot $bot, array $config): void
    {
        $taskId = (int)$task['id'];
        $type   = (string)$task['type'];
        $existing = Database::getUserTask($userId, $taskId);

        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }

        if ($type === 'manual') {
            if ($existing !== null && ($existing['status'] ?? '') === 'pending') {
                $bot->sendMessage(
                    $userId,
                    emoji(49) . ' تایید انجام این تسک ثبت شده و در انتظار بررسی ادمین است. لطفاً تا پایان بررسی صبر کنید.',
                    self::backKeyboard($config),
                    'Markdown'
                );
                return;
            }
            $kb = [
                [[
                    'text' => ' تایید انجام تسک',
                    'callback_data' => 'task_confirm_' . $taskId,
                    'style' => 'success',
                    'icon_custom_emoji_id' => emoji_id(13),
                ]],
                [[
                    'text' => 'برگشت به منوی اصلی',
                    'callback_data' => 'back_to_main_menu',
                    'style' => 'danger',
                    'icon_custom_emoji_id' => emoji_id(11),
                ]],
            ];
            $bot->sendMessage($userId, self::describeTaskForUser($userId, $task, $config), json_encode(['inline_keyboard' => $kb]), 'Markdown');
            return;
        }

        // Progress types: idempotently create the in_progress row (INSERT
        // IGNORE — never resets an existing pending/completed row).
        Database::ensureUserTaskStarted($userId, $taskId);

        $kb = [];
        $groupUrl = self::groupLink($task);
        if (in_array($type, self::GROUP_TYPES, true) && $groupUrl !== '') {
            $joinBtn = [
                'text' => ' ' . Settings::getMessage('join_button', $config),
                'url'  => $groupUrl,
                'style' => 'success',
            ];
            $joinEmoji = emoji_id(16);
            if ($joinEmoji !== null) $joinBtn['icon_custom_emoji_id'] = $joinEmoji;
            $kb[] = [$joinBtn];
        }
        $checkLabel = ($type === 'race') ? ' بررسی وضعیت' : ' بررسی و دریافت پاداش';
        $kb[] = [[
            'text' => $checkLabel,
            'callback_data' => 'task_check_' . $taskId,
            'style' => 'primary',
            'icon_custom_emoji_id' => emoji_id(13),
        ]];
        $kb[] = [[
            'text' => 'برگشت به منوی اصلی',
            'callback_data' => 'back_to_main_menu',
            'style' => 'danger',
            'icon_custom_emoji_id' => emoji_id(11),
        ]];

        $bot->sendMessage($userId, self::describeTaskForUser($userId, $task, $config), json_encode(['inline_keyboard' => $kb]), 'Markdown');
    }

    /**
     * T46-l: «بررسی و دریافت پاداش» / «بررسی وضعیت» — recount the user's
     * progress from the source of truth and complete + pay when the goal is
     * met (verification is automatic either way; the button is the user's
     * immediate feedback channel).
     */
    public static function checkProgressTask(int $userId, int $taskId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null || !self::isNewType((string)($task['type'] ?? ''))) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }
        $type = (string)$task['type'];
        $existing = Database::getUserTask($userId, $taskId);
        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }

        $conf = self::taskConfig($task['task_config'] ?? null, $type);
        $target = max(1, (int)($conf['count'] ?? 1));

        if ($type === 'race') {
            $key = (string)($conf['race_key'] ?? '');
            if ($key === '') $key = 't' . $taskId;
            $starts = (int)($conf['starts'] ?? 0);
            $hours = (int)($conf['hours'] ?? 24);
            $taken = Database::countRaceFinishers($key);
            if ($starts > 0 && time() > $starts + $hours * 3600) {
                $bot->sendMessage($userId, emoji(26) . ' مهلت این مسابقه تمام شده است.', self::backKeyboard($config), 'Markdown');
                return;
            }
            $bot->sendMessage(
                $userId,
                emoji(0) . ' مسابقه در جریان است!' . "\n" . emoji(35) . ' تا این لحظه ' . faDigits($taken) . ' نفر جایگاه گرفته‌اند.' . "\n" . emoji(49) . ' کافیست شرط مسابقه را زودتر از بقیه انجام دهید؛ نتیجه به‌صورت خودکار ثبت می‌شود.',
                self::backKeyboard($config),
                'Markdown'
            );
            return;
        }

        $done = 0;
        switch ($type) {
            case 'group_add':
            case 'group_msgs':
                $done = Database::countTaskEvents($taskId, $userId);
                break;
            case 'referrals':
                $done = Database::getReferralCount($userId);
                break;
            case 'play_games':
                $done = Database::countFinishedGames($userId);
                break;
            case 'win_games':
                $done = Database::countWonGames($userId);
                break;
        }

        if ($done >= $target) {
            Database::ensureUserTaskStarted($userId, $taskId);
            if (Database::completeUserTask($userId, $taskId) !== 1) {
                $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
                $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
                return;
            }
            $rewardText = self::grantTaskReward($userId, $task);
            Database::logEvent('task_completed', $userId, ['task_id' => $taskId, 'type' => $type, 'via' => 'check']);
            $msg = emoji(1) . ' تسک «' . (string)$task['title'] . '» کامل شد!' . "\n" . emoji(8) . ' پاداش: ' . $rewardText;
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }

        $msg = emoji(49) . ' ' . (string)$task['title'] . "\n"
            . emoji(35) . ' پیشرفت شما: ' . faDigits(min($done, $target)) . ' از ' . faDigits($target) . "\n\n"
            . 'به محض رسیدن به حد نصاب، پاداش به‌صورت خودکار واریز می‌شود.';
        $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
    }

    /**
     * T46-l: «تایید انجام تسک» — the user asserts they finished a manual
     * task. Creates a pending review row that flows through the SAME admin
     * pipeline as text answers (panel queue → approve/reject → reward).
     */
    public static function confirmManualTask(int $userId, int $taskId, TelegramBot $bot, array $config): void
    {
        $task = Database::getTask($taskId);
        if ($task === null || ($task['type'] ?? '') !== 'manual') {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }
        $existing = Database::getUserTask($userId, $taskId);
        if ($existing !== null && ($existing['status'] ?? '') === 'completed') {
            $msg = str_replace('%s', (string)$task['title'], Settings::getMessage('tasks_completed', $config));
            $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
            return;
        }
        if ($existing !== null && ($existing['status'] ?? '') === 'pending') {
            $bot->sendMessage(
                $userId,
                emoji(49) . ' تایید انجام این تسک قبلا ثبت شده و در انتظار بررسی ادمین است.',
                self::backKeyboard($config),
                'Markdown'
            );
            return;
        }
        // Rejected before? createUserTask's upsert overwrites the rejected
        // row — retry stays possible (same semantics as text tasks).
        Database::createUserTask($userId, $taskId, 'pending', '(تایید انجام تسک توسط کاربر)', null);
        Database::logEvent('task_text_submitted', $userId, ['task_id' => $taskId, 'via' => 'manual_confirm']);
        $msg = emoji(1) . ' انجام تسک «' . (string)$task['title'] . '» ثبت شد و در انتظار بررسی ادمین است.' . "\n" . emoji(8) . ' پس از تایید، پاداش به حساب شما واریز می‌شود.';
        $bot->sendMessage($userId, $msg, self::backKeyboard($config), 'Markdown');
        self::notifyAdminsTextAnswer($userId, $task, '(تایید انجام تسک توسط کاربر)', $bot);
    }

    /**
     * T46-l: group-side hook — MessageHandler calls this for EVERY group
     * message (before command routing) so service messages are seen too.
     * Attributes progress for group_add / group_msgs / race tasks whose
     * target channel matches this chat. All replies go to the actor's
     * PRIVATE chat and only on real state changes — groups are never
     * spammed and commands never count.
     */
    public static function onGroupMessage(int $chatId, string $chatUsername, int $fromUserId, array $message, TelegramBot $bot, array $config): void
    {
        if ($fromUserId <= 0) return;
        $handle = ($chatUsername !== '' ? '@' . $chatUsername : '');
        $tasks = Database::getGroupTasksForChat((string)$chatId, $handle);
        if (empty($tasks)) return;

        $newMembers = $message['new_chat_members'] ?? null;
        if (is_array($newMembers) && !empty($newMembers)) {
            $addedIds = [];
            foreach ($newMembers as $nm) {
                $uid = (int)($nm['id'] ?? 0);
                if ($uid > 0 && empty($nm['is_bot'])) $addedIds[] = $uid;
            }
            if (empty($addedIds)) return;
            foreach ($tasks as $task) {
                $type = (string)$task['type'];
                $conf = self::taskConfig($task['task_config'] ?? null, $type);
                if ($type === 'group_add') {
                    self::bumpGroupAdds(
                        $fromUserId, $task, $conf, $addedIds,
                        'gadd:' . (int)$task['id'] . ':', $bot, $config, false
                    );
                } elseif ($type === 'race' && ($conf['action'] ?? 'message') === 'add') {
                    $key = (string)($conf['race_key'] ?? '');
                    if ($key === '') $key = 't' . (int)$task['id'];
                    self::bumpGroupAdds(
                        $fromUserId, $task, $conf, $addedIds,
                        'gaddr:' . $key . ':', $bot, $config, true
                    );
                }
            }
            return;
        }

        // Plain message path — only real text messages count; commands don't.
        $text = trim((string)($message['text'] ?? ''));
        if ($text === '' || str_starts_with($text, '/')) return;
        $msgId = (int)($message['message_id'] ?? 0);

        foreach ($tasks as $task) {
            $type = (string)$task['type'];
            $conf = self::taskConfig($task['task_config'] ?? null, $type);
            if ($type === 'group_msgs') {
                if ($msgId <= 0) continue;
                $key = 'gmsg:' . (int)$task['id'] . ':' . $chatId . ':' . $msgId;
                if (Database::recordTaskEvent((int)$task['id'], $fromUserId, $key)) {
                    self::maybeCompleteProgressTask($fromUserId, $task, 'group_msgs', $bot, $config);
                }
            } elseif ($type === 'race' && ($conf['action'] ?? 'message') === 'message') {
                self::raceFinish($fromUserId, $task, $bot, $config);
            }
        }
    }

    /**
     * T46-l: count one batch of group adds for one actor (dedupe per added
     * member) and drive either the plain group_add completion or the
     * race(add) finisher flow.
     */
    private static function bumpGroupAdds(int $adderId, array $task, array $conf, array $addedIds, string $keyPrefix, TelegramBot $bot, array $config, bool $isRace): void
    {
        $taskId = (int)$task['id'];
        $insertedAny = false;
        foreach ($addedIds as $addedId) {
            $key = $keyPrefix . $adderId . ':' . $addedId;
            if (Database::recordTaskEvent($taskId, $adderId, $key)) $insertedAny = true;
        }
        if (!$insertedAny) return;

        if ($isRace) {
            $target = max(1, (int)($conf['count'] ?? 1));
            $done = Database::countTaskEventsByPrefix($keyPrefix . $adderId . ':', $adderId);
            if ($done >= $target) {
                self::raceFinish($adderId, $task, $bot, $config);
            }
            return;
        }
        self::maybeCompleteProgressTask($adderId, $task, 'group_add', $bot, $config);
    }

    /**
     * T46-l: complete a count-based task for the user when the goal is met.
     * Guarded by completeUserTask (exactly-once) and all sends are wrapped —
     * this runs inside group message processing and must never throw.
     */
    private static function maybeCompleteProgressTask(int $userId, array $task, string $via, TelegramBot $bot, array $config): void
    {
        try {
            $taskId = (int)$task['id'];
            $conf = self::taskConfig($task['task_config'] ?? null, (string)$task['type']);
            $target = max(1, (int)($conf['count'] ?? 1));
            $done = Database::countTaskEvents($taskId, $userId);
            if ($done < $target) return;
            Database::ensureUserTaskStarted($userId, $taskId);
            if (Database::completeUserTask($userId, $taskId) !== 1) return;
            $rewardText = self::grantTaskReward($userId, $task);
            Database::logEvent('task_completed', $userId, ['task_id' => $taskId, 'type' => (string)$task['type'], 'via' => $via]);
            $msg = emoji(1) . ' تسک «' . (string)$task['title'] . '» کامل شد!' . "\n" . emoji(8) . ' پاداش: ' . $rewardText;
            $bot->sendMessage($userId, $msg, null, 'Markdown');
        } catch (\Throwable $e) {
            error_log('[T46-l] maybeCompleteProgressTask failed: ' . $e->getMessage());
        }
    }

    /**
     * T46-l: race finisher flow. The finisher marker (racefin:<key>:<uid>)
     * is INSERT IGNORE — exactly one ordinal per user. The ordinal selects
     * which active race task of the same race_key pays (its place field);
     * the paying task's own window is honored. Winners get a private
     * message; finishers without a defined place get one polite notice.
     */
    private static function raceFinish(int $userId, array $task, TelegramBot $bot, array $config): void
    {
        try {
            $conf = self::taskConfig($task['task_config'] ?? null, 'race');
            $key = (string)($conf['race_key'] ?? '');
            if ($key === '') $key = 't' . (int)$task['id'];
            $starts = (int)($conf['starts'] ?? 0);
            $hours = (int)($conf['hours'] ?? 24);
            if ($starts > 0 && time() > $starts + $hours * 3600) return; // expired — silent

            $finKey = 'racefin:' . $key . ':' . $userId;

            // T46-p: serialize the finisher INSERT + ordinal COUNT per race —
            // two same-instant finishers both counted the SAME ordinal
            // (REPEATABLE READ snapshot) and both were paid the same podium
            // place. On a lock timeout we fall back to the old unserialized
            // path rather than silently losing a finish.
            $raceLocked = false;
            try {
                $rl = Database::getConnection()->prepare("SELECT GET_LOCK(:ln, 5) AS l");
                $rl->execute([':ln' => 'race_' . $key]);
                $raceLocked = ((int)($rl->fetch()['l'] ?? 0)) === 1;
            } catch (\Throwable $e) {
                $raceLocked = false;
            }

            if ($raceLocked) {
                try {
                    if (!Database::recordTaskEvent((int)$task['id'], $userId, $finKey)) return; // already finished
                    $ordinal = Database::countRaceFinishers($key);
                } finally {
                    try { Database::getConnection()->prepare("SELECT RELEASE_LOCK(:ln) AS r")->execute([':ln' => 'race_' . $key]); } catch (\Throwable $e2) {}
                }
            } else {
                if (!Database::recordTaskEvent((int)$task['id'], $userId, $finKey)) return; // already finished
                $ordinal = Database::countRaceFinishers($key);
            }
            $races = Database::getActiveTasksByTypes(['race']);
            $paying = null;
            foreach ($races as $rt) {
                $rc = self::taskConfig($rt['task_config'] ?? null, 'race');
                $rk = (string)($rc['race_key'] ?? '');
                if ($rk === '') $rk = 't' . (int)$rt['id'];
                if ($rk === $key && (int)($rc['place'] ?? 1) === $ordinal) {
                    $paying = $rt;
                    break;
                }
            }

            if ($paying === null) {
                // No prize for this podium place — notice only for users who
                // consciously joined (have a user_tasks row in this race).
                $joined = false;
                foreach ($races as $rt) {
                    $rc = self::taskConfig($rt['task_config'] ?? null, 'race');
                    $rk = (string)($rc['race_key'] ?? '');
                    if ($rk === '') $rk = 't' . (int)$rt['id'];
                    if ($rk !== $key) continue;
                    $row = Database::getUserTask($userId, (int)$rt['id']);
                    if ($row !== null) { $joined = true; break; }
                }
                if ($joined) {
                    try {
                        $bot->sendMessage(
                            $userId,
                            emoji(35) . ' شما نفر ' . faDigits($ordinal) . ' شدید؛ متاسفانه برای این جایگاه جایزه‌ای تعریف نشده است.',
                            null,
                            'Markdown'
                        );
                    } catch (\Throwable $e) {}
                }
                return;
            }

            $pc = self::taskConfig($paying['task_config'] ?? null, 'race');
            $pStarts = (int)($pc['starts'] ?? 0);
            $pHours = (int)($pc['hours'] ?? 24);
            if ($pStarts > 0 && time() > $pStarts + $pHours * 3600) return; // paying task expired

            Database::ensureUserTaskStarted($userId, (int)$paying['id']);
            if (Database::completeUserTask($userId, (int)$paying['id']) !== 1) return;
            $rewardText = self::grantTaskReward($userId, $paying);
            Database::logEvent('task_completed', $userId, ['task_id' => (int)$paying['id'], 'type' => 'race', 'place' => $ordinal]);
            $msg = emoji(1) . ' تبریک! در مسابقه «' . (string)$paying['title'] . '» نفر ' . faDigits($ordinal) . ' شدید!' . "\n" . emoji(8) . ' پاداش: ' . $rewardText;
            $bot->sendMessage($userId, $msg, null, 'Markdown');
        } catch (\Throwable $e) {
            error_log('[T46-l] raceFinish failed: ' . $e->getMessage());
        }
    }

    /**
     * T46-l: referral-side hook — MessageHandler calls this after a REAL
     * referral payment so referral-count tasks auto-complete for the
     * referrer the moment the threshold is reached.
     */
    public static function onNewReferral(int $referrerId, TelegramBot $bot, array $config): void
    {
        try {
            $tasks = Database::getActiveTasksByTypes(['referrals']);
            if (empty($tasks)) return;
            $count = Database::getReferralCount($referrerId);
            foreach ($tasks as $task) {
                $conf = self::taskConfig($task['task_config'] ?? null, 'referrals');
                if ($count < max(1, (int)($conf['count'] ?? 1))) continue;
                Database::ensureUserTaskStarted($referrerId, (int)$task['id']);
                if (Database::completeUserTask($referrerId, (int)$task['id']) !== 1) continue;
                $rewardText = self::grantTaskReward($referrerId, $task);
                Database::logEvent('task_completed', $referrerId, ['task_id' => (int)$task['id'], 'type' => 'referrals', 'via' => 'referral_hook']);
                try {
                    $bot->sendMessage(
                        $referrerId,
                        emoji(1) . ' تسک «' . (string)$task['title'] . '» کامل شد!' . "\n" . emoji(8) . ' پاداش: ' . $rewardText,
                        null,
                        'Markdown'
                    );
                } catch (\Throwable $e) {}
            }
        } catch (\Throwable $e) {
            error_log('[T46-l] onNewReferral failed: ' . $e->getMessage());
        }
    }

    /**
     * T46-l: create a configurable task from the admin panel / API.
     * Group-based types reference the channel row (numeric id / handle /
     * join link) WITHOUT flipping it into task mode — several tasks may
     * share one group. The race window is anchored at creation time.
     *
     * @return int the new task id
     */
    public static function createConfiguredTask(string $type, string $title, ?string $description, array $params, int $channelId, int $rewardGameTokens, int $rewardToman, int $rewardNtx, ?int $createdBy): int
    {
        if (!self::isNewType($type)) {
            throw new \InvalidArgumentException('invalid_task_type');
        }
        $target = null;
        $linkChannel = in_array($type, self::GROUP_TYPES, true);
        if ($linkChannel) {
            $channel = Database::getChannelById($channelId);
            if ($channel === null) {
                throw new \InvalidArgumentException('channel_not_found');
            }
            $target = (string)($channel['channel_handle'] ?? '');
        }
        $conf = self::normalizeConfigParams($type, $params);
        if ($type === 'race') {
            $conf['starts'] = time(); // window anchor — fixed at creation
        }
        $taskId = Database::createTask(
            $type, $title, $description, $target,
            $linkChannel ? $channelId : null,
            'game_token', 0, $createdBy,
            $rewardGameTokens, $rewardToman, $rewardNtx
        );
        Database::updateTaskConfig($taskId, json_encode($conf, JSON_UNESCAPED_UNICODE));
        Database::logEvent('task_created', null, [
            'task_id' => $taskId,
            'type' => $type,
            'channel_id' => $linkChannel ? $channelId : null,
            'task_config' => $conf,
            'reward_game_tokens' => $rewardGameTokens,
            'reward_toman' => $rewardToman,
            'reward_ntx' => $rewardNtx,
            'created_by' => $createdBy,
        ]);
        return $taskId;
    }

    /**
     * T46-l: merge new params into an existing configurable task's config
     * (panel edit). The race 'starts' anchor is intentionally preserved so
     * editing a task does not silently restart its window.
     */
    public static function updateConfiguredTaskParams(int $taskId, array $params): void
    {
        $task = Database::getTask($taskId);
        if ($task === null) return;
        $type = (string)$task['type'];
        if (!self::isNewType($type)) return;
        $conf = self::taskConfig($task['task_config'] ?? null, $type);
        foreach (self::normalizeConfigParams($type, $params) as $k => $v) {
            $conf[$k] = $v;
        }
        Database::updateTaskConfig($taskId, json_encode($conf, JSON_UNESCAPED_UNICODE));
    }

    /**
     * T46-l: decode + clamp a task's stored config for rendering. Callers
     * always receive the full key set for the type — no null-guards needed.
     */
    public static function taskConfig(?string $json, string $type): array
    {
        $raw = [];
        if ($json !== null && $json !== '') {
            $decoded = json_decode($json, true);
            if (is_array($decoded)) $raw = $decoded;
        }
        $clamp = static function ($v, int $def, int $min, int $max): int {
            $n = (int)round((float)$v);
            if ($n < $min) $n = $min;
            if ($n > $max) $n = $max;
            return $n;
        };
        switch ($type) {
            case 'group_add':
            case 'group_msgs':
            case 'referrals':
            case 'play_games':
            case 'win_games':
                return ['count' => $clamp($raw['count'] ?? 1, 1, 1, 100000)];
            case 'race':
                $key = isset($raw['race_key']) ? (string)$raw['race_key'] : '';
                $cleanKey = self::sanitizeRaceKey($key);
                return [
                    'action' => (($raw['action'] ?? 'message') === 'add') ? 'add' : 'message',
                    'count' => $clamp($raw['count'] ?? 1, 1, 1, 100000),
                    'hours' => $clamp($raw['hours'] ?? 24, 1, 1, 8760),
                    'place' => $clamp($raw['place'] ?? 1, 1, 1, 10),
                    'race_key' => $cleanKey,
                    'starts' => max(0, (int)($raw['starts'] ?? 0)),
                ];
        }
        return [];
    }

    // ─── Internal helpers ────────────────────────────────────────────────

    /**
     * F12: interpolate an admin-editable config message template WITHOUT
     * sprintf — a stray '%' typed by an admin corrupts sprintf output (and
     * %d with a string argument misrenders). Placeholders (%s / %d) are
     * replaced literally, one occurrence at a time, left to right: each
     * argument consumes the next (earliest) placeholder.
     */
    private static function interpolate(string $template, string ...$args): string
    {
        $out = $template;
        foreach ($args as $arg) {
            $bestPos = false;
            $bestLen = 0;
            foreach (['%s', '%d'] as $needle) {
                $p = strpos($out, $needle);
                if ($p !== false && ($bestPos === false || $p < $bestPos)) {
                    $bestPos = $p;
                    $bestLen = strlen($needle);
                }
            }
            if ($bestPos === false) {
                break; // no placeholder left for further arguments
            }
            $out = substr($out, 0, $bestPos) . $arg . substr($out, $bestPos + $bestLen);
        }
        return $out;
    }

    /**
     * Persian label for a currency key. Used in sprintf-style reward
     * messages ("پاداش: 5 توکن بازی").
     */
    private static function currencyName(string $rewardType, array $config): string
    {
        return Settings::getMessage('currency_' . $rewardType, $config);
    }

    /**
     * Build the "back to main menu" keyboard — used after most task flows so
     * the user isn't stranded without navigation.
     */
    private static function backKeyboard(array $config): string
    {
        // backToMainMenuKeyboard is a global helper defined in helpers/keyboard.php.
        // It is always available at runtime (loaded by bootstrap.php).
        if (function_exists('backToMainMenuKeyboard')) {
            return backToMainMenuKeyboard();
        }
        // Defensive fallback (should never execute in production).
        // T46-p: proper row-wrapped shape — the old [button] shape (button at
        // row level) would have 400'd had it ever executed.
        return json_encode([
            'inline_keyboard' => [
                [
                    [
                        'text'          => '↩️ برگشت به منوی اصلی',
                        'callback_data' => 'back_to_main_menu',
                    ],
                ],
            ],
        ]);
    }

    /**
     * Send the channel-join prompt for a channel task: an inline keyboard
     * with a URL button (the join link) and a verify button
     * (callback 'task_verify_<taskId>').
     */
    private static function sendChannelJoinPrompt(int $userId, array $task, TelegramBot $bot, array $config): void
    {
        $channelId = isset($task['channel_id']) ? (int)$task['channel_id'] : 0;
        if ($channelId <= 0) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }
        $channel = Database::getChannelById($channelId);
        if ($channel === null) {
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        $joinUrl = self::channelJoinUrl($channel);
        if ($joinUrl === '') {
            // No resolvable join URL — cannot proceed.
            $bot->sendMessage($userId, Settings::getMessage('error_generic', $config), null, 'Markdown');
            return;
        }

        // Build inline keyboard: row 1 = join URL button, row 2 = verify button.
        $joinBtn = [
            'text' => ' ' . Settings::getMessage('join_button', $config),
            'url'  => $joinUrl,
            'style' => 'success',
        ];
        $joinEmoji = emoji_id(16); // join channel
        if ($joinEmoji !== null) {
            $joinBtn['icon_custom_emoji_id'] = $joinEmoji;
        }

        $verifyBtn = [
            'text'          => ' ' . Settings::getMessage('confirm_button', $config),
            'callback_data' => 'task_verify_' . (int)$task['id'],
            'style'         => 'primary',
        ];
        $verifyEmoji = emoji_id(49); // task
        if ($verifyEmoji !== null) {
            $verifyBtn['icon_custom_emoji_id'] = $verifyEmoji;
        }

        $replyMarkup = json_encode([
            'inline_keyboard' => [
                [$joinBtn],
                [$verifyBtn],
            ],
        ]);

        // Prompt text — reuse the existing 'not_joined' channel-membership
        // message which asks the user to join then confirm.
        $prompt = Settings::getMessage('not_joined', $config);
        if ($prompt === '') {
            // Fallback inline text if the admin blanked the message.
            $prompt = ' برای دریافت پاداش، در کانال زیر عضو شوید و سپس دکمه تأیید را بزنید:';
        }

        $bot->sendMessage($userId, $prompt, $replyMarkup, 'Markdown');
    }

    /**
     * Resolve a public t.me/<handle> URL for a channel row. Prefers the
     * admin-configured join_link; falls back to the handle.
     */
    private static function channelJoinUrl(array $channel): string
    {
        $joinLink = (string)($channel['join_link'] ?? '');
        if ($joinLink !== '') {
            return $joinLink;
        }
        $handle = (string)($channel['channel_handle'] ?? '');
        $handle = ltrim($handle, '@');
        if ($handle === '') return '';
        return 'https://t.me/' . $handle;
    }

    /**
     * Look up a user's chat-member status in a channel.
     *
     * For private channels we use the channel's numeric_id (a `-100…` chat
     * id) and call TelegramBot::getChatMember() directly.
     *
     * For public channels we use the @handle. TelegramBot::getChatMember is
     * typed `int $chatId`, so for the @handle case we go through TelegramBot's
     * public send() method with a string chat_id (which the Telegram API
     * accepts for public usernames).
     *
     * Returns null on any API failure.
     */
    private static function getChatMember(TelegramBot $bot, array $channel, int $userId): ?array
    {
        $numericId = isset($channel['numeric_id']) ? (string)$channel['numeric_id'] : '';
        if ($numericId !== '' && $numericId !== '0') {
            return $bot->getChatMember((int)$numericId, $userId);
        }

        $handle = (string)($channel['channel_handle'] ?? '');
        $handle = ltrim($handle, '@');
        if ($handle === '') return null;

        $res = $bot->send('getChatMember', [
            'chat_id' => '@' . $handle,
            'user_id' => $userId,
        ]);
        if ($res === null || !isset($res->ok) || !$res->ok || !isset($res->result)) {
            return null;
        }
        return json_decode(json_encode($res->result), true);
    }

    /**
     * Notify all admins with the 'tasks' permission (falling back to
     * 'support') that a text-task answer is pending review. Best-effort:
     * each send is wrapped in try/catch.
     */
    private static function notifyAdminsTextAnswer(int $userId, array $task, string $answerText, TelegramBot $bot): void
    {
        $admins = Database::getAdminsWithPermission('tasks');
        if (empty($admins)) {
            $admins = Database::getAdminsWithPermission('support');
        }
        if (empty($admins)) return;

        $user = Database::loadUser($userId);
        $displayName = $user !== null ? ((string)($user['displayName'] ?? 'user')) : 'user';
        $title = (string)($task['title'] ?? '');

        // Truncate very long answers in the notification preview so the
        // message stays readable; the admin sees the full text in the panel.
        $answerPreview = $answerText;
        $maxLen = 800;
        if (mb_strlen($answerPreview) > $maxLen) {
            $answerPreview = mb_substr($answerPreview, 0, $maxLen) . '…';
        }

        // v2.9.45 (R6-90/MEDIUM): neutralize the user answer — it went into the
        // admins' official-bot chat as Markdown (live-link injection vector).
        $answerPreview = function_exists('neutralizeUserEcho') ? neutralizeUserEcho((string)$answerPreview) : (string)$answerPreview;
        // T46-l: manual-task confirmations announce themselves as such so
        // admins don't mistake them for free-text answers.
        $heading = (($task['type'] ?? '') === 'manual') ? 'تاییدیه جدید برای تسک دستی' : 'پاسخ جدید برای تسک متنی';
        $text = emoji(49) . " {$heading}"
            . "\n" . emoji(23) . " تسک: {$title}"
            . "\n" . emoji(35) . " کاربر: {$displayName} ({$userId})"
            . "\n\n" . "پاسخ کاربر:"
            . "\n" . $answerPreview
            . "\n\n" . "برای بررسی، به پنل مدیریت مراجعه کنید.";

        foreach ($admins as $admin) {
            $adminId = (int)($admin['user_id'] ?? 0);
            if ($adminId <= 0) continue;
            try {
                $bot->sendMessage($adminId, $text, null, 'Markdown');
            } catch (\Throwable $e) {
                error_log("TaskSystem admin notify to {$adminId} failed: " . $e->getMessage());
            }
        }
    }
}
