<?php

declare(strict_types=1);

namespace app\common\service;

use app\common\library\game\StreakWinReward;
use support\think\Db;
use Throwable;

/**
 * 开奖后结算注单：写入 win_amount、status=已结算；中奖时入账并记 user_wallet_record（biz_type=payout）。
 * 连胜赔率来自 game_config.streak_win_reward；结算后更新 user.current_streak（未中奖则连胜归 0）。
 */
final class GameBetSettleService
{
    public const PLAY_STATUS_PENDING_DRAW = 1;
    public const PLAY_STATUS_SETTLED = 2;
    public const PLAY_STATUS_REFUNDED = 3;
    public const PLAY_STATUS_RETURNED = 4;
    public const PLAY_STATUS_PENDING_REVIEW = 5;

    public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount';

    /** 小奖（非大奖档）中奖：移动端弹窗/通知专用 */
    public const TOPIC_BET_WIN = 'bet.win';

    /**
     * 对指定期次按开奖号码结算所有「待开奖」注单；同一注单幂等（仅 status=1 会更新）。
     *
     * @return array{
     *     jackpot_hits: list<array{user_id: int, nickname: string, period_no: string, total_win: string, result_number: int}>,
     *     bet_wins: list<array<string, mixed>>
     * }
     *
     * @throws Throwable
     */
    public static function settleBetsForDraw(int $recordId, int $resultNumber): array
    {
        if ($recordId <= 0 || $resultNumber < 1) {
            return ['jackpot_hits' => [], 'bet_wins' => []];
        }

        $now = time();
        $jackpotMaxAmount = self::jackpotMaxAmount();
        $bets = Db::name('bet_order')
            ->where('period_id', $recordId)
            ->where('status', self::PLAY_STATUS_PENDING_DRAW)
            ->order('id', 'asc')
            ->select()
            ->toArray();

        /** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
        $aggregateByUser = [];

        /** @var array<int, array{streak_at: int, had_win: bool}> */
        $userOutcome = [];

        /** @var array<int, true> */
        $jackpotNotify = [];

        /** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, bets: list<array{bet_id: int, win_amount: string}>}> */
        $smallWinByUser = [];

        foreach ($bets as $bet) {
            $betId = (int) ($bet['id'] ?? 0);
            if ($betId <= 0) {
                continue;
            }

            $userId = (int) ($bet['user_id'] ?? 0);
            if ($userId > 0 && !isset($userOutcome[$userId])) {
                $userOutcome[$userId] = [
                    'streak_at' => (int) ($bet['streak_at_bet'] ?? 0),
                    'had_win'   => false,
                ];
            }

            $win = self::computeWinAmount($bet, $resultNumber);
            $jackpot = '0.00';
            $needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount);
            $nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED;

            $affected = Db::name('bet_order')
                ->where('id', $betId)
                ->where('status', self::PLAY_STATUS_PENDING_DRAW)
                ->update([
                    'win_amount' => $win,
                    'jackpot_extra_amount' => $jackpot,
                    'status' => $nextStatus,
                    'update_time' => $now,
                ]);

            if ($affected === 0) {
                continue;
            }

            self::creditUserBetFlow($bet, $now);

            if ($userId > 0) {
                if (bccomp($win, '0', 2) > 0) {
                    $userOutcome[$userId]['had_win'] = true;
                }
                if (bccomp($win, '0', 2) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) {
                    $jackpotNotify[$userId] = true;
                }
            }

            if ($userId <= 0) {
                continue;
            }

            $balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
            if (!$needReview && bccomp($win, '0', 2) > 0) {
                $paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber);
                if ($paid !== null) {
                    $balanceAfter = $paid;
                }
                $streakAtBet = (int) ($bet['streak_at_bet'] ?? 0);
                if ($paid !== null && !StreakWinReward::isJackpotForStreakAtBet($streakAtBet)) {
                    if (!isset($smallWinByUser[$userId])) {
                        $smallWinByUser[$userId] = [
                            'user_id' => $userId,
                            'period_id' => $recordId,
                            'period_no' => (string) ($bet['period_no'] ?? ''),
                            'result_number' => $resultNumber,
                            'total_win' => '0.00',
                            'balance_after' => $balanceAfter,
                            'bets' => [],
                        ];
                    }
                    $smallWinByUser[$userId]['total_win'] = bcadd($smallWinByUser[$userId]['total_win'], $win, 2);
                    $smallWinByUser[$userId]['balance_after'] = $balanceAfter;
                    $smallWinByUser[$userId]['bets'][] = [
                        'bet_id' => $betId,
                        'win_amount' => $win,
                    ];
                }
            }

            $periodNo = (string) ($bet['period_no'] ?? '');
            if (!isset($aggregateByUser[$userId])) {
                $aggregateByUser[$userId] = [
                    'period_no'     => $periodNo,
                    'total_win'     => '0.00',
                    'balance_after' => $balanceAfter,
                    'orders'        => [],
                ];
            }
            $aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 2);
            $aggregateByUser[$userId]['balance_after'] = $balanceAfter;
            $aggregateByUser[$userId]['orders'][] = [
                'order_no'   => (string) $betId,
                'win_amount' => $win,
                'hit'        => bccomp($win, '0', 2) > 0,
            ];
        }

        foreach ($userOutcome as $userId => $info) {
            $streakAt = (int) ($info['streak_at'] ?? 0);
            $hadWin = (bool) ($info['had_win'] ?? false);
            if ($hadWin) {
                $next = $streakAt + 1;
                if ($next > 10) {
                    $next = 10;
                }
            } else {
                $next = 0;
            }
            Db::name('user')->where('id', $userId)->update([
                'current_streak' => $next,
                'update_time'    => $now,
            ]);
            GameHotDataCoordinator::afterUserCommitted($userId);
            GameWebSocketPayloadHelper::publishUserStreak($userId, $next);
        }

        $jackpotHits = [];
        $hitUserIds = [];
        foreach ($jackpotNotify as $uid => $_) {
            if (!isset($aggregateByUser[$uid])) {
                continue;
            }
            $agg = $aggregateByUser[$uid];
            if (bccomp($agg['total_win'], '0', 2) <= 0) {
                continue;
            }
            $hitUserIds[] = (int) $uid;
        }
        $userNameMap = self::loadUserDisplayNames($hitUserIds);
        foreach ($hitUserIds as $uid) {
            $agg = $aggregateByUser[$uid];
            $jackpotHits[] = [
                'user_id'        => $uid,
                'nickname'       => $userNameMap[$uid] ?? ('用户' . $uid),
                'period_no'      => (string) ($agg['period_no'] ?? ''),
                'total_win'      => (string) $agg['total_win'],
                'result_number'  => $resultNumber,
            ];
        }

        $betWins = array_values($smallWinByUser);

        return ['jackpot_hits' => $jackpotHits, 'bet_wins' => $betWins];
    }

    /**
     * 事务提交后推送小奖中奖（bet.win）。
     *
     * @param list<array<string, mixed>> $betWins
     */
    public static function publishBetWinsAfterCommit(array $betWins): void
    {
        foreach ($betWins as $payload) {
            if (!is_array($payload) || empty($payload['user_id'])) {
                continue;
            }
            $userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT);
            if ($userId === false || $userId <= 0) {
                continue;
            }
            $data = array_merge($payload, [
                'is_jackpot' => false,
                'server_time' => time(),
            ]);
            GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data);
        }
    }

    /**
     * 批量读取用户展示名：nickname 优先；空则 fallback 到 username；仍空则返回空串（调用方自行兜底）。
     *
     * @param list<int> $userIds
     * @return array<int, string>
     */
    private static function loadUserDisplayNames(array $userIds): array
    {
        $userIds = array_values(array_unique(array_filter($userIds, static fn ($v): bool => $v > 0)));
        if ($userIds === []) {
            return [];
        }
        $rows = Db::name('user')
            ->whereIn('id', $userIds)
            ->field(['id', 'nickname', 'username'])
            ->select()
            ->toArray();
        $out = [];
        foreach ($rows as $row) {
            $uid = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : 0;
            if ($uid <= 0) {
                continue;
            }
            $nickname = isset($row['nickname']) && is_string($row['nickname']) ? trim($row['nickname']) : '';
            if ($nickname !== '') {
                $out[$uid] = $nickname;
                continue;
            }
            $username = isset($row['username']) && is_string($row['username']) ? trim($row['username']) : '';
            $out[$uid] = $username;
        }

        return $out;
    }

    /**
     * 大奖审核通过后派彩（幂等）：仅当 play_record.status=待审核 且 win_amount>=阈值时执行。
     *
     * @return array{ok: bool, msg: string, balance_after?: string}
     */
    public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array
    {
        if ($playRecordId <= 0) {
            return ['ok' => false, 'msg' => __('Parameter error')];
        }
        // 兼容：bet_order 可能是 VIEW，且 * 列表会固化；审核字段始终以 game_play_record 为准
        $row = Db::name('game_play_record')->where('id', $playRecordId)->find();
        if (!is_array($row)) {
            $row = Db::name('bet_order')->where('id', $playRecordId)->find();
        }
        if (!is_array($row)) {
            return ['ok' => false, 'msg' => __('Record not found')];
        }
        $status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
        if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
            return ['ok' => false, 'msg' => __('This record does not require review')];
        }
        $winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2);
        $threshold = self::jackpotMaxAmount();
        if (!self::shouldRequireJackpotReview($winAmount, $threshold)) {
            return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')];
        }
        $userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0;
        if ($userId <= 0) {
            return ['ok' => false, 'msg' => __('Order is missing user info')];
        }
        $now = time();
        $balanceAfter = null;
        if (bccomp($winAmount, '0', 2) > 0) {
            $balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩');
        }
        $reviewRemark = trim($remark);
        if ($reviewRemark === '') {
            $reviewRemark = 'approved';
        }
        $update = [
            'status' => self::PLAY_STATUS_SETTLED,
            'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
            'review_time' => $now,
            'review_remark' => substr($reviewRemark, 0, 255),
            'update_time' => $now,
        ];
        // 优先写主表
        Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
        // 兼容写 view 场景（若存在且可写）
        try {
            Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
        } catch (\Throwable) {
        }

        $out = ['ok' => true, 'msg' => __('Approved')];
        if (is_string($balanceAfter)) {
            $out['balance_after'] = $balanceAfter;
        }
        return $out;
    }

    /**
     * 大奖审核拒绝：仅当 status=待审核 才可操作；拒绝后不派彩，标记为已退回(status=4)。
     *
     * @return array{ok: bool, msg: string}
     */
    public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array
    {
        if ($playRecordId <= 0) {
            return ['ok' => false, 'msg' => __('Parameter error')];
        }
        $reason = trim($remark);
        if ($reason === '') {
            return ['ok' => false, 'msg' => __('Please provide reject reason')];
        }
        $row = Db::name('game_play_record')->where('id', $playRecordId)->find();
        if (!is_array($row)) {
            $row = Db::name('bet_order')->where('id', $playRecordId)->find();
        }
        if (!is_array($row)) {
            return ['ok' => false, 'msg' => __('Record not found')];
        }
        $status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
        if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
            return ['ok' => false, 'msg' => __('This record does not require review')];
        }
        $now = time();
        $update = [
            'status' => self::PLAY_STATUS_RETURNED,
            'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
            'review_time' => $now,
            'review_remark' => substr($reason, 0, 255),
            'update_time' => $now,
        ];
        Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
        try {
            Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
        } catch (\Throwable) {
        }
        return ['ok' => true, 'msg' => __('Rejected')];
    }

    /**
     * 补偿：库中已结束局次但注单仍为待开奖的，可重复调用（幂等）。
     */
    public static function settlePendingForEndedRecords(): int
    {
        $rows = Db::name('game_record')
            ->where('status', 2)
            ->whereNotNull('result_number')
            ->field(['id', 'result_number'])
            ->order('id', 'asc')
            ->select()
            ->toArray();

        $count = 0;
        foreach ($rows as $row) {
            $rid = (int) ($row['id'] ?? 0);
            $rn = (int) ($row['result_number'] ?? 0);
            if ($rid <= 0 || $rn < 1) {
                continue;
            }
            $pending = Db::name('bet_order')
                ->where('period_id', $rid)
                ->where('status', self::PLAY_STATUS_PENDING_DRAW)
                ->count();
            if ($pending === 0) {
                continue;
            }
            Db::startTrans();
            try {
                self::settleBetsForDraw($rid, $rn);
                Db::commit();
                $count++;
            } catch (Throwable $e) {
                Db::rollback();
                throw $e;
            }
        }

        return $count;
    }

    /**
     * 应付派彩：开奖号码 ∈ pick_numbers 即中奖；整笔 total_amount × odds_factor（odds_factor 来自连胜奖励表对应档位）。
     */
    public static function computeWinAmount(array $bet, int $resultNumber): string
    {
        $pickNumbers = $bet['pick_numbers'] ?? null;
        if (is_string($pickNumbers)) {
            $decoded = json_decode($pickNumbers, true);
            $pickNumbers = is_array($decoded) ? $decoded : [];
        }
        if (!is_array($pickNumbers)) {
            $pickNumbers = [];
        }
        if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) {
            return '0.00';
        }
        $total = (string) ($bet['total_amount'] ?? '0');
        $streak = (int) ($bet['streak_at_bet'] ?? 0);
        $odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);

        return bcmul($total, $odds, 2);
    }

    /**
     * 累加玩家打码量（流水）：按本注单 total_amount 1:1 加到 user.bet_flow_coin。
     *
     * 幂等性由调用点保证：只有 bet_order 首次从 status=1 变更为 status=2（返回 $affected=1）
     * 时才会调用本方法，重复结算不会触发。
     */
    private static function creditUserBetFlow(array $bet, int $now): void
    {
        $userId = isset($bet['user_id']) && is_numeric($bet['user_id']) ? intval($bet['user_id']) : 0;
        if ($userId <= 0) {
            return;
        }
        $totalRaw = $bet['total_amount'] ?? '0';
        $total = is_string($totalRaw) ? trim($totalRaw) : (is_numeric($totalRaw) ? strval($totalRaw) : '0');
        if ($total === '' || !is_numeric($total)) {
            return;
        }
        $flow = bcadd($total, '0', 2);
        if (bccomp($flow, '0', 2) <= 0) {
            return;
        }
        Db::name('user')
            ->where('id', $userId)
            ->update([
                'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow),
                'update_time'   => $now,
            ]);
        GameHotDataCoordinator::afterUserCommitted($userId);
    }

    /**
     * @return string|null 派彩后余额；已幂等入账过时返回当前余额；失败或未执行派彩返回 null
     */
    private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark, ?int $resultNumber = null): ?string
    {
        $userId = (int) ($bet['user_id'] ?? 0);
        if ($userId <= 0) {
            return null;
        }

        $idem = 'payout_bet_' . $betId;
        if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
            $coin = Db::name('user')->where('id', $userId)->value('coin');

            return (string) ($coin ?? '0');
        }

        $user = Db::name('user')->where('id', $userId)->find();
        if (!$user) {
            return null;
        }

        $before = (string) ($user['coin'] ?? '0');
        $after = bcadd($before, $winAmount, 2);

        Db::name('user_wallet_record')->insert([
            'user_id' => $userId,
            'channel_id' => $bet['channel_id'] ?? null,
            'biz_type' => 'payout',
            'direction' => 1,
            'amount' => $winAmount,
            'balance_before' => $before,
            'balance_after' => $after,
            'ref_type' => 'bet_order',
            'ref_id' => $betId,
            'idempotency_key' => $idem,
            'operator_admin_id' => $operatorAdminId,
            'remark' => $remark !== '' ? $remark : '压注派彩',
            'create_time' => $now,
        ]);

        Db::name('user')->where('id', $userId)->update([
            'coin' => $after,
            'update_time' => $now,
        ]);
        GameHotDataCoordinator::afterUserCommitted($userId);
        $walletPayload = [
            'user_id' => $userId,
            'balance_after' => $after,
            'biz_type' => 'payout',
            'ref_id' => $betId,
            'amount' => $winAmount,
            'period_no' => (string) ($bet['period_no'] ?? ''),
            'period_id' => isset($bet['period_id']) && is_numeric($bet['period_id']) ? (int) $bet['period_id'] : 0,
            'changed_at' => $now,
        ];
        if ($resultNumber !== null && $resultNumber > 0) {
            $walletPayload['result_number'] = $resultNumber;
        }
        GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId));

        return $after;
    }

    private static function jackpotMaxAmount(): string
    {
        // 结算属于高频长驻进程逻辑：为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效，
        // 这里直接读库拿最新值（本方法在 settleBetsForDraw 中仅调用一次）。
        $row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
        if (!is_array($row)) {
            return '0.00';
        }
        $raw = $row['config_value'] ?? null;
        if ($raw === null || $raw === '') {
            return '0.00';
        }
        $v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
        if ($v === '' || !is_numeric($v)) {
            return '0.00';
        }
        $normalized = bcadd($v, '0', 2);
        if (bccomp($normalized, '0', 2) <= 0) {
            return '0.00';
        }
        return $normalized;
    }

    private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool
    {
        if (bccomp($threshold, '0', 2) <= 0) {
            return false;
        }
        return bccomp($winAmount, $threshold, 2) >= 0;
    }
}
