<?php

namespace App\Services\Wallet;

use App\Models\Player;
use App\Models\Currency;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use Illuminate\Support\Str;
use App\Models\PlayerWallet;
use App\Models\TransferOrder;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Exceptions\WalletOperationException;
use App\Support\PlayerFundingMode;

/**
 * 主站 ↔ 彩票钱包：转入 / 转出（幂等键 + 流水 + 订单）。
 */
final class LotteryTransferService
{
    private const WALLET_TYPE_LOTTERY = 'lottery';

    private const DIR_IN = 'in';

    private const DIR_OUT = 'out';

    private const ST_PROCESSING = 'processing';

    private const ST_SUCCESS = 'success';

    private const ST_FAILED = 'failed';

    /** PRD §6.2/6.7：主站超时待对账 */
    private const ST_PENDING_RECONCILE = 'pending_reconcile';

    /** PRD §12：对账后冲正 */
    private const ST_REVERSED = 'reversed';

    /** PRD §12：对账后标记结案（仅改状态，不动钱包） */
    private const ST_MANUALLY_PROCESSED = 'manually_processed';

    private const BIZ_TRANSFER_IN = 'transfer_in';

    private const BIZ_TRANSFER_OUT = 'transfer_out';

    private const BIZ_TRANSFER_OUT_REFUND = 'transfer_out_refund';

    private const BIZ_REVERSAL = 'reversal';

    private const TXN_POSTED = 'posted';

    private const TXN_PENDING_RECONCILE = 'pending_reconcile';

    private const TXN_DIR_IN = 1;

    private const TXN_DIR_OUT = 2;

    public function __construct(
        private readonly MainSiteWalletGateway $mainSite,
        private readonly WalletBalanceRealtimeNotifier $balanceRealtime,
    ) {}

    /**
     * 转入：主站扣款成功后增加彩票余额。
     *
     * @return array<string, mixed>
     */
    public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
    {
        $this->assertWalletFundingMode($player);
        $this->assertPositiveAmount($amountMinor);
        $currencyCode = $this->normalizeCurrency($currencyCode);
        $this->assertCurrencyEnabled($currencyCode);
        $this->assertTransferInEnabled();
        $this->assertLotteryWalletNotFrozen($player, $currencyCode);
        $this->assertTransferAmountLimits(self::DIR_IN, $currencyCode, $amountMinor);

        $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
        if ($existing !== null) {
            return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor);
        }

        $transferNo = $this->newTransferNo('TI');

        try {
            TransferOrder::query()->create([
                'transfer_no' => $transferNo,
                'player_id' => $player->id,
                'direction' => self::DIR_IN,
                'currency_code' => $currencyCode,
                'amount' => $amountMinor,
                'idempotent_key' => $idempotentKey,
                'status' => self::ST_PROCESSING,
            ]);
        } catch (QueryException $e) {
            $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
            if ($existing !== null) {
                return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor);
            }
            throw $e;
        }

        /** @var TransferOrder $order */
        $order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail();

        $main = $this->mainSite->debitMainForLotteryDeposit($player, $currencyCode, $amountMinor, $idempotentKey);

        if (! $main->ok) {
            if ($main->uncertain) {
                $order->forceFill([
                    'status' => self::ST_PENDING_RECONCILE,
                    'external_request_payload' => $main->requestPayload,
                    'external_response_payload' => $main->responsePayload,
                    'fail_reason' => 'main_site_timeout',
                    'finished_at' => null,
                ])->save();

                throw new WalletOperationException(
                    'pending_reconcile',
                    ErrorCode::WalletTransferPending->value,
                    409,
                );
            }

            $order->forceFill([
                'status' => self::ST_FAILED,
                'external_request_payload' => $main->requestPayload,
                'external_response_payload' => $main->responsePayload,
                'fail_reason' => $main->errorMessage ?? 'main_site_failed',
                'finished_at' => now(),
            ])->save();

            throw new WalletOperationException(
                $main->errorMessage ?? 'main_site_failed',
                ErrorCode::WalletExternalRejected->value,
            );
        }

        try {
            DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
                $wallet = $this->lockLotteryWallet($player, $currencyCode);
                $this->postLotteryWalletMovement(
                    wallet: $wallet,
                    bizType: self::BIZ_TRANSFER_IN,
                    direction: self::TXN_DIR_IN,
                    amountMinor: $amountMinor,
                    bizNo: $transferNo,
                    externalRefNo: $main->externalRefNo,
                    idempotentKey: $idempotentKey,
                    remark: null,
                    deltaSign: 1,
                );

                $order->forceFill([
                    'status' => self::ST_SUCCESS,
                    'external_ref_no' => $main->externalRefNo,
                    'external_request_payload' => $main->requestPayload,
                    'external_response_payload' => $main->responsePayload,
                    'finished_at' => now(),
                ])->save();
            });
        } catch (\Throwable $e) {
            $order->refresh();
            if ($order->status === self::ST_PROCESSING) {
                $order->forceFill([
                    'status' => self::ST_PENDING_RECONCILE,
                    'external_ref_no' => $main->externalRefNo,
                    'external_request_payload' => $main->requestPayload,
                    'external_response_payload' => $main->responsePayload,
                    'fail_reason' => 'lottery_credit_failed',
                    'finished_at' => null,
                ])->save();
            }

            throw $e;
        }

        return $this->successPayload(
            TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
            $player,
            $currencyCode,
            self::BIZ_TRANSFER_IN,
        );
    }

    /**
     * 转出：先扣彩票余额，再调用主站加款；失败则冲正彩票余额。
     *
     * @return array<string, mixed>
     */
    public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
    {
        $this->assertWalletFundingMode($player);
        $this->assertPositiveAmount($amountMinor);
        $currencyCode = $this->normalizeCurrency($currencyCode);
        $this->assertCurrencyEnabled($currencyCode);
        $this->assertTransferOutEnabled();
        $this->assertLotteryWalletNotFrozen($player, $currencyCode);
        $this->assertTransferAmountLimits(self::DIR_OUT, $currencyCode, $amountMinor);

        $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
        if ($existing !== null) {
            return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor);
        }

        $transferNo = $this->newTransferNo('TO');

        try {
            TransferOrder::query()->create([
                'transfer_no' => $transferNo,
                'player_id' => $player->id,
                'direction' => self::DIR_OUT,
                'currency_code' => $currencyCode,
                'amount' => $amountMinor,
                'idempotent_key' => $idempotentKey,
                'status' => self::ST_PROCESSING,
            ]);
        } catch (QueryException $e) {
            $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
            if ($existing !== null) {
                return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor);
            }
            throw $e;
        }

        /** @var TransferOrder $order */
        $order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail();

        try {
            DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey): void {
                $wallet = $this->lockLotteryWallet($player, $currencyCode);
                $this->postLotteryWalletMovement(
                    wallet: $wallet,
                    bizType: self::BIZ_TRANSFER_OUT,
                    direction: self::TXN_DIR_OUT,
                    amountMinor: $amountMinor,
                    bizNo: $transferNo,
                    externalRefNo: null,
                    idempotentKey: $idempotentKey,
                    remark: null,
                    deltaSign: -1,
                    requireBalance: true,
                );
            });
        } catch (WalletOperationException $e) {
            if ($e->lotteryCode === ErrorCode::WalletInsufficientBalance->value) {
                $order->forceFill([
                    'status' => self::ST_FAILED,
                    'fail_reason' => 'insufficient_balance',
                    'finished_at' => now(),
                ])->save();
            }

            throw $e;
        }

        $main = $this->mainSite->creditMainForLotteryWithdraw($player, $currencyCode, $amountMinor, $idempotentKey);

        if (! $main->ok) {
            if ($main->uncertain) {
                DB::transaction(function () use ($player, $transferNo, $order, $main): void {
                    WalletTxn::query()
                        ->where('player_id', $player->id)
                        ->where('biz_no', $transferNo)
                        ->where('biz_type', self::BIZ_TRANSFER_OUT)
                        ->update(['status' => self::TXN_PENDING_RECONCILE]);

                    $order->forceFill([
                        'status' => self::ST_PENDING_RECONCILE,
                        'external_request_payload' => $main->requestPayload,
                        'external_response_payload' => $main->responsePayload,
                        'fail_reason' => 'main_site_timeout',
                        'finished_at' => null,
                    ])->save();
                });

                throw new WalletOperationException(
                    'pending_reconcile',
                    ErrorCode::WalletTransferPending->value,
                    409,
                );
            }

            DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey, $order, $main): void {
                $wallet = $this->lockLotteryWallet($player, $currencyCode);
                $this->postLotteryWalletMovement(
                    wallet: $wallet,
                    bizType: self::BIZ_TRANSFER_OUT_REFUND,
                    direction: self::TXN_DIR_IN,
                    amountMinor: $amountMinor,
                    bizNo: $transferNo,
                    externalRefNo: null,
                    idempotentKey: $idempotentKey,
                    remark: 'withdraw_failed_refund',
                    deltaSign: 1,
                );

                $order->forceFill([
                    'status' => self::ST_FAILED,
                    'external_request_payload' => $main->requestPayload,
                    'external_response_payload' => $main->responsePayload,
                    'fail_reason' => $main->errorMessage ?? 'main_site_failed',
                    'finished_at' => now(),
                ])->save();
            });

            throw new WalletOperationException(
                $main->errorMessage ?? 'main_site_failed',
                ErrorCode::WalletExternalRejected->value,
            );
        }

        $order->forceFill([
            'status' => self::ST_SUCCESS,
            'external_ref_no' => $main->externalRefNo,
            'external_request_payload' => $main->requestPayload,
            'external_response_payload' => $main->responsePayload,
            'finished_at' => now(),
        ])->save();

        return $this->successPayload(
            TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
            $player,
            $currencyCode,
            self::BIZ_TRANSFER_OUT,
        );
    }

    /**
     * 对账操作：冲正 / 补入账 / 标记结案。
     *
     * 冲正（reverse）：主站确认未成功，对已扣彩票余额的转出单做反向操作（加回余额），标记为已冲正。
     * 标记结案（manually_process）：确认已在系统外处理完毕，仅改订单状态，不动钱包。
     *
     * @param  'reverse'|'manually_process'  $action
     * @throws WalletOperationException
     */
    public function reconcileTransferOrder(
        TransferOrder $order,
        string $action,
        string $remark = '',
    ): void {
        if ($action === 'reverse') {
            DB::transaction(fn (): mixed => $this->doReverse($order, $remark));

            return;
        }

        if ($action === 'complete_credit') {
            DB::transaction(fn (): mixed => $this->completeStuckTransferInCredit($order, $remark));

            return;
        }

        if ($action === 'manually_process') {
            DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark));

            return;
        }

        throw new WalletOperationException(
            'invalid_reconcile_action',
            ErrorCode::WalletExternalRejected->value,
            422,
        );
    }

    private function doReverse(TransferOrder $order, string $remark): void
    {
        /** @var TransferOrder $locked */
        $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail();

        if ($locked->status === self::ST_REVERSED) {
            return;
        }

        if ($locked->status !== self::ST_PENDING_RECONCILE) {
            throw new WalletOperationException(
                'order_not_pending_reconcile',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if (! $this->isEligibleForReverse($locked)) {
            throw new WalletOperationException(
                'reverse_not_eligible',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if ($locked->direction === self::DIR_OUT) {
            $idempotentKey = 'reversal:'.$locked->transfer_no;
            $alreadyCredited = WalletTxn::query()
                ->where('idempotent_key', $idempotentKey)
                ->where('biz_type', self::BIZ_REVERSAL)
                ->exists();

            if (! $alreadyCredited) {
                $wallet = $this->lockLotteryWalletById($locked->player_id, $locked->currency_code);
                $this->postLotteryWalletMovement(
                    wallet: $wallet,
                    bizType: self::BIZ_REVERSAL,
                    direction: self::TXN_DIR_IN,
                    amountMinor: (int) $locked->amount,
                    bizNo: $locked->transfer_no,
                    externalRefNo: null,
                    idempotentKey: $idempotentKey,
                    remark: $remark ?: 'reversal_pending_reconcile',
                    deltaSign: 1,
                );
            }
        } elseif ($this->isEligibleForTransferInReverse($locked)) {
            $player = Player::query()->whereKey($locked->player_id)->firstOrFail();
            $refund = $this->mainSite->refundMainForFailedLotteryDeposit(
                $player,
                (string) $locked->currency_code,
                (int) $locked->amount,
                'refund:'.$locked->transfer_no,
            );

            if (! $refund->ok) {
                throw new WalletOperationException(
                    $refund->errorMessage ?? 'main_site_refund_failed',
                    $refund->uncertain
                        ? ErrorCode::WalletTransferPending->value
                        : ErrorCode::WalletExternalRejected->value,
                    $refund->uncertain ? 409 : 422,
                );
            }

            $locked->forceFill([
                'external_request_payload' => $refund->requestPayload,
                'external_response_payload' => $refund->responsePayload,
                'external_ref_no' => $refund->externalRefNo ?? $locked->external_ref_no,
            ])->save();
        }

        $locked->forceFill([
            'status' => self::ST_REVERSED,
            'fail_reason' => 'reversed: '.($remark ?: 'admin_reversal'),
            'finished_at' => now(),
        ])->save();
    }

    /**
     * 主站已扣款但彩票侧入账失败时，人工/对账补完成转入。
     */
    private function completeStuckTransferInCredit(TransferOrder $order, string $remark): void
    {
        /** @var TransferOrder $locked */
        $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail();

        if ($locked->direction !== self::DIR_IN) {
            throw new WalletOperationException(
                'invalid_reconcile_action',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if ($locked->status === self::ST_SUCCESS) {
            return;
        }

        if ($locked->status !== self::ST_PENDING_RECONCILE) {
            throw new WalletOperationException(
                'order_not_pending_reconcile',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if (! $this->isEligibleForCompleteCredit($locked)) {
            throw new WalletOperationException(
                'complete_credit_not_eligible',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        $idempotentKey = (string) $locked->idempotent_key;
        if (WalletTxn::query()
            ->where('idempotent_key', $idempotentKey)
            ->where('biz_type', self::BIZ_TRANSFER_IN)
            ->where('status', self::TXN_POSTED)
            ->exists()) {
            $locked->forceFill([
                'status' => self::ST_SUCCESS,
                'finished_at' => now(),
            ])->save();

            return;
        }

        $player = Player::query()->whereKey($locked->player_id)->firstOrFail();
        $currencyCode = (string) $locked->currency_code;
        $wallet = $this->lockLotteryWallet($player, $currencyCode);

        $this->postLotteryWalletMovement(
            wallet: $wallet,
            bizType: self::BIZ_TRANSFER_IN,
            direction: self::TXN_DIR_IN,
            amountMinor: (int) $locked->amount,
            bizNo: $locked->transfer_no,
            externalRefNo: $locked->external_ref_no,
            idempotentKey: $idempotentKey,
            remark: $remark ?: 'complete_stuck_transfer_in',
            deltaSign: 1,
        );

        $locked->forceFill([
            'status' => self::ST_SUCCESS,
            'fail_reason' => null,
            'finished_at' => now(),
        ])->save();
    }

    private function doManuallyProcess(TransferOrder $order, string $remark): void
    {
        /** @var TransferOrder $locked */
        $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail();

        $allowedStatuses = [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE];
        if (! in_array($locked->status, $allowedStatuses, true)) {
            throw new WalletOperationException(
                'order_not_pending_reconcile',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if ($locked->status === self::ST_MANUALLY_PROCESSED) {
            return;
        }

        if ($locked->direction === self::DIR_OUT && $locked->status === self::ST_PENDING_RECONCILE) {
            throw new WalletOperationException(
                'manually_process_requires_reverse_for_transfer_out',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        if (! $this->isEligibleForManualProcess($locked)) {
            throw new WalletOperationException(
                'manually_process_not_eligible',
                ErrorCode::WalletExternalRejected->value,
                422,
            );
        }

        $locked->forceFill([
            'status' => self::ST_MANUALLY_PROCESSED,
            'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
            'finished_at' => now(),
        ])->save();
    }

    /** 仅主站已扣款（有 external_ref_no）且彩票入账失败时可补完成转入。 */
    public function isEligibleForCompleteCredit(TransferOrder $order): bool
    {
        return $order->fail_reason === 'lottery_credit_failed'
            && trim((string) $order->external_ref_no) !== '';
    }

    public function isEligibleForTransferInReverse(TransferOrder $order): bool
    {
        return $order->direction === self::DIR_IN
            && $order->status === self::ST_PENDING_RECONCILE
            && $this->isEligibleForCompleteCredit($order);
    }

    /** 后台冲正：转出 pending_reconcile 或转入 lottery_credit_failed 且主站已扣款。 */
    public function isEligibleForReverse(TransferOrder $order): bool
    {
        if ($order->status !== self::ST_PENDING_RECONCILE) {
            return false;
        }

        if ($order->direction === self::DIR_OUT) {
            return true;
        }

        return $this->isEligibleForTransferInReverse($order);
    }

    public function isEligibleForManualProcess(TransferOrder $order): bool
    {
        if (! in_array($order->status, [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE], true)) {
            return false;
        }

        if ($order->direction === self::DIR_OUT && $order->status === self::ST_PENDING_RECONCILE) {
            return false;
        }

        return $order->fail_reason !== 'lottery_credit_failed';
    }

    private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet
    {
        $wallet = PlayerWallet::query()
            ->where([
                'player_id' => $playerId,
                'wallet_type' => self::WALLET_TYPE_LOTTERY,
                'currency_code' => $currencyCode,
            ])
            ->lockForUpdate()
            ->first();

        if ($wallet === null) {
            throw new WalletOperationException(
                'wallet_not_found',
                ErrorCode::WalletInvalidCurrency->value,
                422,
            );
        }

        return $wallet;
    }

    /**
     * @return array<string, mixed>
     */
    private function existingOrderResponse(
        TransferOrder $order,
        Player $player,
        string $expectedDirection,
        string $currencyCode,
        int $amountMinor,
    ): array {
        if ((int) $order->player_id !== (int) $player->id
            || $order->direction !== $expectedDirection
            || strtoupper($order->currency_code) !== $currencyCode
            || (int) $order->amount !== $amountMinor
        ) {
            throw new WalletOperationException(
                'idempotent_conflict',
                ErrorCode::WalletIdempotentConflict->value,
            );
        }

        return match ($order->status) {
            self::ST_SUCCESS => $this->successPayload(
                $order,
                $player,
                $currencyCode,
                $expectedDirection === self::DIR_IN ? self::BIZ_TRANSFER_IN : self::BIZ_TRANSFER_OUT,
            ),
            self::ST_PROCESSING => throw new WalletOperationException(
                'pending',
                ErrorCode::WalletTransferPending->value,
                409,
            ),
            self::ST_PENDING_RECONCILE => throw new WalletOperationException(
                'pending_reconcile',
                ErrorCode::WalletTransferPending->value,
                409,
            ),
            self::ST_FAILED => throw $this->failedOrderToException($order),
            default => throw new WalletOperationException(
                'unknown_order_status',
                ErrorCode::WalletExternalRejected->value,
            ),
        };
    }

    /**
     * @return array<string, mixed>
     */
    private function successPayload(TransferOrder $order, Player $player, string $currencyCode, string $primaryBizType): array
    {
        $wallet = PlayerWallet::query()->where([
            'player_id' => $player->id,
            'wallet_type' => self::WALLET_TYPE_LOTTERY,
            'currency_code' => $currencyCode,
        ])->first();

        $balance = $wallet !== null ? (int) $wallet->balance : 0;
        $frozen = $wallet !== null ? (int) $wallet->frozen_balance : 0;

        $logId = WalletTxn::query()
            ->where('player_id', $player->id)
            ->where('biz_no', $order->transfer_no)
            ->where('biz_type', $primaryBizType)
            ->value('txn_no');

        return [
            'transfer_no' => $order->transfer_no,
            'direction' => $order->direction,
            'currency_code' => $order->currency_code,
            'amount' => (int) $order->amount,
            'status' => $order->status,
            'external_ref_no' => $order->external_ref_no,
            /** PRD §10.1.1 示例字段名 */
            'balance' => $balance,
            'log_id' => $logId,
            'lottery_balance_after' => $balance,
            'lottery_available_after' => max(0, $balance - $frozen),
            'finished_at' => $order->finished_at?->toIso8601String(),
        ];
    }

    private function lockLotteryWallet(Player $player, string $currencyCode): PlayerWallet
    {
        $wallet = PlayerWallet::query()
            ->where([
                'player_id' => $player->id,
                'wallet_type' => self::WALLET_TYPE_LOTTERY,
                'currency_code' => $currencyCode,
            ])
            ->lockForUpdate()
            ->first();

        if ($wallet === null) {
            return PlayerWallet::query()->create([
                'player_id' => $player->id,
                'wallet_type' => self::WALLET_TYPE_LOTTERY,
                'currency_code' => $currencyCode,
                'balance' => 0,
                'frozen_balance' => 0,
                'status' => 0,
                'version' => 0,
            ]);
        }

        if ((int) $wallet->status !== 0) {
            throw new WalletOperationException(
                'lottery_wallet_frozen',
                ErrorCode::WalletLotteryFrozen->value,
            );
        }

        return $wallet;
    }

    /**
     * 转出扣款前钱包必须存在且余额足够；若不存在则余额视为 0。
     */
    private function assertLotteryWalletNotFrozen(Player $player, string $currencyCode): void
    {
        $wallet = PlayerWallet::query()->where([
            'player_id' => $player->id,
            'wallet_type' => self::WALLET_TYPE_LOTTERY,
            'currency_code' => $currencyCode,
        ])->first();

        if ($wallet !== null && (int) $wallet->status !== 0) {
            throw new WalletOperationException(
                'lottery_wallet_frozen',
                ErrorCode::WalletLotteryFrozen->value,
            );
        }
    }

    private function assertWalletFundingMode(Player $player): void
    {
        if (PlayerFundingMode::usesCredit($player)) {
            throw new WalletOperationException(
                'credit_player_no_wallet_transfer',
                ErrorCode::WalletCreditPlayerNoTransfer->value,
                422,
            );
        }
    }

    private function assertPositiveAmount(int $amountMinor): void
    {
        if ($amountMinor < 1) {
            throw new WalletOperationException(
                'invalid_amount',
                ErrorCode::WalletInvalidAmount->value,
            );
        }
    }

    private function normalizeCurrency(string $code): string
    {
        return strtoupper(substr(trim($code), 0, 16));
    }

    private function assertCurrencyEnabled(string $currencyCode): void
    {
        if (! preg_match('/^[A-Z0-9]{1,16}$/', $currencyCode)) {
            throw new WalletOperationException(
                'invalid_currency',
                ErrorCode::WalletInvalidCurrency->value,
            );
        }

        $ok = Currency::query()->where('code', $currencyCode)->where('is_enabled', true)->exists();
        if (! $ok) {
            throw new WalletOperationException(
                'invalid_currency',
                ErrorCode::WalletInvalidCurrency->value,
            );
        }
    }

    private function assertTransferInEnabled(): void
    {
        if (! (bool) LotterySettings::get('wallet.transfer_in_enabled', true)) {
            throw new WalletOperationException(
                'transfer_in_disabled',
                ErrorCode::WalletTransferInDisabled->value,
            );
        }
    }

    private function assertTransferOutEnabled(): void
    {
        if (! (bool) LotterySettings::get('wallet.transfer_out_enabled', true)) {
            throw new WalletOperationException(
                'transfer_out_disabled',
                ErrorCode::WalletTransferOutDisabled->value,
            );
        }
    }

    /**
     * PRD §6.2：最小/最大金额（最小货币单位）；可按币种覆盖 JSON。
     *
     * @see LotterySettingsSeeder 默认键；可选 `wallet.transfer_limits_by_currency` = {"NPR":{"in_min":100,...}}
     */
    private function assertTransferAmountLimits(string $direction, string $currencyCode, int $amountMinor): void
    {
        $limits = $this->transferLimitsForCurrency($currencyCode);
        $min = $direction === self::DIR_IN ? $limits['in_min'] : $limits['out_min'];
        $max = $direction === self::DIR_IN ? $limits['in_max'] : $limits['out_max'];

        if ($amountMinor < $min || $amountMinor > $max) {
            throw new WalletOperationException(
                'amount_out_of_limits',
                ErrorCode::WalletAmountExceedsLimit->value,
            );
        }
    }

    /**
     * @return array{in_min: int, in_max: int, out_min: int, out_max: int}
     */
    private function transferLimitsForCurrency(string $currencyCode): array
    {
        $defaults = [
            'in_min' => max(1, (int) LotterySettings::get('wallet.transfer_in_min_minor', 100)),
            'in_max' => (int) LotterySettings::get('wallet.transfer_in_max_minor', 9_999_999_999_999_999),
            'out_min' => max(1, (int) LotterySettings::get('wallet.transfer_out_min_minor', 100)),
            'out_max' => (int) LotterySettings::get('wallet.transfer_out_max_minor', 9_999_999_999_999_999),
        ];

        $raw = LotterySettings::get('wallet.transfer_limits_by_currency');
        if (is_string($raw)) {
            $raw = json_decode($raw, true);
        }
        if (! is_array($raw) || ! isset($raw[$currencyCode]) || ! is_array($raw[$currencyCode])) {
            return $defaults;
        }

        $patch = $raw[$currencyCode];

        return [
            'in_min' => isset($patch['in_min']) ? max(1, (int) $patch['in_min']) : $defaults['in_min'],
            'in_max' => isset($patch['in_max']) ? max(1, (int) $patch['in_max']) : $defaults['in_max'],
            'out_min' => isset($patch['out_min']) ? max(1, (int) $patch['out_min']) : $defaults['out_min'],
            'out_max' => isset($patch['out_max']) ? max(1, (int) $patch['out_max']) : $defaults['out_max'],
        ];
    }

    private function newTransferNo(string $prefix): string
    {
        return $prefix.'_'.Str::lower(str_replace('-', '', Str::uuid()->toString()));
    }

    private function newTxnNo(): string
    {
        return 'WX_'.Str::lower(str_replace('-', '', Str::uuid()->toString()));
    }

    /**
     * 统一执行彩票钱包余额变更并记录流水。
     *
     * @return array{before: int, after: int}
     */
    private function postLotteryWalletMovement(
        PlayerWallet $wallet,
        string $bizType,
        int $direction,
        int $amountMinor,
        string $bizNo,
        ?string $externalRefNo,
        ?string $idempotentKey,
        ?string $remark,
        int $deltaSign,
        bool $requireBalance = false,
    ): array {
        $before = (int) $wallet->balance;
        $available = $before - (int) $wallet->frozen_balance;
        if ($requireBalance && $deltaSign < 0 && $available < $amountMinor) {
            throw new WalletOperationException(
                'insufficient_balance',
                ErrorCode::WalletInsufficientBalance->value,
            );
        }

        $delta = $amountMinor * $deltaSign;
        $after = $before + $delta;

        $wallet->forceFill([
            'balance' => $after,
            'version' => (int) $wallet->version + 1,
        ])->save();

        WalletTxn::query()->create([
            'txn_no' => $this->newTxnNo(),
            'player_id' => $wallet->player_id,
            'wallet_id' => $wallet->id,
            'biz_type' => $bizType,
            'biz_no' => $bizNo,
            'direction' => $direction,
            'amount' => $amountMinor,
            'balance_before' => $before,
            'balance_after' => $after,
            'status' => self::TXN_POSTED,
            'external_ref_no' => $externalRefNo,
            'idempotent_key' => $idempotentKey,
            'remark' => $remark,
        ]);

        $wallet->refresh();
        $this->balanceRealtime->notifyAfterMovement($wallet, $delta, $bizType);

        return [
            'before' => $before,
            'after' => $after,
        ];
    }

    private function failedOrderToException(TransferOrder $order): WalletOperationException
    {
        if (($order->fail_reason ?? '') === 'insufficient_balance') {
            return new WalletOperationException(
                'insufficient_balance',
                ErrorCode::WalletInsufficientBalance->value,
            );
        }

        return new WalletOperationException(
            (string) ($order->fail_reason ?? 'failed'),
            ErrorCode::WalletExternalRejected->value,
        );
    }
}
