<?php

namespace App\Services\Ticket;

use App\Models\Draw;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use App\Exceptions\IdempotentTicketReplayException;
use App\Exceptions\TicketOperationException;
use App\Services\Jackpot\JackpotContributionService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Support\PlayerFundingMode;
use App\Services\Player\PlayerCreditService;

final class TicketPlacementService
{
    public function __construct(
        private readonly PlayCatalogResolver $catalogResolver,
        private readonly PlayRuleEngine $ruleEngine,
        private readonly TicketLineInstantRebateApplicator $rebateApplicator,
        private readonly RiskPoolService $riskPoolService,
        private readonly TicketWalletService $ticketWalletService,
        private readonly JackpotContributionService $jackpotContribution,
        private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
        private readonly PlayerCreditService $playerCreditService,
    ) {}

    /**
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    public function place(Player $player, array $payload): array
    {
        $currencyCode = strtoupper((string) $payload['currency_code']);
        $clientTraceId = isset($payload['client_trace_id']) && $payload['client_trace_id'] !== ''
            ? (string) $payload['client_trace_id']
            : null;

        $drawNo = trim((string) ($payload['draw_id'] ?? ''));
        $drawIdForIdempotency = $drawNo === ''
            ? null
            : Draw::query()->where('draw_no', $drawNo)->value('id');

        if ($clientTraceId !== null && $drawIdForIdempotency !== null) {
            $existing = TicketOrder::query()
                ->where('player_id', $player->id)
                ->where('draw_id', $drawIdForIdempotency)
                ->where('client_trace_id', $clientTraceId)
                ->first();

            if ($existing !== null) {
                return $this->resolveIdempotentReplay($existing);
            }
        }

        $expectedVersions = $payload['expected_config_versions'] ?? null;
        if (is_array($expectedVersions)) {
            $expectedVersions = [
                'play_config_version_no' => (int) $expectedVersions['play_config_version_no'],
                'odds_version_no' => (int) $expectedVersions['odds_version_no'],
                'risk_cap_version_no' => (int) $expectedVersions['risk_cap_version_no'],
            ];
        } else {
            $expectedVersions = null;
        }

        try {
            $riskRedisCompensation = [];

            $placement = DB::transaction(function () use (
                $player,
                $currencyCode,
                $payload,
                $expectedVersions,
                $clientTraceId,
                $drawNo,
                &$riskRedisCompensation,
            ): array {
            DB::afterRollback(function () use (&$riskRedisCompensation): void {
                foreach ($riskRedisCompensation as $entry) {
                    $this->riskPoolService->compensateRedisAcquires(
                        (int) $entry['draw_id'],
                        $entry['locks'],
                    );
                }
            });

            $draw = Draw::query()
                ->where('draw_no', $drawNo)
                ->lockForUpdate()
                ->first();
            if ($draw === null) {
                throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
            }
            if (! $this->drawHallSnapshot->isBettingOpen($draw)) {
                throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
            }

            if ($clientTraceId !== null && $clientTraceId !== '') {
                $existingInTx = TicketOrder::query()
                    ->where('player_id', $player->id)
                    ->where('draw_id', $draw->id)
                    ->where('client_trace_id', $clientTraceId)
                    ->lockForUpdate()
                    ->first();
                if ($existingInTx !== null) {
                    throw new IdempotentTicketReplayException($existingInTx);
                }
            }

            $configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);

            $evaluatedLines = [];
            $totalBet = 0;
            $totalRebate = 0;
            $totalActualDeduct = 0;
            $totalEstimatedPayout = 0;
            $closedPlayCleanupRows = [];

            foreach ((array) $payload['lines'] as $index => $line) {
                try {
                    $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode);
                } catch (TicketOperationException $e) {
                    if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) {
                        $closedPlayCleanupRows[] = [
                            'client_line_no' => $index + 1,
                            'play_code' => (string) ($line['play_code'] ?? ''),
                        ];

                        continue;
                    }

                    throw $e;
                }
                $evaluated = $this->ruleEngine->evaluateLine(
                    (array) $line,
                    $resolved['play_config'],
                    $resolved['odds_items'],
                );
                $evaluated = $this->rebateApplicator->apply($player, $evaluated);

                $locks = array_map(fn (array $combo): array => [
                    'number_4d' => $combo['number_4d'],
                    'amount' => $combo['estimated_payout'],
                ], $evaluated['combinations']);
                // place 阶段以 acquire 的原子扣减结果为准，允许单行售罄后形成混合成功/失败结果。

                $evaluatedLines[] = $evaluated;
                $rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
                $totalBet += (int) $evaluated['total_bet_amount'];
                $totalRebate += $rebateAmount;
                $totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
                $totalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
            }

            if ($closedPlayCleanupRows !== []) {
                throw new TicketOperationException(
                    'play_closed_need_cleanup',
                    ErrorCode::PlayModeClosed->value,
                    400,
                    [
                        'cleanup_hint' => '玩法已关闭，相关注项已清理',
                        'cleanup_lines' => $closedPlayCleanupRows,
                    ],
                );
            }

            $creditLine = PlayerFundingMode::usesCredit($player);

            if ($creditLine) {
                $this->playerCreditService->assertCreditPreflight($player, $totalActualDeduct);
            } else {
                $wallet = PlayerWallet::query()
                    ->where('player_id', $player->id)
                    ->where('wallet_type', 'lottery')
                    ->where('currency_code', $currencyCode)
                    ->lockForUpdate()
                    ->first();
                if ($wallet !== null && (int) $wallet->status !== 0) {
                    throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value);
                }

                $walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
                $walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
                if ($walletAvailable < $totalActualDeduct) {
                    throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
                }
            }

            try {
                $order = TicketOrder::query()->create([
                'order_no' => $this->newOrderNo(),
                'player_id' => $player->id,
                'draw_id' => $draw->id,
                'currency_code' => $currencyCode,
                'total_bet_amount' => $totalBet,
                'total_rebate_amount' => $totalRebate,
                'total_actual_deduct' => $totalActualDeduct,
                'total_estimated_payout' => $totalEstimatedPayout,
                'status' => 'pending',
                'submit_source' => 'h5',
                'client_trace_id' => $payload['client_trace_id'] ?? null,
                'play_config_version_no' => $configVersions['play_config_version_no'],
                'odds_version_no' => $configVersions['odds_version_no'],
                'risk_cap_version_no' => $configVersions['risk_cap_version_no'],
                ]);
            } catch (QueryException $e) {
                if ($clientTraceId !== null && $this->isUniqueClientTraceViolation($e)) {
                    $existing = TicketOrder::query()
                        ->where('player_id', $player->id)
                        ->where('draw_id', $draw->id)
                        ->where('client_trace_id', $clientTraceId)
                        ->first();
                    if ($existing !== null) {
                        throw new IdempotentTicketReplayException($existing);
                    }
                }

                throw $e;
            }

            $successfulItems = [];
            $failedItems = [];
            $successTotalBet = 0;
            $successTotalRebate = 0;
            $successTotalActualDeduct = 0;
            $successTotalEstimatedPayout = 0;
            $firstFailure = null;
            foreach ($evaluatedLines as $evaluated) {
                $item = TicketItem::query()->create([
                    'ticket_no' => $this->newTicketNo(),
                    'order_id' => $order->id,
                    'player_id' => $player->id,
                    'draw_id' => $draw->id,
                    'original_number' => $evaluated['original_number'],
                    'normalized_number' => $this->normalizedNumberForStorage($evaluated),
                    'play_code' => $evaluated['play_code'],
                    'dimension' => $evaluated['dimension'],
                    'digit_slot' => $evaluated['digit_slot'],
                    'bet_mode' => $evaluated['bet_mode'],
                    'unit_bet_amount' => $evaluated['unit_bet_amount'],
                    'total_bet_amount' => $evaluated['total_bet_amount'],
                    'rebate_rate_snapshot' => $evaluated['rebate_rate_snapshot'],
                    'commission_rate_snapshot' => $evaluated['commission_rate_snapshot'],
                    'actual_deduct_amount' => 0,
                    'odds_snapshot_json' => $evaluated['odds_snapshot_json'],
                    'rule_snapshot_json' => $evaluated['rule_snapshot_json'],
                    'combination_count' => $evaluated['combination_count'],
                    'estimated_max_payout' => $evaluated['estimated_max_payout'],
                    'risk_locked_amount' => 0,
                    'status' => 'pending',
                    'fail_reason_code' => null,
                    'fail_reason_text' => null,
                    'win_amount' => 0,
                    'jackpot_win_amount' => 0,
                    'settled_at' => null,
                ]);

                $locks = [];
                foreach ($evaluated['combinations'] as $combo) {
                    TicketCombination::query()->create([
                        'ticket_item_id' => $item->id,
                        'combination_no' => $combo['combination_no'],
                        'number_4d' => $combo['number_4d'],
                        'bet_amount' => $combo['bet_amount'],
                        'estimated_payout' => $combo['estimated_payout'],
                        'created_at' => now(),
                    ]);
                    $locks[] = [
                        'number_4d' => $combo['number_4d'],
                        'amount' => $combo['estimated_payout'],
                    ];
                }

                try {
                    $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks);
                } catch (TicketOperationException $e) {
                    if ($e->lotteryCode !== ErrorCode::RiskPoolSoldOut->value) {
                        throw $e;
                    }

                    $firstFailure ??= $e;
                    $item->forceFill([
                        'status' => 'failed',
                        'fail_reason_code' => (string) $e->lotteryCode,
                        'fail_reason_text' => 'risk_sold_out',
                    ])->save();
                    $failedItems[] = $item;

                    continue;
                }

                $rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
                $item->forceFill([
                    'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'],
                    'risk_locked_amount' => $lockedAmount,
                    'status' => 'pending_confirm',
                ])->save();

                $successfulItems[] = $item;
                $successTotalBet += (int) $evaluated['total_bet_amount'];
                $successTotalRebate += $rebateAmount;
                $successTotalActualDeduct += (int) $evaluated['actual_deduct_amount'];
                $successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
                $riskRedisCompensation[] = [
                    'draw_id' => (int) $draw->id,
                    'locks' => $locks,
                ];
            }

            if ($successfulItems === []) {
                throw $firstFailure ?? new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
            }

            $order->forceFill([
                'total_bet_amount' => $successTotalBet,
                'total_rebate_amount' => $successTotalRebate,
                'total_actual_deduct' => $successTotalActualDeduct,
                'total_estimated_payout' => $successTotalEstimatedPayout,
                'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm',
            ])->save();

            if ($creditLine) {
                $this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct);
            } else {
                $this->ticketWalletService->reserveBetDeduct(
                    $player,
                    $currencyCode,
                    $successTotalActualDeduct,
                    $order,
                );
            }

            return [
                'order' => $order,
                'draw_id' => (int) $draw->id,
                'currency_code' => $currencyCode,
                'successful_item_ids' => array_map(
                    fn (TicketItem $item): int => (int) $item->id,
                    $successfulItems,
                ),
                'has_failed_items' => $failedItems !== [],
                'success_total_actual_deduct' => $successTotalActualDeduct,
            ];
        });
        } catch (IdempotentTicketReplayException $e) {
            return $this->resolveIdempotentReplay($e->order);
        }

        $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail();
        $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail();

        $creditLine = PlayerFundingMode::usesCredit($player);

        try {
            $balanceAfter = $creditLine
                ? $this->playerCreditService->availableCreditMinor(
                    $player,
                    (string) $placement['currency_code'],
                )
                : $this->ticketWalletService->finalizeReservedBetDeduct(
                    $player,
                    (string) $placement['currency_code'],
                    (int) $placement['success_total_actual_deduct'],
                    $order,
                );

            DB::transaction(function () use ($order, $draw, $placement): void {
                $successfulItems = TicketItem::query()
                    ->whereIn('id', $placement['successful_item_ids'])
                    ->lockForUpdate()
                    ->get();

                foreach ($successfulItems as $item) {
                    $item->forceFill(['status' => 'pending_draw'])->save();
                    $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $placement['currency_code']);
                }

                $order->forceFill([
                    'status' => $placement['has_failed_items'] ? 'partial_failed' : 'placed',
                ])->save();
            });
        } catch (\Throwable $e) {
            DB::transaction(function () use ($order, $player, $placement): void {
                $items = TicketItem::query()
                    ->where('order_id', $order->id)
                    ->where('status', 'pending_confirm')
                    ->with('combinations')
                    ->lockForUpdate()
                    ->get();

                foreach ($items as $item) {
                    $locks = [];
                    foreach ($item->combinations as $combo) {
                        $locks[] = [
                            'number_4d' => (string) $combo->number_4d,
                            'amount' => (int) $combo->estimated_payout,
                        ];
                    }
                    $this->riskPoolService->release((int) $order->draw_id, $item, $locks);
                    $item->forceFill([
                        'status' => 'refunded',
                        'fail_reason_code' => (string) ErrorCode::BetInsufficientBalance->value,
                        'fail_reason_text' => 'wallet_deduct_failed_refund',
                        'risk_locked_amount' => 0,
                    ])->save();
                }

                $order->forceFill(['status' => 'refunded'])->save();
                
                // Reverse the bet deduct first to restore balance
                if (! PlayerFundingMode::usesCredit($player)) {
                    $this->ticketWalletService->reverseBetDeduct($order);
                    $this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
                } else {
                    $this->playerCreditService->reverseBetHold(
                        $player,
                        (int) $placement['success_total_actual_deduct'],
                        (int) $order->id,
                    );
                }
            });

            throw $e;
        }

        $order = TicketOrder::query()->whereKey($order->id)->firstOrFail();

        return $this->responseForOrder($order, $balanceAfter);
    }

    private function responseForOrder(TicketOrder $order, ?int $balanceAfter): array
    {
        $order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
        $draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
        $successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count();
        $pendingConfirmCount = TicketItem::query()
            ->where('order_id', $order->id)
            ->where('status', 'pending_confirm')
            ->count();
        $failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count();
        if ($balanceAfter === null) {
            $walletTxn = WalletTxn::query()
                ->where('biz_type', 'bet_deduct')
                ->where('biz_no', $order->order_no)
                ->latest('id')
                ->first();
            $balanceAfter = $walletTxn === null ? null : (int) $walletTxn->balance_after;
        }

        $nowUtc = now()->utc();

        return [
            'order_no' => $order->order_no,
            'draw' => [
                'draw_id' => $draw->draw_no,
                'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc),
                'db_status' => $draw->status,
            ],
            'summary' => [
                'order_status' => $order->status,
                'total_bet_amount' => (int) $order->total_bet_amount,
                'total_rebate_amount' => (int) $order->total_rebate_amount,
                'total_actual_deduct' => (int) $order->total_actual_deduct,
                'total_estimated_payout' => (int) $order->total_estimated_payout,
                'success_count' => $successCount,
                'pending_confirm_count' => $pendingConfirmCount,
                'failure_count' => $failureCount,
            ],
            'balance_after' => $balanceAfter,
            'items' => TicketItem::query()
                ->where('order_id', $order->id)
                ->orderBy('id')
                ->get()
                ->map(fn (TicketItem $item) => [
                    'ticket_no' => $item->ticket_no,
                    'play_code' => $item->play_code,
                    'number' => $item->original_number,
                    'total_bet_amount' => (int) $item->total_bet_amount,
                    'actual_deduct_amount' => (int) $item->actual_deduct_amount,
                    'estimated_max_payout' => (int) $item->estimated_max_payout,
                    'combination_count' => (int) $item->combination_count,
                    'status' => $item->status,
                    'fail_reason_code' => $item->fail_reason_code,
                    'fail_reason_text' => $item->fail_reason_text,
                ])->values()->all(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function resolveIdempotentReplay(TicketOrder $order): array
    {
        if (! $this->canIdempotentReplay($order)) {
            throw new TicketOperationException(
                'idempotent_replay_rejected',
                ErrorCode::BetIdempotentReplayRejected->value,
                409,
            );
        }

        return $this->responseForOrder($order, null);
    }

    private function canIdempotentReplay(TicketOrder $order): bool
    {
        return in_array($order->status, [
            'placed',
            'partial_failed',
            'pending_confirm',
            'partial_pending_confirm',
        ], true);
    }

    private function isUniqueClientTraceViolation(QueryException $exception): bool
    {
        $sqlState = $exception->errorInfo[0] ?? '';

        return in_array($sqlState, ['23000', '23505'], true)
            || str_contains(strtolower($exception->getMessage()), 'uniq_ticket_orders_player_draw_trace');
    }

    private function newOrderNo(): string
    {
        return 'TO'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    }

    private function newTicketNo(): string
    {
        return 'TK'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    }

    /**
     * ticket_items.normalized_number 现为 char(4)，短维度玩法需要统一填充。
     *
     * @param  array<string, mixed>  $evaluated
     */
    private function normalizedNumberForStorage(array $evaluated): string
    {
        $number = (string) $evaluated['normalized_number'];
        if (strlen($number) === 4) {
            return $number;
        }

        return str_pad($number, 4, '0', STR_PAD_LEFT);
    }
}
