<?php

declare(strict_types=1);

namespace app\api\controller\v1;

use ba\Random;
use app\common\controller\Api;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\model\MallItem;
use app\common\model\MallClaimLog;
use app\common\model\MallDailyPush;
use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\library\MallPlayxRatios;
use app\common\library\MallDailyPushRawLogger;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
use support\Response;

/**
 * PlayX 积分商城 API
 */
class Playx extends Api
{
    /**
     * 从请求解析 mall_user_asset.id（muser token、session、user_id 均指向资产表主键或 playx_user_id）
     */
    private function resolvePlayxAssetIdFromRequest(Request $request): ?int
    {
        $sessionId = strval($request->post('session_id', $request->get('session_id', '')));
        if ($sessionId !== '') {
            $session = MallSession::where('session_id', $sessionId)->find();
            if ($session) {
                $expireTime = intval($session->expire_time ?? 0);
                if ($expireTime > time()) {
                    $asset = MallUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find();
                    if ($asset) {
                        return intval($asset->getKey());
                    }
                }
            }
            $assetId = $this->resolveAssetIdByToken($sessionId);
            if ($assetId !== null) {
                return $assetId;
            }
        }

        $token = strval($request->post('token', $request->get('token', '')));
        if ($token === '') {
            $token = get_auth_token(['ba', 'token'], $request);
        }
        if ($token !== '') {
            return $this->resolveAssetIdByToken($token);
        }

        $userId = strval($request->post('user_id', $request->get('user_id', '')));
        if ($userId === '') {
            return null;
        }
        if (ctype_digit($userId)) {
            return intval($userId);
        }

        $asset = MallUserAsset::where('playx_user_id', $userId)->find();
        if ($asset) {
            return intval($asset->getKey());
        }

        return null;
    }

    private function resolveAssetIdByToken(string $token): ?int
    {
        $tokenData = Token::get($token);
        $tokenType = strval($tokenData['type'] ?? '');
        $isMemberOrMall = $tokenType === UserAuth::TOKEN_TYPE || $tokenType === UserAuth::TOKEN_TYPE_MALL_USER;
        if (!empty($tokenData)
            && $isMemberOrMall
            && intval($tokenData['expire_time'] ?? 0) > time()
            && intval($tokenData['user_id'] ?? 0) > 0
        ) {
            return intval($tokenData['user_id']);
        }

        return null;
    }

    private function buildTempPhone(): ?string
    {
        for ($i = 0; $i < 8; $i++) {
            $candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
            if (!MallUserAsset::where('phone', $candidate)->find()) {
                return $candidate;
            }
        }

        return null;
    }

    private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallUserAsset
    {
        $asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
        if ($asset) {
            return $asset;
        }

        $effectiveUsername = trim($username);
        if ($effectiveUsername === '') {
            $effectiveUsername = 'playx_' . $playxUserId;
        }
        $byName = MallUserAsset::where('username', $effectiveUsername)->find();
        if ($byName) {
            $byName->playx_user_id = $playxUserId;
            $byName->save();

            return $byName;
        }

        $phone = $this->buildTempPhone();
        if ($phone === null) {
            return null;
        }
        $pwd = hash_password(Random::build('alnum', 16));
        $now = time();

        return MallUserAsset::create([
            'playx_user_id'            => $playxUserId,
            'username'                 => $effectiveUsername,
            'phone'                    => $phone,
            'password'                 => $pwd,
            'admin_id'                 => 0,
            'locked_points'            => 0,
            'available_points'         => 0,
            'today_limit'              => 0,
            'today_claimed'            => 0,
            'today_limit_date'         => null,
            'create_time'              => $now,
            'update_time'              => $now,
        ]);
    }

    private function getAssetById(int $assetId): ?MallUserAsset
    {
        return MallUserAsset::where('id', $assetId)->find();
    }

    /**
     * 按每日推送记录同步用户资产主信息；applyAssetDelta=true 时才落资产增量。
     */
    private function syncAssetByDailyPush(string $playxUserId, string $username, string $date, int $newLocked, int $todayLimit, bool $applyAssetDelta): ?MallUserAsset
    {
        $asset = $this->ensureAssetForPlayx($playxUserId, $username);
        if (!$asset) {
            return null;
        }

        $asset->playx_user_id = $playxUserId;
        $uname = trim($username);
        if ($uname !== '') {
            $asset->username = $uname;
        }

        if ($applyAssetDelta) {
            if ($asset->today_limit_date !== $date) {
                $asset->today_claimed = 0;
                $asset->today_limit_date = $date;
            }
            $asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
            $asset->today_limit = $todayLimit;
        }

        $asset->save();

        return $asset;
    }

    /**
     * Daily Push API - PlayX 调用商城接收 T+1 数据
     * POST /api/v1/mall/dailyPush
     */
    public function dailyPush(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $rawBody = $request->rawBody();
        $body = $request->post();
        if (empty($body) && $rawBody !== '') {
            $body = json_decode($rawBody, true) ?? [];
        }
        if ($rawBody === '') {
            $rawBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }
        MallDailyPushRawLogger::log($request, $rawBody);

        $secret = config('playx.daily_push_secret', '');
        if ($secret !== '') {
            $sig = $request->header('X-Signature', '');
            $ts = $request->header('X-Timestamp', '');
            $rid = $request->header('X-Request-Id', '');
            if ($sig === '' || $ts === '' || $rid === '') {
                return $this->error(__('Daily push signature missing or incomplete'), null, 0, ['statusCode' => 401]);
            }
            $canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', json_encode($body));
            $expected = hash_hmac('sha256', $canonical, $secret);
            if (!hash_equals($expected, $sig)) {
                return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
            }
        }

        // ===== 新版批量上报格式 =====
        // 兼容你们截图：{ report_date, member:[{member_id, login, lty_deposit, lty_withdrawal, yesterday_total_w, yesterday_total_deposit}, ...] }
        if (isset($body['report_date']) && isset($body['member']) && is_array($body['member'])) {
            $reportDate = $body['report_date'];
            $date = '';
            if (is_numeric($reportDate)) {
                $date = date('Y-m-d', intval($reportDate));
            } else {
                $date = strval($reportDate);
            }

            $members = $body['member'];
            if ($date === '' || empty($members)) {
                return $this->error(__('Missing required fields: report_date, member'));
            }

            $requestId = strval($body['request_id'] ?? '');
            if ($requestId === '') {
                $requestId = 'report_' . $date;
            }

            $ratios = MallPlayxRatios::get();
            $returnRatio = $ratios['return_ratio'];
            $unlockRatio = $ratios['unlock_ratio'];

            $results = [];
            $allDeduped = true;

            foreach ($members as $m) {
                $playxUserId = strval($m['member_id'] ?? '');
                if ($playxUserId === '') {
                    return $this->error(__('Missing required fields: member_id'));
                }

                $username = strval($m['login'] ?? '');
                $yesterdayWinLossNet = $m['yesterday_total_w'] ?? 0;
                $yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
                $lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
                $lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;

                $exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
                if ($exists) {
                    $newLocked = 0;
                    if ($yesterdayWinLossNet < 0) {
                        $newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
                    }
                    $todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
                    $asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $newLocked, $todayLimit, false);
                    if (!$asset) {
                        return $this->error(__('Failed to ensure PlayX user asset'));
                    }

                    $results[] = [
                        'user_id'  => $playxUserId,
                        'deduped'  => true,
                        'accepted' => true,
                        'message'  => __('Duplicate input'),
                    ];
                    continue;
                }

                Db::startTrans();
                try {
                    MallDailyPush::create([
                        'user_id'                  => $playxUserId,
                        'date'                      => $date,
                        'username'                 => $username,
                        'yesterday_win_loss_net'   => $yesterdayWinLossNet,
                        'yesterday_total_deposit' => $yesterdayTotalDeposit,
                        'lifetime_total_deposit'  => $lifetimeTotalDeposit,
                        'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
                        'create_time'              => time(),
                    ]);

                    $newLocked = 0;
                    if ($yesterdayWinLossNet < 0) {
                        $newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
                    }
                    $todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));

                    $asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $newLocked, $todayLimit, true);
                    if (!$asset) {
                        throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
                    }

                    Db::commit();

                    $results[] = [
                        'user_id'  => $playxUserId,
                        'deduped'  => false,
                        'accepted' => true,
                        'message'  => __('Ok'),
                    ];
                    $allDeduped = false;
                } catch (\Throwable $e) {
                    Db::rollback();
                    return $this->error($e->getMessage());
                }
            }

            return $this->success('', [
                'request_id' => $requestId,
                'accepted'   => true,
                'deduped'    => $allDeduped,
                'message'    => $allDeduped ? __('Duplicate input') : __('Ok'),
                'results'    => $results,
            ]);
        }

        // ===== 旧版单条上报格式（兼容）=====
        $requestId = $body['request_id'] ?? '';
        $date = $body['date'] ?? '';
        $playxUserId = strval($body['user_id'] ?? '');
        $yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
        $yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;

        if ($requestId === '' || $date === '' || $playxUserId === '') {
            return $this->error(__('Missing required fields: request_id, date, user_id'));
        }

        $exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
        if ($exists) {
            $newLocked = 0;
            $ratios = MallPlayxRatios::get();
            $returnRatio = $ratios['return_ratio'];
            $unlockRatio = $ratios['unlock_ratio'];
            if ($yesterdayWinLossNet < 0) {
                $newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
            }
            $todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
            $asset = $this->syncAssetByDailyPush($playxUserId, strval($body['username'] ?? ''), $date, $newLocked, $todayLimit, false);
            if (!$asset) {
                return $this->error(__('Failed to ensure PlayX user asset'));
            }

            return $this->success('', [
                'request_id' => $requestId,
                'accepted'   => true,
                'deduped'    => true,
                'message'    => __('Duplicate input'),
            ]);
        }

        Db::startTrans();
        try {
            MallDailyPush::create([
                'user_id'                  => $playxUserId,
                'date'                     => $date,
                'username'                 => $body['username'] ?? '',
                'yesterday_win_loss_net'   => $yesterdayWinLossNet,
                'yesterday_total_deposit' => $yesterdayTotalDeposit,
                'lifetime_total_deposit'  => $body['lifetime_total_deposit'] ?? 0,
                'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
                'create_time'              => time(),
            ]);

            $newLocked = 0;
            $ratios = MallPlayxRatios::get();
            $returnRatio = $ratios['return_ratio'];
            $unlockRatio = $ratios['unlock_ratio'];
            if ($yesterdayWinLossNet < 0) {
                $newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
            }
            $todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));

            $asset = $this->syncAssetByDailyPush($playxUserId, strval($body['username'] ?? ''), $date, $newLocked, $todayLimit, true);
            if (!$asset) {
                throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
            }

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success('', [
            'request_id' => $requestId,
            'accepted'   => true,
            'deduped'    => false,
            'message'    => __('Ok'),
        ]);
    }

    /**
     * Token 验证 - POST /api/v1/playx/verify-token
     * 配置 playx.verify_token_local_only=true 时：不向 PlayX 请求，且不校验传入 token（联调占位）。
     */
    public function verifyToken(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        if (config('playx.verify_token_local_only', false)) {
            return $this->verifyTokenLocalOpen();
        }

        $token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
        if ($token === '') {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $baseUrl = strval(config('playx.angpow_import.base_url', ''));
        $verifyUrlRaw = strval(config('playx.api.token_verify_url', '/api/v1/auth/verify-token'));
        $verifyUrlTrimmed = trim($verifyUrlRaw);
        $isAbsoluteVerifyUrl = str_starts_with($verifyUrlTrimmed, 'http://')
            || str_starts_with($verifyUrlTrimmed, 'https://');

        if ($isAbsoluteVerifyUrl) {
            $targetVerifyUrl = $verifyUrlTrimmed;
        } else {
            $verifyPath = ltrim($verifyUrlTrimmed, '/');
            if ($verifyPath === '') {
                return $this->error(__('PlayX API not configured'));
            }
            if ($baseUrl === '') {
                return $this->error(__('PlayX API not configured'));
            }
            $targetVerifyUrl = rtrim($baseUrl, '/') . '/' . $verifyPath;
        }

        try {
            $requestId = 'mall_' . uniqid();

            $clientOptions = [
                'timeout'     => 10,
                'http_errors' => false,
            ];
            if (!$isAbsoluteVerifyUrl) {
                $clientOptions['base_uri'] = rtrim($baseUrl, '/') . '/';
            }
            $client = new \GuzzleHttp\Client($clientOptions);

            if ($isAbsoluteVerifyUrl) {
                $merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
                $authKey = strval(config('playx.angpow_import.auth_key', ''));
                if ($merchantCode === '' || $authKey === '') {
                    return $this->error(__('PlayX API not configured'));
                }

                // 回调网关要求 Body 含 merchant_code、request_date（Unix 秒）；签名字符串与 Body 参与签名字段一致（HMAC 同 angpow）。
                $requestDate = strval(time());
                $signatureInput = 'merchant_code=' . $merchantCode
                    . '&request_date=' . $requestDate
                    . '&request_id=' . $requestId
                    . '&token=' . $token;
                $signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
                if ($signature === null) {
                    return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
                }

                $headers = [
                    'Content-Type'        => 'application/json',
                    'X-Request-Signature'   => $signature,
                ];
                $payload = [
                    'merchant_code' => $merchantCode,
                    'request_date'  => $requestDate,
                    'request_id'    => $requestId,
                    'token'         => $token,
                ];
                $res = $client->post($targetVerifyUrl, [
                    'headers' => $headers,
                    'json'    => $payload,
                ]);
            } else {
                $merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
                $authKey = strval(config('playx.angpow_import.auth_key', ''));
                if ($merchantCode === '' || $authKey === '') {
                    return $this->error(__('PlayX API not configured'));
                }

                $requestDate = strval(time());
                $signatureInput = 'merchant_code=' . $merchantCode
                    . '&request_date=' . $requestDate
                    . '&request_id=' . $requestId
                    . '&token=' . $token;
                $signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
                if ($signature === null) {
                    return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
                }

                $headers = [
                    'Content-Type' => 'application/json',
                    'X-Request-Signature' => $signature,
                    'X-Signature' => $signature,
                    'X-Request-Date' => $requestDate,
                    'X-Request-ID' => $requestId,
                ];
                $payload = [
                    'merchant_code' => $merchantCode,
                    'request_date'  => $requestDate,
                    'request_id'    => $requestId,
                    'token'         => $token,
                ];
                $verifyPath = ltrim($verifyUrlTrimmed, '/');
                $res = $client->post($verifyPath, [
                    'headers' => $headers,
                    'json'    => $payload,
                ]);
            }
            $data = json_decode(strval($res->getBody()), true);
            $code = $res->getStatusCode();
            if ($code !== 200 || empty($data['user_id'])) {
                $remoteMsg = '';
                if (is_array($data)) {
                    $candidateMsg = $data['message'] ?? ($data['msg'] ?? ($data['error'] ?? ''));
                    if (is_string($candidateMsg) && $candidateMsg !== '') {
                        $remoteMsg = $candidateMsg;
                    }
                }
                if ($remoteMsg === '') {
                    $bodyText = strval($res->getBody());
                    if ($bodyText !== '') {
                        $remoteMsg = mb_substr($bodyText, 0, 300);
                    }
                }
                if ($remoteMsg === '' || str_contains(strtolower($remoteMsg), '<html')) {
                    $remoteMsg = __('PlayX verify upstream failed', [strval($code), $targetVerifyUrl]);
                }
                $msg = $remoteMsg !== '' ? $remoteMsg : __('Token expiration');

                return $this->error($msg, null, 0, ['statusCode' => 401]);
            }

            $userId = strval($data['user_id']);
            $username = strval($data['username'] ?? '');

            $expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
            if (!empty($data['token_expire_at'])) {
                $ts = strtotime(strval($data['token_expire_at']));
                if ($ts !== false && $ts > 0) {
                    $expireAt = intval($ts);
                }
            }

            $asset = $this->ensureAssetForPlayx($userId, $username);
            if ($asset === null) {
                return $this->error(__('Failed to ensure PlayX user asset'));
            }

            $sessionId = bin2hex(random_bytes(16));
            MallSession::create([
                'session_id'  => $sessionId,
                'user_id'     => $userId,
                'username'    => $username,
                'expire_time' => $expireAt,
                'create_time' => time(),
                'update_time' => time(),
            ]);

            return $this->success('', [
                'session_id'      => $sessionId,
                'user_id'         => $userId,
                'username'        => $username,
                'token_expire_at' => date('c', $expireAt),
            ]);
        } catch (\Throwable $e) {
            return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
        }
    }

    /**
     * 本地联调：不校验 token，按配置默认用户签发新 mall_session（待 PlayX 远程校验就绪后关闭 verify_token_local_only）。
     */
    private function verifyTokenLocalOpen(): Response
    {
        $playxUserId = strval(config('playx.verify_token_local_default_user_id', 'testmyr'));
        $username = strval(config('playx.verify_token_local_default_username', 'yangyang123'));
        if ($playxUserId === '') {
            return $this->error(__('PlayX API not configured'));
        }

        $asset = $this->ensureAssetForPlayx($playxUserId, $username);
        if ($asset === null) {
            return $this->error(__('Failed to ensure PlayX user asset'));
        }

        $expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
        $sessionId = bin2hex(random_bytes(16));
        $now = time();
        MallSession::create([
            'session_id'  => $sessionId,
            'user_id'     => $playxUserId,
            'username'    => $username,
            'expire_time' => $expireAt,
            'create_time' => $now,
            'update_time' => $now,
        ]);

        return $this->success('Success', [
            'session_id'      => $sessionId,
            'user_id'         => $playxUserId,
            'username'        => $username,
            'token_expire_at' => date('c', $expireAt),
        ]);
    }

    /**
     * 生成 token verify 所需签名：Base64(HMAC-SHA1(canonical, key))
     */
    private function buildPlayxTokenVerifySignature(string $input, string $authKey): ?string
    {
        $keyBytes = null;

        $maybeBase64 = base64_decode($authKey, true);
        if (is_string($maybeBase64) && $maybeBase64 !== '') {
            $keyBytes = $maybeBase64;
        }

        if ($keyBytes === null) {
            $isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
            if ($isHex) {
                $hex = hex2bin($authKey);
                if (is_string($hex) && $hex !== '') {
                    $keyBytes = $hex;
                }
            }
        }

        if ($keyBytes === null) {
            $keyBytes = $authKey;
        }

        $raw = hash_hmac('sha1', $input, $keyBytes, true);
        if (!is_string($raw) || $raw === '') {
            return null;
        }

        return base64_encode($raw);
    }

    /**
     * 用户资产
     * GET /api/v1/playx/assets?user_id=xxx
     */
    public function assets(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $asset = $this->getAssetById($assetId);
        if (!$asset) {
            return $this->success('', [
                'locked_points'   => 0,
                'available_points' => 0,
                'today_limit'     => 0,
                'today_claimed'    => 0,
                'withdrawable_cash' => 0,
            ]);
        }

        $ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
        $withdrawableCash = round($asset->available_points * $ratio, 2);

        return $this->success('', [
            'locked_points'    => $asset->locked_points,
            'available_points' => $asset->available_points,
            'today_limit'      => $asset->today_limit,
            'today_claimed'    => $asset->today_claimed,
            'withdrawable_cash' => $withdrawableCash,
        ]);
    }

    /**
     * 领取
     * POST /api/v1/playx/claim
     */
    public function claim(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $claimRequestId = strval($request->post('claim_request_id', ''));
        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }
        if ($claimRequestId === '') {
            return $this->error(__('claim_request_id and user_id/session_id required'));
        }

        $asset = $this->getAssetById($assetId);
        if (!$asset || strval($asset->playx_user_id ?? '') === '') {
            return $this->error(__('User asset not found'));
        }
        $playxUserId = strval($asset->playx_user_id);

        $exists = MallClaimLog::where('claim_request_id', $claimRequestId)->find();
        if ($exists) {
            return $this->success('', $this->formatAsset($asset));
        }

        $todayLimitDate = date('Y-m-d');
        if ($asset->today_limit_date !== $todayLimitDate) {
            $asset->today_claimed = 0;
            $asset->today_limit_date = $todayLimitDate;
        }

        $remain = $asset->today_limit - $asset->today_claimed;
        if ($asset->locked_points <= 0 || $remain <= 0) {
            return $this->error(__('No points to claim or limit reached'));
        }

        $canClaim = min($asset->locked_points, $remain);

        Db::startTrans();
        try {
            MallClaimLog::create([
                'claim_request_id' => $claimRequestId,
                'user_id'          => $playxUserId,
                'claimed_amount'   => $canClaim,
                'create_time'      => time(),
            ]);

            $asset->locked_points -= $canClaim;
            $asset->available_points += $canClaim;
            $asset->today_claimed += $canClaim;
            $asset->save();

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        $asset->refresh();
        return $this->success(__('Claim success'), $this->formatAsset($asset));
    }

    /**
     * 商品列表
     * GET /api/v1/playx/items?type=BONUS|PHYSICAL|WITHDRAW
     */
    public function items(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $type = $request->get('type', '');
        $typeMap = ['BONUS' => 1, 'PHYSICAL' => 2, 'WITHDRAW' => 3];
        $query = MallItem::where('status', 1);
        if ($type !== '' && isset($typeMap[$type])) {
            $query->where('type', $typeMap[$type]);
        }
        $list = $query->order('sort', 'asc')->select();

        $out = [];
        foreach ($list->toArray() as $row) {
            $out[] = $this->applyMallItemLocaleForApi($row);
        }

        return $this->success('', ['list' => $out]);
    }

    /**
     * 红利兑换
     * POST /api/v1/playx/bonus/redeem
     */
    public function bonusRedeem(Request $request): Response
    {
        return $this->redeemBonus($request);
    }

    /**
     * 实物兑换
     * POST /api/v1/playx/physical/redeem
     */
    public function physicalRedeem(Request $request): Response
    {
        return $this->redeemPhysical($request);
    }

    /**
     * 提现申请
     * POST /api/v1/playx/withdraw/apply
     */
    public function withdrawApply(Request $request): Response
    {
        return $this->redeemWithdraw($request);
    }

    /**
     * 订单列表
     * GET /api/v1/playx/orders?user_id=xxx
     */
    public function orders(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }
        $asset = $this->getAssetById($assetId);
        if (!$asset || strval($asset->playx_user_id ?? '') === '') {
            return $this->success('', ['list' => []]);
        }

        $list = MallOrder::where('user_id', strval($asset->playx_user_id))
            ->with(['mallItem'])
            ->order('id', 'desc')
            ->limit(100)
            ->select();

        $arr = $list->toArray();
        foreach ($arr as &$orderRow) {
            if (isset($orderRow['mallItem']) && is_array($orderRow['mallItem'])) {
                $orderRow['mallItem'] = $this->applyMallItemLocaleForApi($orderRow['mallItem']);
            }
        }
        unset($orderRow);

        return $this->success('', ['list' => $arr]);
    }

    /**
     * 积分流水（领取/兑换/退回/管理员调整）
     * GET /api/v1/mall/pointsLogs
     *
     * 鉴权：token / session_id / user_id（同 assets/orders）
     *
     * 参数：
     * - limit: 每页条数（默认 20，最大 100）
     * - cursor: 游标（上一页返回的 next_cursor）
     * - direction: IN | OUT（可选，不传返回全部）
     */
    public function pointsLogs(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }
        $asset = $this->getAssetById($assetId);
        if (!$asset || ($asset->playx_user_id ?? '') === '') {
            return $this->success('', [
                'list' => [],
                'next_cursor' => null,
            ]);
        }

        $playxUserId = $asset->playx_user_id;

        $limit = $request->get('limit', $request->post('limit', '20'));
        if (!is_string($limit) || $limit === '' || !ctype_digit($limit) || $limit === '0') {
            $limit = '20';
        }
        if (strlen($limit) > 3 || $limit > '100') {
            $limit = '100';
        }

        $cursor = $request->get('cursor', $request->post('cursor', ''));
        if (!is_string($cursor)) {
            $cursor = '';
        }

        $direction = $request->get('direction', $request->post('direction', ''));
        $direction = is_string($direction) ? strtoupper(trim($direction)) : '';
        if ($direction !== '' && $direction !== 'IN' && $direction !== 'OUT') {
            $direction = '';
        }

        $sql = <<<SQL
SELECT
  t.biz_type,
  t.direction,
  t.points,
  t.ts,
  t.ref_id,
  t.order_no,
  t.order_status,
  t.item_id,
  t.item_title,
  t.item_type,
  t.item_score,
  t.item_amount,
  t.item_multiplier,
  t.item_category,
  t.item_category_title,
  t.sort_key
FROM (
  SELECT
    'CLAIM' AS biz_type,
    'IN' AS direction,
    cl.claimed_amount AS points,
    cl.create_time AS ts,
    cl.claim_request_id AS ref_id,
    '' AS order_no,
    '' AS order_status,
    0 AS item_id,
    '' AS item_title,
    0 AS item_type,
    0 AS item_score,
    0 AS item_amount,
    0 AS item_multiplier,
    '' AS item_category,
    '' AS item_category_title,
    CONCAT(LPAD(cl.create_time, 10, '0'), '_1_', LPAD(cl.id, 10, '0')) AS sort_key
  FROM mall_claim_log cl
  WHERE cl.user_id = :user_id_claim

  UNION ALL

  SELECT
    CONCAT('REDEEM_', o.type) AS biz_type,
    'OUT' AS direction,
    o.points_cost AS points,
    o.create_time AS ts,
    o.external_transaction_id AS ref_id,
    o.external_transaction_id AS order_no,
    o.status AS order_status,
    o.mall_item_id AS item_id,
    COALESCE(i.title, '') AS item_title,
    COALESCE(i.type, 0) AS item_type,
    COALESCE(i.score, 0) AS item_score,
    COALESCE(i.amount, 0) AS item_amount,
    COALESCE(i.multiplier, 0) AS item_multiplier,
    COALESCE(i.category, '') AS item_category,
    COALESCE(i.category_title, '') AS item_category_title,
    CONCAT(LPAD(o.create_time, 10, '0'), '_2_', LPAD(o.id, 10, '0')) AS sort_key
  FROM mall_order o
  LEFT JOIN mall_item i ON i.id = o.mall_item_id
  WHERE o.user_id = :user_id_redeem AND o.points_cost > 0

  UNION ALL

  SELECT
    'REFUND' AS biz_type,
    'IN' AS direction,
    o.points_cost AS points,
    o.update_time AS ts,
    o.external_transaction_id AS ref_id,
    o.external_transaction_id AS order_no,
    o.status AS order_status,
    o.mall_item_id AS item_id,
    COALESCE(i.title, '') AS item_title,
    COALESCE(i.type, 0) AS item_type,
    COALESCE(i.score, 0) AS item_score,
    COALESCE(i.amount, 0) AS item_amount,
    COALESCE(i.multiplier, 0) AS item_multiplier,
    COALESCE(i.category, '') AS item_category,
    COALESCE(i.category_title, '') AS item_category_title,
    CONCAT(LPAD(o.update_time, 10, '0'), '_3_', LPAD(o.id, 10, '0')) AS sort_key
  FROM mall_order o
  LEFT JOIN mall_item i ON i.id = o.mall_item_id
  WHERE o.user_id = :user_id_refund AND o.status = 'REJECTED' AND o.points_cost > 0 AND o.update_time IS NOT NULL

  UNION ALL

  SELECT
    'ADMIN_ADJUST' AS biz_type,
    CASE WHEN pl.score_value > 0 THEN 'IN' ELSE 'OUT' END AS direction,
    ABS(pl.score_value) AS points,
    pl.create_time AS ts,
    pl.id AS ref_id,
    '' AS order_no,
    '' AS order_status,
    0 AS item_id,
    '' AS item_title,
    0 AS item_type,
    0 AS item_score,
    0 AS item_amount,
    0 AS item_multiplier,
    '' AS item_category,
    '' AS item_category_title,
    CONCAT(LPAD(pl.create_time, 10, '0'), '_4_', LPAD(pl.id, 10, '0')) AS sort_key
  FROM mall_points_log pl
  WHERE pl.user_asset_id = :user_asset_id_adjust
) t
WHERE 1=1
SQL;

        $params = [
            'user_id_claim'  => $playxUserId,
            'user_id_redeem' => $playxUserId,
            'user_id_refund' => $playxUserId,
            'user_asset_id_adjust' => $assetId,
        ];

        if ($cursor !== '') {
            $sql .= "\n  AND t.sort_key < :cursor";
            $params['cursor'] = $cursor;
        }
        if ($direction !== '') {
            $sql .= "\n  AND t.direction = :direction";
            $params['direction'] = $direction;
        }

        $sql .= "\nORDER BY t.sort_key DESC";
        $sql .= "\nLIMIT " . $limit;

        try {
            $rows = Db::query($sql, $params);
        } catch (\Throwable $e) {
            return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
        }

        $list = [];
        $nextCursor = null;
        if (is_array($rows)) {
            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $memo = '';
                if (($row['biz_type'] ?? '') === 'ADMIN_ADJUST') {
                    $memoKey = ($row['direction'] ?? '') === 'OUT'
                        ? 'Administrator deducted %s points'
                        : 'Administrator added %s points';
                    $memo = __($memoKey, [$row['points'] ?? 0]);
                }
                $list[] = [
                    'biz_type' => $row['biz_type'] ?? '',
                    'direction' => $row['direction'] ?? '',
                    'points' => $row['points'] ?? 0,
                    'memo' => $memo,
                    'ts' => $row['ts'] ?? null,
                    'ref_id' => $row['ref_id'] ?? '',
                    'order_no' => $row['order_no'] ?? '',
                    'order_status' => $row['order_status'] ?? '',
                    'item_id' => $row['item_id'] ?? 0,
                    'item_title' => $row['item_title'] ?? '',
                    'item_type' => $row['item_type'] ?? 0,
                    'item_score' => $row['item_score'] ?? 0,
                    'mallItem' => ($row['item_id'] ?? 0) ? [
                        'id' => $row['item_id'] ?? 0,
                        'title' => $row['item_title'] ?? '',
                        'type' => $row['item_type'] ?? 0,
                        'score' => $row['item_score'] ?? 0,
                        'amount' => $row['item_amount'] ?? 0,
                        'multiplier' => $row['item_multiplier'] ?? 0,
                        'category' => $row['item_category'] ?? '',
                        'category_title' => $row['item_category_title'] ?? '',
                    ] : null,
                    'cursor' => $row['sort_key'] ?? '',
                ];
                $nextCursor = $row['sort_key'] ?? $nextCursor;
            }
        }

        $itemsMap = $this->buildMallItemLocaleMapForPointsLog($list);
        foreach ($list as &$row) {
            $id = (int)($row['item_id'] ?? 0);
            if ($id > 0 && isset($itemsMap[$id])) {
                $row['item_title'] = $itemsMap[$id]['title'];
                if (isset($row['mallItem']) && is_array($row['mallItem'])) {
                    $row['mallItem']['title'] = $itemsMap[$id]['title'];
                }
            }
        }
        unset($row);

        return $this->success('', [
            'list' => $list,
            'next_cursor' => $nextCursor,
        ]);
    }

    /**
     * 收货地址列表
     * GET /api/v1/playx/address/list?session_id=xxx
     */
    public function addressList(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $list = MallAddress::where('playx_user_asset_id', $assetId)
            ->order('default_setting', 'desc')
            ->order('id', 'desc')
            ->select();

        return $this->success('', ['list' => $list->toArray()]);
    }

    /**
     * 添加收货地址（可设置默认）
     * POST /api/v1/playx/address/add
     */
    public function addressAdd(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $phone = trim(strval($request->post('phone', '')));
        $receiverName = trim(strval($request->post('receiver_name', '')));
        $region = MallAddress::normalizeRegion($request->post('region', ''));
        $detailAddress = trim(strval($request->post('detail_address', '')));
        $defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;

        if ($phone === '' || $receiverName === '' || $detailAddress === '' || $region === '' || $region === null) {
            return $this->error(__('Missing required fields'));
        }

        Db::startTrans();
        try {
            if ($defaultSetting === 1) {
                MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
            }

            $created = MallAddress::create([
                'playx_user_asset_id' => $assetId,
                'receiver_name'       => $receiverName,
                'phone'               => $phone,
                'region'              => $region,
                'detail_address'      => $detailAddress,
                'default_setting'     => $defaultSetting,
                'create_time'         => time(),
                'update_time'         => time(),
            ]);

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success('', [
            'id' => $created ? $created->id : null,
        ]);
    }

    /**
     * 修改收货地址（包含设置默认地址）
     * POST /api/v1/playx/address/edit
     */
    public function addressEdit(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $id = intval($request->post('id', 0));
        if ($id <= 0) {
            return $this->error(__('Missing required fields'));
        }

        $row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
        if (!$row) {
            return $this->error(__('Record not found'));
        }

        $updates = [];
        if ($request->post('phone', null) !== null) {
            $updates['phone'] = trim(strval($request->post('phone', '')));
        }
        if ($request->post('receiver_name', null) !== null) {
            $updates['receiver_name'] = trim(strval($request->post('receiver_name', '')));
        }
        if ($request->post('region', null) !== null) {
            $updates['region'] = MallAddress::normalizeRegion($request->post('region', ''));
        }
        if ($request->post('detail_address', null) !== null) {
            $updates['detail_address'] = trim(strval($request->post('detail_address', '')));
        }
        if ($request->post('default_setting', null) !== null) {
            $updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
        }

        if (empty($updates)) {
            return $this->success('', ['updated' => false]);
        }

        Db::startTrans();
        try {
            if (isset($updates['default_setting']) && $updates['default_setting'] === 1) {
                MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
            }
            $updates['update_time'] = time();
            MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->update($updates);
            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success('', ['updated' => true]);
    }

    /**
     * 删除收货地址
     * POST /api/v1/playx/address/delete
     */
    public function addressDelete(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $id = intval($request->post('id', 0));
        if ($id <= 0) {
            return $this->error(__('Missing required fields'));
        }

        $row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
        if (!$row) {
            return $this->error(__('Record not found'));
        }

        $wasDefault = intval($row->default_setting ?? 0) === 1;

        Db::startTrans();
        try {
            MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->delete();

            if ($wasDefault) {
                $fallback = MallAddress::where('playx_user_asset_id', $assetId)->order('id', 'desc')->find();
                if ($fallback) {
                    $fallback->default_setting = 1;
                    $fallback->update_time = time();
                    $fallback->save();
                }
            }

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success('', ['deleted' => true]);
    }

    private function formatAsset(?MallUserAsset $asset): array
    {
        if (!$asset) {
            return [
                'locked_points'    => 0,
                'available_points' => 0,
                'today_limit'      => 0,
                'today_claimed'    => 0,
                'withdrawable_cash' => 0,
            ];
        }
        $ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
        return [
            'locked_points'     => $asset->locked_points,
            'available_points'   => $asset->available_points,
            'today_limit'       => $asset->today_limit,
            'today_claimed'     => $asset->today_claimed,
            'withdrawable_cash' => round($asset->available_points * $ratio, 2),
        ];
    }

    private function redeemBonus(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $itemId = intval($request->post('item_id', 0));
        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }
        if ($itemId <= 0) {
            return $this->error(__('item_id and user_id/session_id required'));
        }

        $item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_BONUS)->where('status', 1)->find();
        if (!$item) {
            return $this->error(__('Item not found or not available'));
        }

        $asset = $this->getAssetById($assetId);
        if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
            return $this->error(__('Insufficient points'));
        }
        $playxUserId = strval($asset->playx_user_id);

        $multiplier = intval($item->multiplier ?? 0);
        if ($multiplier <= 0) {
            $multiplier = 1;
        }
        $amount = floatval($item->amount ?? 0);

        Db::startTrans();
        try {
            $asset->available_points -= $item->score;
            $asset->save();

            $orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
            $order = MallOrder::create([
                'user_id'                  => $playxUserId,
                'type'                     => MallOrder::TYPE_BONUS,
                'status'                   => MallOrder::STATUS_PENDING,
                'mall_item_id'             => $item->id,
                'points_cost'              => $item->score,
                'amount'                   => $amount,
                'multiplier'               => $multiplier,
                'external_transaction_id'  => $orderNo,
                'grant_status'             => MallOrder::GRANT_NOT_SENT,
                'create_time'              => time(),
                'update_time'              => time(),
                'start_time'               => date('Y-m-d H:i:s', time()),
                'end_time'                 => date('Y-m-d H:i:s', time()+86400*3),
            ]);

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        $baseUrl = config('playx.api.base_url', '');
        if ($baseUrl !== '') {
            $this->callPlayxBonusGrant($order, $item, $playxUserId);
        }

        return $this->success(__('Redeem submitted, please wait about 10 minutes'), [
            'order_id' => $order->id,
            'status'   => 'PENDING',
        ]);
    }

    private function redeemPhysical(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $itemId = intval($request->post('item_id', 0));
        $addressId = intval($request->post('address_id', 0));
        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($itemId <= 0 || $addressId <= 0) {
            return $this->error(__('Missing required fields'));
        }
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }

        $addrRow = MallAddress::where('id', $addressId)->where('playx_user_asset_id', $assetId)->find();
        if (!$addrRow) {
            return $this->error(__('Shipping address not found'));
        }
        $snapshot = MallAddress::snapshotForPhysicalOrder($addrRow);
        if ($snapshot['receiver_phone'] === '' || $snapshot['receiver_address'] === '' || $snapshot['receiver_name'] === '') {
            return $this->error(__('Missing required fields'));
        }

        $item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_PHYSICAL)->where('status', 1)->find();
        if (!$item) {
            return $this->error(__('Item not found or not available'));
        }
        if (isset($item->stock) && $item->stock < 1) {
            return $this->error(__('Out of stock'));
        }

        $asset = $this->getAssetById($assetId);
        if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
            return $this->error(__('Insufficient points'));
        }
        $playxUserId = strval($asset->playx_user_id);

        Db::startTrans();
        try {
            $asset->available_points -= $item->score;
            $asset->save();

            MallOrder::create([
                'user_id'          => $playxUserId,
                'type'             => MallOrder::TYPE_PHYSICAL,
                'status'           => MallOrder::STATUS_PENDING,
                'mall_item_id'     => $item->id,
                'points_cost'      => $item->score,
                'mall_address_id'  => $addressId,
                'receiver_name'    => $snapshot['receiver_name'],
                'receiver_phone'   => $snapshot['receiver_phone'],
                'receiver_address' => $snapshot['receiver_address'],
                'grant_status'     => MallOrder::GRANT_NOT_APPLICABLE,
                'create_time'      => time(),
                'update_time'      => time(),
            ]);

            if (isset($item->stock)) {
                $item->stock -= 1;
                $item->save();
            }

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success(__('Redeem success'));
    }

    private function redeemWithdraw(Request $request): Response
    {
        $response = $this->initializeApi($request);
        if ($response !== null) {
            return $response;
        }

        $itemId = intval($request->post('item_id', 0));
        $assetId = $this->resolvePlayxAssetIdFromRequest($request);
        if ($assetId === null) {
            return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
        }
        if ($itemId <= 0) {
            return $this->error(__('item_id and user_id/session_id required'));
        }

        $item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_WITHDRAW)->where('status', 1)->find();
        if (!$item) {
            return $this->error(__('Item not found or not available'));
        }

        $asset = $this->getAssetById($assetId);
        if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
            return $this->error(__('Insufficient points'));
        }
        $playxUserId = strval($asset->playx_user_id);

        $multiplier = intval($item->multiplier ?? 0);
        if ($multiplier <= 0) {
            $multiplier = 1;
        }
        $amount = floatval($item->amount ?? 0);

        Db::startTrans();
        try {
            $asset->available_points -= $item->score;
            $asset->save();

            $orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
            $order = MallOrder::create([
                'user_id'                 => $playxUserId,
                'type'                    => MallOrder::TYPE_WITHDRAW,
                'status'                  => MallOrder::STATUS_PENDING,
                'mall_item_id'            => $item->id,
                'points_cost'             => $item->score,
                'amount'                  => $amount,
                'multiplier'              => $multiplier,
                'external_transaction_id' => $orderNo,
                'grant_status'            => MallOrder::GRANT_NOT_APPLICABLE,
                'create_time'             => time(),
                'update_time'             => time(),
            ]);

            Db::commit();
        } catch (\Throwable $e) {
            Db::rollback();
            return $this->error($e->getMessage());
        }

        return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
            'order_id' => $order->id,
            'status'   => 'PENDING',
        ]);
    }

    private function getApiLocale(): string
    {
        if (function_exists('locale')) {
            $l = locale();
            if (is_string($l) && $l !== '') {
                return $l;
            }
        }

        return 'zh-cn';
    }

    /**
     * @param array<string,mixed> $item MallItem 行（可含 title_en/title_ms 与 description_en/description_ms）
     * @return array<string,mixed>
     */
    private function applyMallItemLocaleForApi(array $item): array
    {
        $lang = $this->getApiLocale();
        if ($lang === 'en') {
            $te = trim((string)($item['title_en'] ?? ''));
            $de = trim((string)($item['description_en'] ?? ''));
            if ($te !== '') {
                $item['title'] = $te;
            }
            if ($de !== '') {
                $item['description'] = $de;
            }
        } elseif ($lang === 'ms') {
            $tm = trim((string)($item['title_ms'] ?? ''));
            $dm = trim((string)($item['description_ms'] ?? ''));
            if ($tm !== '') {
                $item['title'] = $tm;
            }
            if ($dm !== '') {
                $item['description'] = $dm;
            }
        }
        unset($item['title_en'], $item['description_en'], $item['title_ms'], $item['description_ms']);

        return $item;
    }

    /**
     * @param list<array<string,mixed>> $list pointsLogs 已组装的列表
     * @return array<int,array<string,mixed>> id => item 行（已本地化并去掉 *_ms）
     */
    private function buildMallItemLocaleMapForPointsLog(array $list): array
    {
        $ids = [];
        foreach ($list as $row) {
            $id = (int)($row['item_id'] ?? 0);
            if ($id > 0) {
                $ids[$id] = true;
            }
        }
        if ($ids === []) {
            return [];
        }
        $idList = array_keys($ids);
        $map = [];
        $models = MallItem::whereIn('id', $idList)->select();
        foreach ($models as $model) {
            $map[intval($model->id)] = $this->applyMallItemLocaleForApi($model->toArray());
        }

        return $map;
    }

    private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
    {
        $baseUrl = rtrim(config('playx.api.base_url', ''), '/');
        $url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
        if ($baseUrl === '') {
            return;
        }

        try {
            $client = new \GuzzleHttp\Client(['timeout' => 15]);
            $res = $client->post($baseUrl . $url, [
                'json' => [
                    'request_id'              => 'mall_bonus_' . uniqid(),
                    'externalTransactionId'   => $order->external_transaction_id,
                    'user_id'                 => $userId,
                    'amount'                  => $order->amount,
                    'rewardName'              => $item->title ?? '',
                    'category'                => $item->category ?? 'daily',
                    'categoryTitle'           => $item->category_title ?? '',
                    'multiplier'              => $order->multiplier,
                ],
            ]);
            $data = json_decode(strval($res->getBody()), true);
            if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
                $order->grant_status = MallOrder::GRANT_ACCEPTED;
                $order->save();
            } else {
                $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
                $order->fail_reason = $data['message'] ?? 'unknown';
                $order->save();
            }
        } catch (\Throwable $e) {
            $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
            $order->fail_reason = $e->getMessage();
            $order->save();
        }
    }

}
