<?php

declare(strict_types=1);

namespace app\common\service;

use app\common\library\game\StreakWinReward;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\Log;
use support\Redis;
use support\think\Db;
use Throwable;

final class GameLiveService
{
    private const CHANNEL = 'game-live';
    private const EVENT = 'bet-updated';

    /** 与《36字花-移动端接口设计草案》7.1 对齐：公共对局频道 */
    private const CHANNEL_PUBLIC_GAME_PERIOD = 'public-game-period';
    private const EVT_PERIOD_TICK = 'period.tick';
    private const EVT_PERIOD_LOCKED = 'period.locked';
    private const EVT_PERIOD_OPENED = 'period.opened';
    private const EVT_PERIOD_PAYOUT = 'period.payout';
    /** 派彩宽限期内每秒倒计时（不含彩池/下注列表，仅剩余秒数） */
    private const EVT_PERIOD_PAYOUT_TICK = 'period.payout.tick';

    /** period.tick 边界帧去重（finished / void 每期只推一次），TTL 兼顾跨进程与跨期重启 */
    private const TICK_BOUNDARY_DEDUP_KEY_PREFIX = 'dfw:v1:ws:tick:boundary:';
    private const TICK_BOUNDARY_DEDUP_TTL_SECONDS = 300;
    private const KEY_PERIOD_SECONDS = 'period_seconds';
    private const KEY_BET_SECONDS = 'bet_seconds';
    private const KEY_PAYOUT_SECONDS = 'payout_seconds';
    private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';

    /** 开奖结果号码池：1 至此上限（与单注可选号码个数配置无关） */
    private const DRAW_NUMBER_MAX = 36;

    /** 派彩展示宽限期默认值（秒），可被 game_config.payout_seconds 覆盖 */
    private const DEFAULT_PAYOUT_SECONDS = 3;
    /** 启动自愈：判定“异常卡局”的最小超时冗余秒数 */
    private const STARTUP_RECOVER_GRACE_SECONDS = 10;

    /**
     * 服务重启后自动巡检上一局：若长时间卡在进行中状态，则自动作废并退款待开奖注单。
     */
    public static function recoverAbnormalPeriodOnStartup(): void
    {
        $row = Db::name('game_record')
            ->whereIn('status', [0, 1, 2, 3])
            ->order('id', 'desc')
            ->find();
        if (!$row) {
            return;
        }

        $recordId = (int) ($row['id'] ?? 0);
        if ($recordId <= 0) {
            return;
        }
        $status = (int) ($row['status'] ?? 0);
        $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0;
        if ($resultNumber > 0 && in_array($status, [0, 1, 2, 3], true)) {
            self::recoverPayoutForRecordOnStartup($recordId);
            return;
        }

        $periodStartAt = (int) ($row['period_start_at'] ?? 0);
        if ($periodStartAt <= 0) {
            return;
        }
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $timeoutAt = $periodStartAt + $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS;
        if (time() <= $timeoutAt) {
            return;
        }
        self::markAbnormalAndRefundOnStartup($recordId, $status);
    }

    private static function recoverPayoutForRecordOnStartup(int $recordId): void
    {
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
        if (!$lock['acquired']) {
            return;
        }
        try {
            $row = Db::name('game_record')->where('id', $recordId)->find();
            if (!$row) {
                return;
            }
            $status = (int) ($row['status'] ?? 0);
            if (!in_array($status, [0, 1, 2, 3], true)) {
                return;
            }
            $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0;
            if ($resultNumber <= 0) {
                return;
            }

            $now = time();
            $payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0;
            $settleOut = ['jackpot_hits' => [], 'bet_wins' => []];
            Db::startTrans();
            try {
                $settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber);
                if ($status === 2) {
                    if ($payoutUntil <= 0) {
                        $payoutUntil = $now + self::getPayoutGraceSeconds();
                    }
                    Db::name('game_record')->where('id', $recordId)->update([
                        'status' => 3,
                        'payout_until' => $payoutUntil,
                        'update_time' => $now,
                    ]);
                } elseif ($status === 3) {
                    if ($payoutUntil <= 0) {
                        $payoutUntil = $now;
                        Db::name('game_record')->where('id', $recordId)->update([
                            'payout_until' => $payoutUntil,
                            'update_time' => $now,
                        ]);
                    }
                } else {
                    $payoutUntil = $now;
                    Db::name('game_record')->where('id', $recordId)->update([
                        'status' => 3,
                        'payout_until' => $payoutUntil,
                        'update_time' => $now,
                    ]);
                }
                Db::commit();
            } catch (Throwable $e) {
                Db::rollback();
                Log::warning('game live startup payout recover failed', [
                    'record_id' => $recordId,
                    'error' => $e->getMessage(),
                ]);
                return;
            }

            $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
            GameBetSettleService::publishBetWinsAfterCommit($betWins);

            GameHotDataCoordinator::afterGameRecordCommitted($recordId);
            self::publishSnapshot(null);

            if ($payoutUntil <= $now) {
                self::finalizePayoutForRecordLocked($recordId);
            }
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
        }
    }

    private static function markAbnormalAndRefundOnStartup(int $recordId, int $status): void
    {
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
        if (!$lock['acquired']) {
            return;
        }
        try {
            $fresh = Db::name('game_record')->where('id', $recordId)->find();
            if (!$fresh) {
                return;
            }
            $freshStatus = (int) ($fresh['status'] ?? 0);
            if (!in_array($freshStatus, [0, 1, 2, 3], true)) {
                return;
            }
            $freshResultNumber = isset($fresh['result_number']) ? (int) $fresh['result_number'] : 0;
            if ($freshResultNumber > 0) {
                return;
            }

            $now = time();
            $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00'];
            Db::startTrans();
            try {
                $refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now);
                $refundedUserCount = count($refund['user_ids']);
                $refundedOrderCount = (int) ($refund['order_count'] ?? 0);
                $refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00';
                $reason = sprintf(
                    'system_recover:from=%d|users=%d|orders=%d|amount=%s',
                    $freshStatus,
                    $refundedUserCount,
                    $refundedOrderCount,
                    $refundedTotalAmount
                );
                Db::name('game_record')->where('id', $recordId)->update([
                    'status' => 5,
                    'void_reason' => $reason,
                    'pending_draw_number' => null,
                    'payout_until' => null,
                    'ai_locked_number' => null,
                    'update_time' => $now,
                ]);
                Db::commit();
            } catch (Throwable $e) {
                Db::rollback();
                Log::warning('game live startup abnormal recover failed', [
                    'record_id' => $recordId,
                    'status' => $status,
                    'error' => $e->getMessage(),
                ]);
                return;
            }

            GameHotDataCoordinator::afterGameRecordCommitted($recordId);
            foreach ($refund['user_ids'] as $uid) {
                if ($uid > 0) {
                    GameHotDataCoordinator::afterUserCommitted($uid);
                }
            }
            // 异常对局作废后：自动暂停游戏，不自动创建新一期；需管理员手动开启「游戏运行」才会重新开局
            GameRecordService::setAutoCreateEnabled(false);
            GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
            self::publishSnapshot(null);
            Log::info('game live startup marked abnormal and refunded', [
                'record_id' => $recordId,
                'old_status' => $freshStatus,
                'refunded_user_count' => count($refund['user_ids']),
                'refunded_order_count' => (int) ($refund['order_count'] ?? 0),
                'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00',
            ]);
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
        }
    }

    private static function finalizePayoutForRecordLocked(int $recordId): void
    {
        $now = time();
        Db::startTrans();
        try {
            Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([
                'status' => 4,
                'payout_until' => null,
                'update_time' => $now,
            ]);
            GameRecordService::createNextRecordAfterDraw();
            Db::commit();
        } catch (Throwable $e) {
            Db::rollback();
            Log::warning('game live startup finalize payout failed', [
                'record_id' => $recordId,
                'error' => $e->getMessage(),
            ]);
            return;
        }
        GameHotDataCoordinator::afterGameRecordCommitted($recordId);
        try {
            GameRecordStatService::refreshForRecordId($recordId);
        } catch (Throwable) {
        }
        self::publishSnapshot(null);
    }

    public static function buildSnapshot(?int $recordId = null, bool $freshFromDb = false): array
    {
        $record = $freshFromDb ? self::resolveRecordFromDb($recordId) : self::resolveRecord($recordId);
        if (!$record) {
            return self::emptySnapshotPayload();
        }

        $rid = (int) $record['id'];
        self::ensureAiLocked($rid);
        $record = $freshFromDb ? self::reloadRecordFromDb($rid) : self::reloadRecord($rid);
        if (!$record) {
            return self::emptySnapshotPayload();
        }

        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $pickMax = self::getPickMaxNumberCount();
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        $remaining = max(0, $periodSeconds - $elapsed);
        $betRemaining = max(0, $betSeconds - $elapsed);
        $status = (int) $record['status'];

        $payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0;
        $payoutRemaining = 0;
        if ($status === 3 && $payoutUntil > 0) {
            $payoutRemaining = max(0, $payoutUntil - time());
        }

        $bets = Db::name('bet_order')
            ->alias('bo')
            ->leftJoin('user gu', 'gu.id = bo.user_id')
            ->where('bo.period_id', $rid)
            ->order('bo.id', 'desc')
            ->limit(200)
            ->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.create_time,gu.username as user_username')
            ->select()
            ->toArray();

        $candidates = [];
        $canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
        if ($canCalculate) {
            for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
                $loss = self::estimateLossForNumber($bets, $n);
                $candidates[] = [
                    'number' => $n,
                    'estimated_loss' => $loss,
                ];
            }
        }

        $aiLocked = $record['ai_locked_number'] ?? null;
        $aiDisplay = null;
        if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
            $aiDisplay = (int) $aiLocked;
        }

        $pendingRaw = $record['pending_draw_number'] ?? null;
        $pendingDraw = null;
        if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) {
            $pd = (int) $pendingRaw;
            if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) {
                $pendingDraw = $pd;
            }
        }

        $canScheduleDraw = ($status === 0 || $status === 1)
            && $elapsed >= $betSeconds
            && $elapsed < $periodSeconds;

        $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
        $hasActiveRound = GameRecordService::hasActiveRecord();
        /** 关服且已无进行中局：派彩结束后的「完整维护」态（仅此时展示维护中 UI） */
        $maintenanceUi = !$runtimeEnabled && !$hasActiveRound;

        return [
            'record' => $record,
            'bets' => array_map(static function (array $row): array {
                return [
                    'id' => (int) $row['id'],
                    'user_id' => (int) $row['user_id'],
                    'username' => isset($row['user_username']) && is_string($row['user_username']) ? $row['user_username'] : '',
                    'period_no' => (string) $row['period_no'],
                    'pick_numbers' => $row['pick_numbers'],
                    'total_amount' => (string) $row['total_amount'],
                    'streak_at_bet' => (int) $row['streak_at_bet'],
                    'create_time' => (int) $row['create_time'],
                ];
            }, $bets),
            'candidate_numbers' => $candidates,
            'ai_default_number' => $aiDisplay,
            'calc_number' => $aiDisplay,
            'pending_draw_number' => $pendingDraw,
            'period_seconds' => $periodSeconds,
            'bet_seconds' => $betSeconds,
            'pick_max_number_count' => $pickMax,
            'draw_number_max' => self::DRAW_NUMBER_MAX,
            'remaining_seconds' => $remaining,
            'bet_remaining_seconds' => $betRemaining,
            'payout_remaining_seconds' => $payoutRemaining,
            'is_payout_phase' => $status === 3,
            'runtime_enabled' => $runtimeEnabled,
            'maintenance_ui' => $maintenanceUi,
            /** 关闭游戏（维护）时仍允许完成当局、计算与预约开奖；仅阻止新用户下注与结束后自动开新期 */
            'can_calculate' => $canCalculate,
            'can_draw' => $canScheduleDraw,
            'can_schedule_draw' => $canScheduleDraw,
            'server_time' => time(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private static function emptySnapshotPayload(): array
    {
        $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
        $hasActiveRound = GameRecordService::hasActiveRecord();
        $maintenanceUi = !$runtimeEnabled && !$hasActiveRound;

        return [
            'record' => null,
            'bets' => [],
            'candidate_numbers' => [],
            'ai_default_number' => null,
            'calc_number' => null,
            'pending_draw_number' => null,
            'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
            'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
            'pick_max_number_count' => self::getPickMaxNumberCount(),
            'draw_number_max' => self::DRAW_NUMBER_MAX,
            'remaining_seconds' => 0,
            'bet_remaining_seconds' => 0,
            'payout_remaining_seconds' => 0,
            'is_payout_phase' => false,
            'runtime_enabled' => $runtimeEnabled,
            'maintenance_ui' => $maintenanceUi,
            'can_calculate' => false,
            'can_draw' => false,
            'can_schedule_draw' => false,
            'server_time' => time(),
        ];
    }

    public static function calculateResult(?int $recordId, ?int $manualNumber = null): array
    {
        $record = self::resolveRecord($recordId);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }
        if (!in_array((int) $record['status'], [0, 1], true)) {
            return ['ok' => false, 'msg' => __('Current game status does not allow calculation')];
        }
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        if ($elapsed < $betSeconds) {
            return ['ok' => false, 'msg' => __('Betting period has not ended; calculation is not available yet')];
        }

        self::ensureAiLocked((int) $record['id']);
        $record = self::reloadRecord((int) $record['id']);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }

        $pickMax = self::getPickMaxNumberCount();
        if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) {
            return ['ok' => false, 'msg' => __('Manual draw number is out of the allowed range')];
        }

        $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
        $candidates = [];
        $bestNumber = null;
        $bestLoss = null;
        $bestNumbers = [];
        for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
            $loss = self::estimateLossForNumber($bets, $n);
            $candidates[] = ['number' => $n, 'estimated_loss' => $loss];
            if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) {
                $bestLoss = $loss;
                $bestNumbers = [$n];
                continue;
            }
            if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) {
                $bestNumbers[] = $n;
            }
        }
        $bestNumber = self::pickRandomNumber($bestNumbers);

        $aiLocked = $record['ai_locked_number'] ?? null;
        $aiDisplay = null;
        if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
            $aiDisplay = (int) $aiLocked;
        }

        $finalNumber = $manualNumber ?? $bestNumber;
        $finalLoss = '0.00';
        if ($finalNumber !== null) {
            $finalLoss = self::estimateLossForNumber($bets, $finalNumber);
        }

        return [
            'ok' => true,
            'msg' => __('Calculation completed'),
            'record' => $record,
            'period_seconds' => $periodSeconds,
            'bet_seconds' => $betSeconds,
            'pick_max_number_count' => $pickMax,
            'draw_number_max' => self::DRAW_NUMBER_MAX,
            'candidate_numbers' => $candidates,
            'ai_default_number' => $aiDisplay,
            'final_number' => $finalNumber,
            'final_estimated_loss' => $finalLoss,
        ];
    }

    /**
     * 管理员预约本期开奖号码（倒计时结束后由 tick 自动开奖，不立即开奖）。
     */
    public static function scheduleDraw(?int $recordId, int $manualNumber): array
    {
        if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) {
            return ['ok' => false, 'msg' => __('Draw number is out of the allowed range')];
        }
        $record = self::resolveRecord($recordId);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }
        if (!in_array((int) $record['status'], [0, 1], true)) {
            return ['ok' => false, 'msg' => __('Current game status does not allow scheduling the draw')];
        }
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        if ($elapsed < $betSeconds) {
            return ['ok' => false, 'msg' => __('Betting has not ended; cannot schedule the draw')];
        }
        if ($elapsed >= $periodSeconds) {
            return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')];
        }

        $rid = (int) $record['id'];
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 1200);
        if (!$lock['acquired']) {
            return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
        }
        try {
            self::ensureAiLocked($rid);
            Db::name('game_record')->where('id', $rid)->update([
                'pending_draw_number' => $manualNumber,
                'update_time' => time(),
            ]);
            GameHotDataCoordinator::afterGameRecordCommitted($rid);
            self::publishSnapshot(null);
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
        }

        return [
            'ok' => true,
            'msg' => __('Draw number scheduled; it will be used when the countdown ends'),
        ];
    }

    /**
     * 倒计时结束自动开奖（AI 或预约号码）；派彩宽限期后由 finalizePayoutGrace 结单并开下一期。
     */
    public static function drawResult(?int $recordId, ?int $manualNumber = null): array
    {
        $record = self::resolveRecord($recordId);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }
        if (!in_array((int) $record['status'], [0, 1], true)) {
            return ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
        }
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        if ($elapsed < $betSeconds) {
            return ['ok' => false, 'msg' => __('Betting period has not ended; drawing is not available yet')];
        }
        if ($elapsed < $periodSeconds) {
            return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
        }

        $rid = (int) $record['id'];
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000);
        if (!$lock['acquired']) {
            return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
        }
        try {
        self::ensureAiLocked($rid);
        $record = self::reloadRecord($rid);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }

        $useManual = $manualNumber;
        if ($useManual === null) {
            $p = $record['pending_draw_number'] ?? null;
            if ($p !== null && $p !== '' && is_numeric((string) $p)) {
                $pn = (int) $p;
                if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) {
                    $useManual = $pn;
                }
            }
        }

        $finalNumber = null;
        $drawMode = 0;
        if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) {
            $finalNumber = $useManual;
            $drawMode = 1;
        } else {
            $al = $record['ai_locked_number'] ?? null;
            if ($al !== null && $al !== '' && is_numeric((string) $al)) {
                $finalNumber = (int) $al;
            }
        }
        if ($finalNumber === null || $finalNumber < 1) {
            $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
            $finalNumber = self::computeBestNumberFromBets($bets) ?? 1;
            $drawMode = 0;
        }

        $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
        $finalLoss = self::estimateLossForNumber($bets, $finalNumber);
        $now = time();
        $payoutUntil = $now + self::getPayoutGraceSeconds();

        $settleOut = ['jackpot_hits' => [], 'bet_wins' => []];
        Db::startTrans();
        try {
            Db::name('game_record')->where('id', (int) $record['id'])->update([
                'status' => 3,
                'result_number' => $finalNumber,
                'draw_mode' => $drawMode,
                'pending_draw_number' => null,
                'payout_until' => $payoutUntil,
                'update_time' => $now,
            ]);
            $settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
            Db::commit();
        } catch (Throwable $e) {
            Db::rollback();
            return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
        }

        $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
        GameBetSettleService::publishBetWinsAfterCommit($betWins);

        GameHotDataCoordinator::afterGameRecordCommitted($rid);

        try {
            GameRecordStatService::refreshForRecordId($rid);
        } catch (Throwable) {
        }
        self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
        self::publishPublicPeriodPayout($rid, (string) $record['period_no'], $finalNumber, $payoutUntil, $now);
        $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
        GameWebSocketEventBus::publish('admin.live.opened', [
            'period_id' => $rid,
            'period_no' => (string) $record['period_no'],
            'result_number' => $finalNumber,
            'payout_until' => $payoutUntil,
            'jackpot_hits' => $jackpotHits,
            'server_time' => $now,
        ]);
        if ($jackpotHits !== []) {
            GameWebSocketEventBus::publish('jackpot.hit', [
                'period_id' => $rid,
                'period_no' => (string) $record['period_no'],
                'result_number' => $finalNumber,
                'hits' => $jackpotHits,
                'server_time' => $now,
            ]);
        }
        self::publishSnapshot(null);

        return [
            'ok' => true,
            'msg' => __('Draw completed; paying out'),
            'result_number' => $finalNumber,
            'estimated_loss' => $finalLoss,
            'payout_until' => $payoutUntil,
        ];
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
        }
    }

    /**
     * 派彩宽限期结束：将本期置为已结束并创建下一期。
     */
    public static function finalizePayoutGrace(): void
    {
        $row = Db::name('game_record')
            ->where('status', 3)
            ->where('payout_until', '>', 0)
            ->where('payout_until', '<=', time())
            ->order('id', 'desc')
            ->find();
        if (!$row) {
            return;
        }
        $id = (int) $row['id'];
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000);
        if (!$lock['acquired']) {
            return;
        }
        try {
            $periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
            $resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
            if ($resultNumber === false || $resultNumber < 1) {
                $resultNumber = null;
            }
            Db::startTrans();
            try {
                Db::name('game_record')->where('id', $id)->update([
                    'status' => 4,
                    'payout_until' => null,
                    'update_time' => time(),
                ]);
                GameRecordService::createNextRecordAfterDraw();
                Db::commit();
            } catch (Throwable) {
                Db::rollback();
                return;
            }
            GameHotDataCoordinator::afterGameRecordCommitted($id);
            GameRecordStatService::refreshForRecordId($id);
            GameHotDataRedis::gameRecordForget($id);
            GameHotDataRedis::gameRecordForget(null);
            self::publishPublicPeriodFinished($id, $periodNo, $resultNumber);
            self::publishSnapshot(null);
            self::publishImmediateBettingTickAfterFinalize();
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']);
        }
    }

    public static function tickAutoDraw(): void
    {
        $record = self::resolveRecord(null);
        if (!$record || !in_array((int) $record['status'], [0, 1], true)) {
            return;
        }
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        self::ensureAiLocked((int) $record['id']);
        $record = self::reloadRecord((int) $record['id']);
        if (!$record) {
            return;
        }
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        if ($elapsed < $periodSeconds) {
            return;
        }
        self::drawResult((int) $record['id'], null);
    }

    /**
     * 作废当前期（仅 status 为下注/封盘）：待开奖注单退款，本期置为已作废，并关闭运行开关。
     *
     * @return array{ok: bool, msg?: string, record?: array|null}
     */
    public static function voidCurrentPeriod(?int $recordId, string $voidReason): array
    {
        $reason = trim($voidReason);
        if ($reason === '') {
            return ['ok' => false, 'msg' => __('Void reason is required')];
        }
        if (function_exists('mb_strlen')) {
            if (mb_strlen($reason) > 255) {
                return ['ok' => false, 'msg' => __('Void reason is too long')];
            }
        } elseif (strlen($reason) > 255) {
            return ['ok' => false, 'msg' => __('Void reason is too long')];
        }
        if (strlen($reason) < 2) {
            return ['ok' => false, 'msg' => __('Void reason is too short')];
        }

        $record = self::resolveRecord($recordId);
        if (!$record) {
            return ['ok' => false, 'msg' => __('No active game in progress')];
        }
        $st = (int) $record['status'];
        if (!in_array($st, [0, 1], true)) {
            return ['ok' => false, 'msg' => __('Current period cannot be voided')];
        }
        $rid = (int) $record['id'];
        $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000);
        if (!$lock['acquired']) {
            return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
        }
        $refundedUserIds = [];
        try {
            $now = time();
            $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
            Db::startTrans();
            try {
                $refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now);
                $refundedUserIds = $refund['user_ids'];
                Db::name('game_record')->where('id', $rid)->update([
                    'status' => 5,
                    'void_reason' => $reason,
                    'pending_draw_number' => null,
                    'payout_until' => null,
                    'ai_locked_number' => null,
                    'update_time' => $now,
                ]);
                Db::commit();
            } catch (Throwable $e) {
                Db::rollback();
                return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
            }
            GameRecordService::setAutoCreateEnabled(false);
            GameHotDataCoordinator::afterGameRecordCommitted($rid);
            GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
            foreach ($refundedUserIds as $uid) {
                if ($uid > 0) {
                    GameHotDataCoordinator::afterUserCommitted($uid);
                }
            }
            self::publishSnapshot(null);

            return [
                'ok' => true,
                'msg' => __('Period voided'),
                'record' => self::reloadRecord($rid),
                'refund' => $refund,
            ];
        } finally {
            GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
        }
    }

    /**
     * @return list<int>
     */
    private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
    {
        $summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now);
        return $summary['user_ids'];
    }

    /**
     * @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
     */
    private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
    {
        $userIdSet = [];
        $orderCount = 0;
        $totalAmount = '0.00';
        $orderIds = [];
        $bets = Db::name('bet_order')
            ->where('period_id', $periodId)
            ->where('status', 1)
            ->order('id', 'asc')
            ->select()
            ->toArray();
        foreach ($bets as $bet) {
            $betId = (int) ($bet['id'] ?? 0);
            $userId = (int) ($bet['user_id'] ?? 0);
            $totalRaw = $bet['total_amount'] ?? '0';
            $total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
            if ($betId <= 0) {
                continue;
            }
            if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
                Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
                    'status' => 3,
                    'update_time' => $now,
                ]);
                $orderCount++;
                $orderIds[] = $betId;
                continue;
            }
            $before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
            $after = bcadd($before, $total, 2);
            $u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
                'coin' => $after,
                'update_time' => $now,
            ]);
            if ($u !== 1) {
                throw new \RuntimeException((string) __('Concurrent balance update; please retry'));
            }
            $bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
                'status' => 3,
                'update_time' => $now,
            ]);
            if ($bo !== 1) {
                throw new \RuntimeException((string) __('Bet order state changed; please retry'));
            }
            $channelIdRaw = $bet['channel_id'] ?? null;
            $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
            if ($channelId === false) {
                $channelId = null;
            }
            UserWalletRecord::create([
                'user_id' => $userId,
                'channel_id' => $channelId,
                'biz_type' => 'void_refund',
                'direction' => 1,
                'amount' => $total,
                'balance_before' => $before,
                'balance_after' => $after,
                'ref_type' => 'bet_order',
                'remark' => (string) __('Period void refund'),
                'create_time' => $now,
            ]);
            $userIdSet[$userId] = true;
            $orderCount++;
            $totalAmount = bcadd($totalAmount, $total, 2);
            $orderIds[] = $betId;
        }

        $out = [];
        foreach (array_keys($userIdSet) as $uid) {
            $out[] = (int) $uid;
        }

        return [
            'user_ids' => $out,
            'order_count' => $orderCount,
            'total_amount' => $totalAmount,
            'order_ids' => $orderIds,
        ];
    }

    public static function publishSnapshot(?int $recordId = null): void
    {
        $snapshot = self::buildSnapshot($recordId, true);
        self::publishPublicPeriodPayoutCountdown($snapshot);
        self::publishPublicPeriodTick($snapshot);
    }

    /**
     * 派彩宽限期内每秒推送倒计时（仅剩余秒数，不含彩池变化）。
     */
    private static function publishPublicPeriodPayoutCountdown(array $snapshot): void
    {
        $record = $snapshot['record'] ?? null;
        if (!is_array($record)) {
            return;
        }
        $dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT);
        if ($dbStatus !== 3) {
            return;
        }
        $payoutUntil = filter_var($record['payout_until'] ?? 0, FILTER_VALIDATE_INT);
        if ($payoutUntil === false || $payoutUntil <= 0) {
            return;
        }
        $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
        if ($periodId === false) {
            $periodId = 0;
        }
        $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
        $now = time();
        $resultNumber = null;
        $resultParsed = filter_var($record['result_number'] ?? null, FILTER_VALIDATE_INT);
        if ($resultParsed !== false && $resultParsed > 0) {
            $resultNumber = $resultParsed;
        }
        GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT_TICK, [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'status' => 'payouting',
            'payout_until' => $payoutUntil,
            'payout_seconds' => self::getPayoutGraceSeconds(),
            'payout_remaining_seconds' => max(0, $payoutUntil - $now),
            'result_number' => $resultNumber,
            'server_time' => $now,
        ]);
    }

    /**
     * 派彩结束并创建新期后，立即推送一帧 betting（避免热缓存仍指向上一期 payouting 导致长时间无 tick）。
     */
    private static function publishImmediateBettingTickAfterFinalize(): void
    {
        $record = self::resolveRecordFromDb(null);
        if (!is_array($record)) {
            return;
        }
        $dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT);
        if ($dbStatus !== 0) {
            return;
        }
        $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
        if ($periodId === false || $periodId <= 0) {
            return;
        }
        $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
        if ($periodNo === '') {
            return;
        }
        $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $elapsed = max(0, time() - (int) ($record['period_start_at'] ?? time()));
        GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'status' => 'betting',
            'countdown' => max(0, $periodSeconds - $elapsed),
            'bet_close_in' => max(0, $betSeconds - $elapsed),
            'result_number' => null,
            'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
            'server_time' => time(),
        ]);
    }

    /**
     * 移动端公共频道：每秒心跳，含期号、倒计时、阶段（对齐 lobbyInit/periodCurrent 语义）
     */
    private static function publishPublicPeriodTick(array $snapshot): void
    {
        $record = $snapshot['record'] ?? null;
        $periodNo = '';
        $periodId = 0;
        $status = 'finished';
        $resultNumber = null;
        if (is_array($record)) {
            $periodNoRaw = $record['period_no'] ?? '';
            if (is_string($periodNoRaw)) {
                $periodNo = $periodNoRaw;
            }
            $periodIdRaw = $record['id'] ?? 0;
            $periodIdParsed = filter_var($periodIdRaw, FILTER_VALIDATE_INT);
            if ($periodIdParsed !== false && $periodIdParsed > 0) {
                $periodId = $periodIdParsed;
            }
            $dbStatusRaw = $record['status'] ?? 4;
            $dbStatus = filter_var($dbStatusRaw, FILTER_VALIDATE_INT);
            if ($dbStatus === false) {
                $dbStatus = 4;
            }
            $betRemainingRaw = $snapshot['bet_remaining_seconds'] ?? 0;
            $betRemaining = filter_var($betRemainingRaw, FILTER_VALIDATE_INT);
            if ($betRemaining === false || $betRemaining < 0) {
                $betRemaining = 0;
            }
            $status = self::mapPublicPeriodStatus($dbStatus, $betRemaining);
            $resultRaw = $record['result_number'] ?? null;
            $resultParsed = filter_var($resultRaw, FILTER_VALIDATE_INT);
            if ($resultParsed !== false && $resultParsed > 0) {
                $resultNumber = $resultParsed;
            }
        }

        $payload = [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'status' => $status,
            'countdown' => max(0, self::safeInt($snapshot['remaining_seconds'] ?? 0)),
            'bet_close_in' => max(0, self::safeInt($snapshot['bet_remaining_seconds'] ?? 0)),
            'result_number' => $resultNumber,
            'runtime_enabled' => !empty($snapshot['runtime_enabled']),
            'server_time' => time(),
        ];

        if (!self::shouldPublishPeriodTick($status, $periodNo)) {
            return;
        }

        GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload);
    }

    /**
     * period.tick 状态过滤（与《36字花-移动端接口设计草案》7.1.3 推送规则对齐）：
     * - betting / locked：保持每秒推送
     * - payouting：完全静默（中奖信息已通过 period.opened / period.payout / jackpot.hit / wallet.changed 通知）
     * - finished / void：每个期号只推一次边界帧，告知前端本期收尾，随后静默直到下一期 betting
     */
    private static function shouldPublishPeriodTick(string $status, string $periodNo): bool
    {
        if ($status === 'payouting') {
            return false;
        }
        if ($status === 'finished' || $status === 'void') {
            if ($periodNo === '') {
                return true;
            }
            return self::markBoundaryFrameOnce($periodNo, $status);
        }

        return true;
    }

    /**
     * 边界帧去重：SET NX EX，占位成功（首次）返回 true；已存在返回 false。Redis 异常时降级为放行。
     */
    private static function markBoundaryFrameOnce(string $periodNo, string $status): bool
    {
        $key = self::TICK_BOUNDARY_DEDUP_KEY_PREFIX . $periodNo . ':' . $status;
        try {
            $client = Redis::connection()->client();
            if (!is_object($client) || !method_exists($client, 'set')) {
                return true;
            }
            $ok = $client->set($key, '1', ['nx', 'ex' => self::TICK_BOUNDARY_DEDUP_TTL_SECONDS]);

            return $ok === true;
        } catch (Throwable) {
            return true;
        }
    }

    /**
     * @param array<string, mixed> $record game_record 行
     */
    private static function publishPublicPeriodLocked(array $record): void
    {
        $periodNo = is_string($record['period_no'] ?? null) ? $record['period_no'] : '';
        $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
        if ($periodId === false) {
            $periodId = 0;
        }
        GameWebSocketEventBus::publish(self::EVT_PERIOD_LOCKED, [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'status' => 'locked',
            'server_time' => time(),
        ]);
    }

    private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
    {
        GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [
            'period_no' => $periodNo,
            'result_number' => $resultNumber,
            'open_time' => $openTime,
        ]);
    }

    /**
     * 派彩阶段开始（开奖后宽限期内推送）。
     * 客户端应以 payout_until 与 server_time 做倒计时，勿用 period.tick 或上期 countdown。
     */
    private static function publishPublicPeriodPayout(int $periodId, string $periodNo, int $resultNumber, int $payoutUntil, int $serverTime): void
    {
        $grace = self::getPayoutGraceSeconds();
        $remaining = max(0, $payoutUntil - $serverTime);
        GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'result_number' => $resultNumber,
            'payout_until' => $payoutUntil,
            'payout_seconds' => $grace,
            'payout_remaining_seconds' => $remaining,
            'server_time' => $serverTime,
        ]);
    }

    /**
     * 派彩宽限期结束、进入下一期前：推送本期收尾帧（每期一次）。
     */
    private static function publishPublicPeriodFinished(int $periodId, string $periodNo, ?int $resultNumber): void
    {
        if ($periodNo === '') {
            return;
        }
        if (!self::markBoundaryFrameOnce($periodNo, 'finished')) {
            return;
        }
        GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [
            'period_id' => $periodId,
            'period_no' => $periodNo,
            'status' => 'finished',
            'countdown' => 0,
            'bet_close_in' => 0,
            'result_number' => $resultNumber,
            'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
            'server_time' => time(),
        ]);
    }

    /**
     * 与文档 3.1/4.1 中 status 字符串对齐：betting / locked / settling / finished
     */
    private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
    {
        if ($dbStatus === 5) {
            return 'void';
        }
        if ($dbStatus === 0) {
            return $betCloseIn > 0 ? 'betting' : 'locked';
        }
        if ($dbStatus === 1) {
            return 'locked';
        }
        if ($dbStatus === 4) {
            return 'finished';
        }
        if ($dbStatus === 3) {
            return 'payouting';
        }
        if ($dbStatus === 2) {
            return 'settling';
        }

        return 'finished';
    }

    private static function resolveRecord(?int $recordId): ?array
    {
        if ($recordId !== null && $recordId > 0) {
            $row = GameHotDataRedis::gameRecordById($recordId);
            if ($row) {
                return $row;
            }
        }
        return GameHotDataRedis::gameRecordActive();
    }

    /**
     * WebSocket 推送专用：始终读库，避免 game_record 热缓存仍指向上一期 payouting 导致长时间不推 period.tick。
     *
     * @return array<string, mixed>|null
     */
    private static function resolveRecordFromDb(?int $recordId): ?array
    {
        if ($recordId !== null && $recordId > 0) {
            $row = Db::name('game_record')->where('id', $recordId)->find();
            return is_array($row) ? $row : null;
        }
        $row = Db::name('game_record')
            ->whereIn('status', [0, 1, 2, 3])
            ->order('id', 'desc')
            ->find();

        return is_array($row) ? $row : null;
    }

    private static function reloadRecord(int $id): ?array
    {
        $row = GameHotDataRedis::gameRecordById($id);
        return $row ?: null;
    }

    /**
     * @return array<string, mixed>|null
     */
    private static function reloadRecordFromDb(int $id): ?array
    {
        if ($id <= 0) {
            return null;
        }
        $row = Db::name('game_record')->where('id', $id)->find();

        return is_array($row) ? $row : null;
    }

    /**
     * 封盘后计算并锁定 AI 号码（本期不变），并封盘（status 0→1）。
     */
    private static function ensureAiLocked(int $recordId): void
    {
        $record = GameHotDataRedis::gameRecordById($recordId);
        if (!$record) {
            return;
        }
        $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
        $elapsed = max(0, time() - (int) $record['period_start_at']);
        if ($elapsed < $betSeconds) {
            return;
        }
        $st = (int) $record['status'];
        if ($st !== 0 && $st !== 1) {
            return;
        }

        $existing = $record['ai_locked_number'] ?? null;
        if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) {
            if ($st === 0) {
                Db::name('game_record')->where('id', $recordId)->update([
                    'status' => 1,
                    'update_time' => time(),
                ]);
                GameHotDataCoordinator::afterGameRecordCommitted($recordId);
                $record['status'] = 1;
                self::publishPublicPeriodLocked($record);
            }
            return;
        }

        $bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
        $best = self::computeBestNumberFromBets($bets);
        if ($best === null || $best < 1) {
            $best = 1;
        }
        $update = [
            'ai_locked_number' => $best,
            'update_time' => time(),
        ];
        if ($st === 0) {
            $update['status'] = 1;
        }
        Db::name('game_record')->where('id', $recordId)->update($update);
        GameHotDataCoordinator::afterGameRecordCommitted($recordId);
        $record = array_merge($record, $update);
        if ($st === 0) {
            self::publishPublicPeriodLocked($record);
        }
    }

    /**
     * @param array<int, array<string, mixed>> $bets
     */
    private static function computeBestNumberFromBets(array $bets): ?int
    {
        $bestLoss = null;
        $bestNumbers = [];
        for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
            $loss = self::estimateLossForNumber($bets, $n);
            if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) {
                $bestLoss = $loss;
                $bestNumbers = [$n];
                continue;
            }
            if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) {
                $bestNumbers[] = $n;
            }
        }
        return self::pickRandomNumber($bestNumbers);
    }

    private static function getConfigInt(string $key, int $default): int
    {
        $row = GameHotDataRedis::gameConfigRow($key);
        if (!$row) {
            return $default;
        }
        $v = $row['config_value'] ?? null;
        if ($v === null || $v === '') {
            return $default;
        }
        if (!is_numeric((string) $v)) {
            return $default;
        }
        return (int) $v;
    }

    private static function getPayoutGraceSeconds(): int
    {
        $seconds = self::getConfigInt(self::KEY_PAYOUT_SECONDS, self::DEFAULT_PAYOUT_SECONDS);
        if ($seconds < 1) {
            return 1;
        }
        if ($seconds > 300) {
            return 300;
        }
        return $seconds;
    }

    private static function getPickMaxNumberCount(): int
    {
        $max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36);
        if ($max < 1) {
            return 1;
        }
        if ($max > 36) {
            return 36;
        }
        return $max;
    }

    private static function estimateLossForNumber(array $bets, int $number): string
    {
        $payout = '0.00';
        foreach ($bets as $bet) {
            $pickNumbers = $bet['pick_numbers'];
            if (is_string($pickNumbers)) {
                $decoded = json_decode($pickNumbers, true);
                $pickNumbers = is_array($decoded) ? $decoded : [];
            }
            if (!is_array($pickNumbers)) {
                $pickNumbers = [];
            }
            if (!in_array($number, array_map('intval', $pickNumbers), true)) {
                continue;
            }
            $total = (string) ($bet['total_amount'] ?? '0');
            $streak = (int) ($bet['streak_at_bet'] ?? 0);
            $odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
            $orderPayout = bcmul($total, $odds, 2);
            $payout = bcadd($payout, $orderPayout, 2);
        }
        return $payout;
    }

    private static function pickRandomNumber(array $numbers): ?int
    {
        if ($numbers === []) {
            return null;
        }
        if (count($numbers) === 1) {
            return $numbers[0];
        }
        $index = random_int(0, count($numbers) - 1);
        return $numbers[$index];
    }

    private static function safeInt($value): int
    {
        $parsed = filter_var($value, FILTER_VALIDATE_INT);
        if ($parsed === false) {
            return 0;
        }
        return $parsed;
    }
}
