<?php

/**
 * §14.5 测试任务 / §14.6 完成标准：玩法命中、未中奖、派彩入彩票钱包、下单失败资金回滚、Jackpot 蓄水与非头奖结算、玩家端可见状态。
 */

use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\RiskPool;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\OddsItem;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch;
use App\Models\TicketCombination;
use App\Models\JackpotContribution;
use App\Support\OddsStandardScopes;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use App\Lottery\DrawResultBatchStatus;
use App\Models\TicketSettlementDetail;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->seed(CurrencySeeder::class);
    $this->seed(PlayTypeSeeder::class);
    $this->seed(OperationalConfigV1Seeder::class);
    $this->seed(LotterySettingsSeeder::class);
});

function p145_player(int $balance = 5_000_000): Player
{
    $uniq = bin2hex(random_bytes(4));
    $player = Player::query()->create([
        'site_code' => 'test',
        'site_player_id' => 'p145-'.$uniq,
        'username' => 'p145_'.$uniq,
        'nickname' => null,
        'default_currency' => 'NPR',
        'status' => 0,
    ]);
    PlayerWallet::query()->create([
        'player_id' => $player->id,
        'wallet_type' => 'lottery',
        'currency_code' => 'NPR',
        'balance' => $balance,
        'frozen_balance' => 0,
        'status' => 0,
        'version' => 0,
    ]);

    return $player;
}

/** 路由 `draw_no` 约束为 `YYYYMMDD-NNN`（序数三位）。 */
function p145_next_draw_no(): string
{
    static $i = 0;
    $i++;

    return sprintf('20260511-%03d', 400 + ($i % 500));
}

function p145_draw(string $drawNo, int $sequenceNo): Draw
{
    return Draw::query()->create([
        'draw_no' => $drawNo,
        'business_date' => '2026-05-11',
        'sequence_no' => $sequenceNo,
        'status' => DrawStatus::Open->value,
        'start_time' => now()->subMinutes(2),
        'close_time' => now()->addMinutes(5),
        'draw_time' => now()->addMinutes(6),
        'cooling_end_time' => null,
        'result_source' => null,
        'current_result_version' => 0,
        'settle_version' => 0,
        'is_reopened' => false,
    ]);
}

/**
 * @param  Closure(string $prizeType, int $prizeIndex): string  $numberFor
 */
function p145_publish_board(Draw $draw, Closure $numberFor): void
{
    $batch = DrawResultBatch::query()->create([
        'draw_id' => $draw->id,
        'result_version' => 1,
        'source_type' => 'rng',
        'rng_seed_hash' => 'p145',
        'raw_seed_encrypted' => null,
        'status' => DrawResultBatchStatus::Published->value,
        'created_by' => null,
        'confirmed_by' => null,
        'confirmed_at' => now(),
    ]);

    foreach (DrawPrizeLayout::slots() as $slot) {
        $num = $numberFor($slot['prize_type'], (int) $slot['prize_index']);
        DrawResultItem::query()->create([
            'draw_id' => $draw->id,
            'result_batch_id' => $batch->id,
            'prize_type' => $slot['prize_type'],
            'prize_index' => $slot['prize_index'],
            'number_4d' => $num,
            'suffix_3d' => substr($num, -3),
            'suffix_2d' => substr($num, -2),
            'head_digit' => (int) substr($num, 0, 1),
            'tail_digit' => (int) substr($num, 3, 1),
        ]);
    }
}

/** 23 格不含 8888，用于 Big 未中奖。 */
function p145_board_without_8888(string $prizeType, int $prizeIndex): string
{
    return match ($prizeType) {
        'first' => '1111',
        'second' => '2222',
        'third' => '3333',
        'starter' => sprintf('41%02d', $prizeIndex),
        'consolation' => sprintf('52%02d', $prizeIndex),
    };
}

function p145_approve_and_payout(Draw $draw): void
{
    $batch = SettlementBatch::query()->where('draw_id', $draw->id)->latest('id')->firstOrFail();
    $admin = AdminUser::query()->create([
        'username' => 'p145_settle_'.bin2hex(random_bytes(3)),
        'name' => 'P145 Settlement',
        'email' => null,
        'password' => Hash::make('secret-strong'),
        'status' => 0,
    ]);
    grantSuperAdminRole($admin);
    $workflow = app(SettlementBatchWorkflowService::class);
    $workflow->approve($batch, $admin);
    $workflow->payout($batch->fresh());
}

test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void {
    $player = p145_player();
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-lose-1',
            'lines' => [
                ['number' => '8888', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    $deduct = (int) $item->actual_deduct_amount;

    p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    expect($item->status)->toBe('settled_lose')
        ->and((int) $item->win_amount)->toBe(0)
        ->and((int) $item->jackpot_win_amount)->toBe(0);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(5_000_000 - $deduct);

    expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
    expect(TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->count())->toBe(1);

    $ticketNo = $item->ticket_no;
    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->getJson('/api/v1/ticket/items/'.$ticketNo)
        ->assertOk()
        ->assertJsonPath('data.status', 'settled_lose')
        ->assertJsonPath('data.win_amount', 0);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->getJson('/api/v1/ticket/draws/'.$drawNo.'/my-match')
        ->assertOk()
        ->assertJsonPath('data.has_bets', true)
        ->assertJsonPath('data.hit_numbers_4d', [])
        ->assertJsonPath('data.total_win_minor', 0);
});

test('§14.5 small hits second tier only', function (): void {
    $player = p145_player();
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-small-1',
            'lines' => [
                ['number' => '8888', 'play_code' => 'small', 'amount' => 10_000],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    $deduct = (int) $item->actual_deduct_amount;
    $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000);

    p145_publish_board($draw, function (string $t, int $i): string {
        return match ($t) {
            'first' => '1001',
            'second' => '8888',
            'third' => '2002',
            'starter' => sprintf('30%02d', $i),
            'consolation' => sprintf('40%02d', $i),
        };
    });

    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    expect($item->status)->toBe('settled_win')
        ->and((int) $item->win_amount)->toBe($expectedWin);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin);
    expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1);
});

test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', function (): void {
    $cases = [
        [
            'play' => 'pos_4b',
            'number' => '7777',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1111',
                'second' => '7777',
                'third' => '3333',
                'starter' => sprintf('51%02d', $i),
                'consolation' => sprintf('62%02d', $i),
            },
            'scope' => 'second',
        ],
        [
            'play' => 'pos_3a',
            'number' => '234',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1234',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
        ],
        [
            'play' => 'pos_2a',
            'number' => '34',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1234',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
        ],
        [
            'play' => 'pos_4e',
            'number' => '7777',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1111',
                'second' => '2222',
                'third' => '3333',
                'starter' => sprintf('51%02d', $i),
                'consolation' => $i === 4 ? '7777' : sprintf('62%02d', $i),
            },
            'scope' => 'consolation',
        ],
    ];

    foreach ($cases as $case) {
        $player = p145_player();
        $drawNo = p145_next_draw_no();
        $draw = p145_draw($drawNo, random_int(1, 99_999));

        $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
            ->postJson('/api/v1/ticket/place', [
                'draw_id' => $drawNo,
                'currency_code' => 'NPR',
                'client_trace_id' => 'p145-'.$case['play'].'-'.uniqid('', true),
                'lines' => [
                    ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
                ],
            ])
            ->assertOk();

        $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
        $deduct = (int) $item->actual_deduct_amount;
        $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
        $perComboWin = (int) floor(10_000 * $odds / 10_000);
        $expectedWin = $perComboWin;

        p145_publish_board($draw, $case['board']);
        $draw->forceFill([
            'status' => DrawStatus::Settling->value,
            'current_result_version' => 1,
        ])->save();

        expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
        p145_approve_and_payout($draw);

        $item->refresh();
        expect($item->status)->toBe('settled_win', $case['play'])
            ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']);

        $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
        expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin, $case['play']);
    }
});

test('module 6 suffix plays settle once per ticket item instead of once per expanded prefix', function (): void {
    $cases = [
        [
            'play' => 'pos_3a',
            'number' => '234',
            'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i),
            'scope' => 'first',
        ],
        [
            'play' => 'pos_2a',
            'number' => '34',
            'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i),
            'scope' => 'first',
        ],
        [
            'play' => 'pos_3abc',
            'number' => '567',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '4567',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
        ],
        [
            'play' => 'pos_2abc',
            'number' => '99',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '8899',
                'second' => '2299',
                'third' => '1199',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
        ],
    ];

    foreach ($cases as $case) {
        $player = p145_player(80_000_000);
        $drawNo = p145_next_draw_no();
        $draw = p145_draw($drawNo, random_int(1, 99_999));

        $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
            ->postJson('/api/v1/ticket/place', [
                'draw_id' => $drawNo,
                'currency_code' => 'NPR',
                'client_trace_id' => 'module6-suffix-'.$case['play'].'-'.uniqid('', true),
                'lines' => [
                    ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
                ],
            ])
            ->assertOk();

        $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
        expect((int) $item->combination_count)->toBeIn([10, 100]);
        $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']] / 10_000);

        p145_publish_board($draw, $case['board']);
        $draw->forceFill([
            'status' => DrawStatus::Settling->value,
            'current_result_version' => 1,
        ])->save();

        expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
        p145_approve_and_payout($draw);

        $item->refresh();
        expect($item->status)->toBe('settled_win', $case['play'])
            ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']);
    }
});

test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void {
    $cases = [
        [
            'play' => 'pos_3abc',
            'number' => '234',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1234',
                'second' => '5234',
                'third' => '9234',
                default => p145_board_without_8888($t, $i),
            },
            'expected_tier' => 'first',
        ],
        [
            'play' => 'pos_3abc',
            'number' => '234',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1567',
                'second' => '5234',
                'third' => '8234',
                default => p145_board_without_8888($t, $i),
            },
            'expected_tier' => 'second',
        ],
        [
            'play' => 'pos_2abc',
            'number' => '99',
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '8899',
                'second' => '2299',
                'third' => '1199',
                default => p145_board_without_8888($t, $i),
            },
            'expected_tier' => 'first',
        ],
    ];

    foreach ($cases as $case) {
        $player = p145_player(80_000_000);
        $drawNo = p145_next_draw_no();
        $draw = p145_draw($drawNo, random_int(1, 99_999));

        $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
            ->postJson('/api/v1/ticket/place', [
                'draw_id' => $drawNo,
                'currency_code' => 'NPR',
                'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
                'lines' => [
                    ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
                ],
            ])
            ->assertOk();

        $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
        $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);

        p145_publish_board($draw, $case['board']);
        $draw->forceFill([
            'status' => DrawStatus::Settling->value,
            'current_result_version' => 1,
        ])->save();

        expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
        p145_approve_and_payout($draw);

        $item->refresh();
        $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();

        expect($item->status)->toBe('settled_win', $case['play'])
            ->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
            ->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
    }
});

test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
    $player = p145_player(80_000_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'module6-ibox-multi-tier',
            'lines' => [
                ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    $deduct = (int) $item->actual_deduct_amount;
    expect($deduct)->toBe(600)
        ->and((int) $item->combination_count)->toBe(6);

    $unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
    $unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
    $expectedWin = $unitWinFirst + $unitWinStarter;

    p145_publish_board($draw, function (string $t, int $i): string {
        return match ($t) {
            'first' => '1212',
            'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
            default => p145_board_without_8888($t, $i),
        };
    });

    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
    $matchLines = is_array($detail->match_detail_json)
        ? ($detail->match_detail_json['lines'] ?? [])
        : [];

    expect($item->status)->toBe('settled_win')
        ->and((int) $item->win_amount)->toBe($expectedWin)
        ->and($detail->matched_prize_tier)->toBe('first')
        ->and(count($matchLines))->toBe(2);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
});

test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
    $player = p145_player(80_000_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));
    $rawAmount = 10_001;
    $comboCount = 24;
    $unitBet = intdiv($rawAmount, $comboCount);
    $expectedDeduct = $unitBet * $comboCount;
    $expectedRemainder = $rawAmount - $expectedDeduct;

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'module6-mbox-remainder-win',
            'lines' => [
                ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    $ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];

    expect((int) $item->combination_count)->toBe($comboCount)
        ->and((int) $item->unit_bet_amount)->toBe($unitBet)
        ->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
        ->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
        ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);

    $expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);

    p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    expect($item->status)->toBe('settled_win')
        ->and((int) $item->win_amount)->toBe($expectedWin);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin);
});

test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
    $player = p145_player(80_000_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));
    $rawAmount = 10_001;
    $expectedDeduct = intdiv($rawAmount, 24) * 24;

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'module6-mbox-remainder-lose',
            'lines' => [
                ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);

    p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    expect($item->status)->toBe('settled_lose')
        ->and((int) $item->win_amount)->toBe(0);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
    expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
});

test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
    JackpotPool::query()->updateOrCreate(
        ['currency_code' => 'NPR'],
        [
            'current_amount' => 0,
            'contribution_rate' => '0.1000',
            'trigger_threshold' => 1,
            'payout_rate' => '1.0000',
            'force_trigger_draw_gap' => 0,
            'min_bet_amount' => 0,
            'status' => 1,
            'last_trigger_draw_id' => null,
        ],
    );

    $player = p145_player();
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-jp-keep',
            'lines' => [
                ['number' => '8888', 'play_code' => 'small', 'amount' => 10_000],
            ],
        ])
        ->assertOk();

    expect(JackpotContribution::query()->count())->toBe(1);
    $poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
    expect((int) $poolAfterBet->current_amount)->toBe(1_000);

    p145_publish_board($draw, function (string $t, int $i): string {
        return match ($t) {
            'first' => '1001',
            'second' => '8888',
            'third' => '2002',
            'starter' => sprintf('30%02d', $i),
            'consolation' => sprintf('40%02d', $i),
        };
    });

    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    expect(JackpotPayoutLog::query()->count())->toBe(0);
    $poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
    expect((int) $poolAfter->current_amount)->toBe(1_000);

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    expect((int) $item->jackpot_win_amount)->toBe(0);
});

test('§14.5 placement partial failure only deducts successful lines when mid-order risk acquire fails', function (): void {
    $player = p145_player(500_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));
    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 5000,
        'locked_amount' => 0,
        'remaining_amount' => 5000,
        'sold_out_status' => 0,
        'version' => 0,
    ]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-rollback',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
                ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.summary.success_count', 1)
        ->assertJsonPath('data.summary.failure_count', 1)
        ->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value);

    expect(TicketOrder::query()->count())->toBe(1);
    expect(TicketOrder::query()->firstOrFail()->status)->toBe('partial_failed');
    expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(500_000 - 120);

    $pool = RiskPool::query()
        ->where('draw_id', $draw->id)
        ->where('normalized_number', '1234')
        ->firstOrFail();
    expect((int) $pool->remaining_amount)->toBe(2000);
    expect((int) $pool->locked_amount)->toBe(3000);
});

test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
    $player = p145_player(80_000_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-odds-snapshot-1',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertOk();

    $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
    $snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
        ->firstWhere('prize_scope', 'first');
    expect($snapshotOdds)->not->toBeNull();

    // 修改当前赔率配置：如果结算错误使用“实时配置”，这里会导致派奖金额变化。
    OddsItem::query()
        ->where('play_code', 'big')
        ->where('prize_scope', 'first')
        ->where('currency_code', 'NPR')
        ->update(['odds_value' => 10_000]);

    p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $item->refresh();
    $expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
    expect($item->status)->toBe('settled_win')
        ->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
});

test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
    $player = p145_player(80_000_000);
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    // 下注一单，确保产生风险池占用。
    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-risk-release-1',
            'lines' => [
                ['number' => '8888', 'play_code' => 'big', 'amount' => 120],
            ],
        ])
        ->assertOk();

    $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
    $cap = (int) $pool->total_cap_amount;
    expect((int) $pool->locked_amount)->toBeGreaterThan(0)
        ->and((int) $pool->remaining_amount)->toBeLessThan($cap);

    // 先走未中奖结算，验证释放。
    p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
    p145_approve_and_payout($draw);

    $poolAfterLose = $pool->fresh();
    expect((int) $poolAfterLose->locked_amount)->toBe(0)
        ->and((int) $poolAfterLose->remaining_amount)->toBe($cap);

    // 再开一盘中奖结算，验证同样释放。
    $drawNo2 = p145_next_draw_no();
    $draw2 = p145_draw($drawNo2, random_int(1, 99_999));
    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo2,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-risk-release-2',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
            ],
        ])
        ->assertOk();

    $pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
    $cap2 = (int) $pool2->total_cap_amount;
    expect((int) $pool2->locked_amount)->toBeGreaterThan(0);

    p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
    $draw2->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
    p145_approve_and_payout($draw2);

    $poolAfterWin = $pool2->fresh();
    expect((int) $poolAfterWin->locked_amount)->toBe(0)
        ->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
});

/**
 * 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法（不含 `half_box`：种子为禁用）。
 * `odd` / `even`：头奖取该注项首条展开组合号码，避免与 `expandOddEven` 枚举顺序硬编码耦合。
 */
test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants settle win', function (): void {
    $cases = [
        [
            'play' => 'straight',
            'line' => ['number' => '8881', 'play_code' => 'straight', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '8881' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'roll',
            'line' => ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '5234' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'box',
            'line' => ['number' => '1357', 'play_code' => 'box', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '7135' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'ibox',
            'line' => ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '1212' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'mbox',
            'line' => ['number' => '2468', 'play_code' => 'mbox', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '8642' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'head',
            'line' => ['number' => '6', 'play_code' => 'head', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '6781' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'tail',
            'line' => ['number' => '2', 'play_code' => 'tail', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '2342' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'odd',
            'line' => ['number' => '1', 'play_code' => 'odd', 'amount' => 10_000, 'dimension' => 'D4'],
            'first_combo_board' => true,
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'even',
            'line' => ['number' => '0', 'play_code' => 'even', 'amount' => 10_000, 'dimension' => 'D4'],
            'first_combo_board' => true,
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'digit_big',
            'line' => ['number' => '9', 'play_code' => 'digit_big', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 2],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '1299' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'digit_small',
            'line' => ['number' => '1', 'play_code' => 'digit_small', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 1],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '3142' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_4a',
            'line' => ['number' => '6006', 'play_code' => 'pos_4a', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => $t === 'first' ? '6006' : p145_board_without_8888($t, $i),
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_4c',
            'line' => ['number' => '4004', 'play_code' => 'pos_4c', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'third' => '4004',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'third',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_4d',
            'line' => ['number' => '5555', 'play_code' => 'pos_4d', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'starter' => $i === 3 ? '5555' : sprintf('71%02d', $i),
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'starter',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_3b',
            'line' => ['number' => '949', 'play_code' => 'pos_3b', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'second' => '2949',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'second',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_3c',
            'line' => ['number' => '678', 'play_code' => 'pos_3c', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'third' => '9678',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'third',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_3abc',
            'line' => ['number' => '567', 'play_code' => 'pos_3abc', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '4567',
                'second' => '8123',
                'third' => '9234',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_2b',
            'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '1111',
                'second' => '7856',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'second',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_2c',
            'line' => ['number' => '30', 'play_code' => 'pos_2c', 'amount' => 10_000],
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '9999',
                'second' => '8888',
                'third' => '7830',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'third',
            'comboMultiplier' => 1,
        ],
        [
            'play' => 'pos_2abc',
            'line' => ['number' => '99', 'play_code' => 'pos_2abc', 'amount' => 100],
            'board' => fn (string $t, int $i): string => match ($t) {
                'first' => '8899',
                'second' => '2299',
                'third' => '1199',
                default => p145_board_without_8888($t, $i),
            },
            'scope' => 'first',
            'comboMultiplier' => 1,
        ],
    ];

    foreach ($cases as $case) {
        $player = p145_player(80_000_000);
        $drawNo = p145_next_draw_no();
        $draw = p145_draw($drawNo, random_int(1, 99_999));

        $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
            ->postJson('/api/v1/ticket/place', [
                'draw_id' => $drawNo,
                'currency_code' => 'NPR',
                'client_trace_id' => 'p145-all-'.$case['play'].'-'.uniqid('', true),
                'lines' => [$case['line']],
            ])
            ->assertOk();

        $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
        $deduct = (int) $item->actual_deduct_amount;
        $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
        $unitOnTicket = (int) $item->unit_bet_amount;
        $perComboWin = (int) floor($unitOnTicket * $odds / 10_000);
        $expectedWin = $perComboWin * (int) $case['comboMultiplier'];

        $board = $case['board'] ?? null;
        if ($case['first_combo_board'] ?? false) {
            $target = (string) TicketCombination::query()
                ->where('ticket_item_id', $item->id)
                ->orderBy('combination_no')
                ->value('number_4d');
            $board = fn (string $t, int $i): string => $t === 'first' ? $target : p145_board_without_8888($t, $i);
        }
        expect($board)->toBeInstanceOf(Closure::class, $case['play']);

        p145_publish_board($draw, $board);
        $draw->forceFill([
            'status' => DrawStatus::Settling->value,
            'current_result_version' => 1,
        ])->save();

        expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
        p145_approve_and_payout($draw);

        $item->refresh();
        expect($item->status)->toBe('settled_win', $case['play'])
            ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']);

        $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
        expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin, $case['play']);
    }
});

test('§14.6 ticket detail shows settlement tier after win', function (): void {
    $player = p145_player();
    $drawNo = p145_next_draw_no();
    $draw = p145_draw($drawNo, random_int(1, 99_999));

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => $drawNo,
            'currency_code' => 'NPR',
            'client_trace_id' => 'p145-detail',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertOk();

    $ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
    expect($ticketNo)->not->toBeEmpty();

    p145_publish_board($draw, function (string $t, int $i): string {
        $num = $t === 'first' ? '1234' : '5678';

        return $num;
    });

    $draw->forceFill([
        'status' => DrawStatus::Settling->value,
        'current_result_version' => 1,
    ])->save();

    app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
    p145_approve_and_payout($draw);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->getJson('/api/v1/ticket/items/'.$ticketNo)
        ->assertOk()
        ->assertJsonPath('data.status', 'settled_win')
        ->assertJsonPath('data.settlement.matched_prize_tier', 'first');
});
