<?php

use App\Models\Draw;
use App\Models\Player;
use App\Models\RiskPool;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\JackpotPool;
use App\Models\PlayerWallet;
use App\Models\TicketCombination;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

function ticketPlayerWithWallet(int $balance = 200_000): Player
{
    $uniq = bin2hex(random_bytes(5));

    $player = Player::query()->create([
        'site_code' => 'test',
        'site_player_id' => 'ticket-player-'.$uniq,
        'username' => 'tp_'.$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;
}

function ticketOpenDraw(string $drawNo = '20260511-001'): Draw
{
    return Draw::query()->create([
        'draw_no' => $drawNo,
        'business_date' => '2026-05-11',
        'sequence_no' => 1,
        '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,
    ]);
}

function ticketPreviewPayload(string $drawNo = '20260511-001'): array
{
    return [
        'draw_id' => $drawNo,
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-001',
        'lines' => [
            [
                'number' => '1234',
                'play_code' => 'big',
                'amount' => 10_000,
            ],
            [
                'number' => '1234',
                'play_code' => 'ibox',
                'amount' => 100,
            ],
        ],
    ];
}

test('ticket preview returns computed summary for open draw', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->assertJsonPath('data.draw.draw_id', '20260511-001')
        ->assertJsonPath('data.summary.total_bet_amount', 12_400)
        ->assertJsonPath('data.summary.total_actual_deduct', 12_400)
        ->assertJsonPath('data.config_versions.play_config_version_no', 1)
        ->assertJsonPath('data.config_versions.odds_version_no', 1)
        ->assertJsonPath('data.config_versions.risk_cap_version_no', 1)
        ->assertJsonCount(2, 'data.lines');
});

test('module 6 box family expands combinations and computes amount semantics', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $payload = [
        'draw_id' => '20260511-001',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-module6-box',
        'lines' => [
            ['number' => '1234', 'play_code' => 'box', 'amount' => 10_000],
            ['number' => '1123', 'play_code' => 'box', 'amount' => 10_000],
            ['number' => '1122', 'play_code' => 'box', 'amount' => 10_000],
            ['number' => '1112', 'play_code' => 'box', 'amount' => 10_000],
            ['number' => '1111', 'play_code' => 'box', 'amount' => 10_000],
            ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
            ['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
        ],
    ];

    $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', $payload)
        ->assertOk();

    $lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
    expect($lines[1]['combination_count'])->toBe(24)
        ->and($lines[2]['combination_count'])->toBe(12)
        ->and($lines[3]['combination_count'])->toBe(6)
        ->and($lines[4]['combination_count'])->toBe(4)
        ->and($lines[5]['combination_count'])->toBe(1)
        ->and($lines[6]['combination_count'])->toBe(6)
        ->and($lines[6]['total_bet_amount'])->toBe(600)
        ->and($lines[7]['combination_count'])->toBe(24)
        ->and($lines[7]['total_bet_amount'])->toBe(9_984)
        ->and($lines[7]['actual_deduct_amount'])->toBe(9_984)
        ->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
});

test('module 6 mbox remainder splits amount evenly across preview and place', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $cases = [
        [
            'number' => '1234',
            'amount' => 10_001,
            'combination_count' => 24,
            'unit_bet_amount' => 416,
            'total_bet_amount' => 9984,
            'rounding_refund_amount' => 17,
        ],
        [
            'number' => '1122',
            'amount' => 601,
            'combination_count' => 6,
            'unit_bet_amount' => 100,
            'total_bet_amount' => 600,
            'rounding_refund_amount' => 1,
        ],
    ];

    foreach ($cases as $index => $case) {
        $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
            ->postJson('/api/v1/ticket/preview', [
                'draw_id' => '20260511-001',
                'currency_code' => 'NPR',
                'client_trace_id' => 'trace-module6-mbox-remainder-'.$index,
                'lines' => [
                    ['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']],
                ],
            ])
            ->assertOk();

        $line = $resp->json('data.lines.0');
        expect($line['combination_count'])->toBe($case['combination_count'])
            ->and($line['total_bet_amount'])->toBe($case['total_bet_amount'])
            ->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount'])
            ->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount'])
            ->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']);
    }

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-module6-mbox-place-remainder',
            'lines' => [
                ['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.summary.total_bet_amount', 9984)
        ->assertJsonPath('data.summary.total_actual_deduct', 9984);

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

    expect((int) $item->unit_bet_amount)->toBe(416)
        ->and((int) $item->total_bet_amount)->toBe(9984)
        ->and((int) $item->actual_deduct_amount)->toBe(9984)
        ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17);

    $comboAmounts = TicketCombination::query()
        ->where('ticket_item_id', $item->id)
        ->pluck('bet_amount')
        ->map(fn ($amount) => (int) $amount)
        ->unique()
        ->values()
        ->all();

    expect($comboAmounts)->toBe([416]);

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

test('module 6 roll expands each R position and charges per expanded combination', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-module6-roll',
            'lines' => [
                ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100],
                ['number' => 'RR34', 'play_code' => 'roll', 'amount' => 100],
            ],
        ])
        ->assertOk();

    $lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
    expect($lines[1]['combination_count'])->toBe(10)
        ->and($lines[1]['total_bet_amount'])->toBe(1_000)
        ->and($lines[2]['combination_count'])->toBe(100)
        ->and($lines[2]['total_bet_amount'])->toBe(10_000);
});

test('module 6 reserved and phase two plays are not available for betting or public entry', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-module6-half-box',
            'lines' => [
                ['number' => '1234', 'play_code' => 'half_box', 'amount' => 10_000],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::PlayModeClosed->value);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-module6-5d',
            'lines' => [
                ['number' => '12345', 'play_code' => '5d', 'amount' => 10_000],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::PlayModeClosed->value);

    $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
    expect($plays->firstWhere('play_code', 'half_box')['config']['is_enabled'])->toBeFalse()
        ->and($plays->contains('play_code', '5d'))->toBeFalse()
        ->and($plays->contains('play_code', '6d'))->toBeFalse();
});

test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', ticketPreviewPayload())
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->assertJsonPath('data.draw.draw_id', '20260511-001')
        ->assertJsonPath('data.summary.total_bet_amount', 12_400)
        ->assertJsonPath('data.summary.total_actual_deduct', 12_400);

    expect(TicketOrder::query()->count())->toBe(1);
    expect(TicketItem::query()->count())->toBe(2);
    expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
    expect(RiskPool::query()->count())->toBeGreaterThan(0);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});

test('ticket place is idempotent by player draw and client trace id', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => 'same-submit-once']);

    $first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->json('data');

    $second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->json('data');

    expect($second['order_no'])->toBe($first['order_no'])
        ->and(TicketOrder::query()->count())->toBe(1)
        ->and(TicketItem::query()->count())->toBe(2)
        ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});

test('ticket place idempotency is scoped per draw not global trace', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw('20260511-001');

    Draw::query()->create([
        'draw_no' => '20260511-002',
        'business_date' => '2026-05-11',
        'sequence_no' => 2,
        '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,
    ]);

    $trace = 'shared-trace-two-draws';
    $payloadA = array_merge(ticketPreviewPayload('20260511-001'), ['client_trace_id' => $trace]);
    $payloadB = array_merge(ticketPreviewPayload('20260511-002'), ['client_trace_id' => $trace]);

    $first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payloadA)
        ->assertOk()
        ->json('data.order_no');

    $second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payloadB)
        ->assertOk()
        ->json('data.order_no');

    expect($second)->not->toBe($first)
        ->and(TicketOrder::query()->count())->toBe(2);
});

test('ticket place accepts draw pending in db when hall rules show open', function (): void {
    $player = ticketPlayerWithWallet();
    Draw::query()->create([
        'draw_no' => '20260511-pending-hall',
        'business_date' => '2026-05-11',
        'sequence_no' => 99,
        'status' => DrawStatus::Pending->value,
        'start_time' => now()->subMinute(),
        '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,
    ]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-pending-hall',
            'currency_code' => 'NPR',
            'client_trace_id' => 'pending-hall-open-place',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value);

    expect(TicketOrder::query()->where('client_trace_id', 'pending-hall-open-place')->exists())->toBeTrue();
});

test('ticket place rejects bet when only frozen balance would cover stake', function (): void {
    $player = ticketPlayerWithWallet(20_000);
    ticketOpenDraw();

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    $wallet->forceFill(['balance' => 20_000, 'frozen_balance' => 19_000])->save();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', array_merge(ticketPreviewPayload(), [
            'client_trace_id' => 'frozen-balance-guard',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ]))
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
});

test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'combo-payout-sum',
            'lines' => [
                ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
            ],
        ])
        ->assertOk()
        ->json('data');

    $item = TicketItem::query()->firstOrFail();
    $combinationSum = TicketCombination::query()
        ->where('ticket_item_id', $item->id)
        ->sum('estimated_payout');

    expect((int) $response['summary']['total_estimated_payout'])->toBe((int) $combinationSum)
        ->and((int) $item->estimated_max_payout)->toBe((int) $combinationSum)
        ->and((int) $item->risk_locked_amount)->toBe((int) $combinationSum);
});

test('ticket place rejects closed draw', function (): void {
    $player = ticketPlayerWithWallet();
    $draw = ticketOpenDraw();
    $draw->update(['status' => DrawStatus::Closed->value]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', ticketPreviewPayload())
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::DrawClosed->value);
});

test('ticket preview and place reject open draw after server close time', function (): void {
    $player = ticketPlayerWithWallet();
    $draw = ticketOpenDraw();
    $draw->update(['close_time' => now()->subSecond()]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::DrawClosed->value);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', ticketPreviewPayload())
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::DrawClosed->value);

    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket place rejects insufficient balance', function (): void {
    $player = ticketPlayerWithWallet(1_000);
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', ticketPreviewPayload())
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);

    expect(TicketOrder::query()->count())->toBe(0);
    expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});

test('ticket place succeeds when expected_config_versions matches preview', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
        ->assertOk()
        ->json('data.config_versions');

    $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value);

    expect(TicketOrder::query()->count())->toBe(1);
    $order = TicketOrder::query()->firstOrFail();
    expect((int) $order->play_config_version_no)->toBe((int) $versions['play_config_version_no'])
        ->and((int) $order->odds_version_no)->toBe((int) $versions['odds_version_no'])
        ->and((int) $order->risk_cap_version_no)->toBe((int) $versions['risk_cap_version_no']);
});

test('ticket place rejects stale expected_config_versions', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
        ->assertOk()
        ->json('data.config_versions');

    OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]);

    $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::BetConfigStale->value);

    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket place rejects sold out risk pool', function (): void {
    $player = ticketPlayerWithWallet();
    $draw = ticketOpenDraw();

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 100,
        'locked_amount' => 100,
        'remaining_amount' => 0,
        'sold_out_status' => 1,
        'version' => 1,
    ]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-002',
            'lines' => [
                [
                    'number' => '1234',
                    'play_code' => 'big',
                    'amount' => 10_000,
                ],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    expect((int) $wallet->balance)->toBe(200_000);
    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket place can return mixed success and failed risk results', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    $draw = ticketOpenDraw();

    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,
    ]);

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '5678',
        'total_cap_amount' => 100,
        'locked_amount' => 100,
        'remaining_amount' => 0,
        'sold_out_status' => 1,
        'version' => 1,
    ]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-mixed-risk',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
                ['number' => '5678', 'play_code' => 'big', 'amount' => 120],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->assertJsonPath('data.summary.success_count', 1)
        ->assertJsonPath('data.summary.failure_count', 1)
        ->assertJsonPath('data.items.0.status', 'pending_draw')
        ->assertJsonPath('data.items.1.status', 'failed')
        ->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value);

    $order = TicketOrder::query()->firstOrFail();
    expect($order->status)->toBe('partial_failed')
        ->and((int) $order->total_actual_deduct)->toBe(120)
        ->and(TicketItem::query()->where('status', 'pending_draw')->count())->toBe(1)
        ->and(TicketItem::query()->where('status', 'failed')->count())->toBe(1);

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

test('ticket place rejects disabled play from active catalog', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $versionId = PlayConfigVersion::query()
        ->where('status', ConfigVersionStatus::Active->value)
        ->value('id');
    expect($versionId)->not->toBeNull();
    PlayConfigItem::query()
        ->where('version_id', $versionId)
        ->where('play_code', 'big')
        ->update(['is_enabled' => false]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->withHeader('X-Locale', 'zh')
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-disabled-play',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
        ->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
        ->assertJsonPath('data.cleanup_hint', '玩法已关闭，相关注项已清理')
        ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
        ->assertJsonPath('data.cleanup_lines.0.play_code', 'big');

    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket preview returns cleanup hint when draft contains closed play', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $versionId = PlayConfigVersion::query()
        ->where('status', ConfigVersionStatus::Active->value)
        ->value('id');
    expect($versionId)->not->toBeNull();
    PlayConfigItem::query()
        ->where('version_id', $versionId)
        ->where('play_code', 'big')
        ->update(['is_enabled' => false]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->withHeader('X-Locale', 'zh')
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-preview-closed-play',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
        ->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
        ->assertJsonPath('data.cleanup_hint', '玩法已关闭，相关注项已清理')
        ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
        ->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
});

test('ticket place rejects bet amount below configured minimum', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-min-bet',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 50],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::WalletAmountExceedsLimit->value);

    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void {
    $player = ticketPlayerWithWallet();
    $draw = ticketOpenDraw();

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 4000,
        'locked_amount' => 0,
        'remaining_amount' => 4000,
        'sold_out_status' => 0,
        'version' => 0,
    ]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-preview-risk-warning',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 160],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.lines.0.risk_status', 'ok')
        ->assertJsonPath('data.warnings.0.number_4d', '1234');

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();

    expect((int) $wallet->balance)->toBe(200_000)
        ->and((int) $pool->locked_amount)->toBe(0)
        ->and((int) $pool->remaining_amount)->toBe(4000)
        ->and(TicketOrder::query()->count())->toBe(0)
        ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});

test('ticket preview validates digit size dimension and slot rules', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $base = [
        'draw_id' => '20260511-001',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-digit-validation',
    ];

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', $base + [
            'lines' => [
                ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', $base + [
            'client_trace_id' => 'trace-digit-invalid-slot',
            'lines' => [
                ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1],
            ],
        ])
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);

    expect(TicketOrder::query()->count())->toBe(0);
});

test('ticket place persists valid 2d digit size slot snapshot', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-digit-d2-slot',
            'lines' => [
                ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.summary.success_count', 1)
        ->assertJsonPath('data.items.0.status', 'pending_draw');

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

    expect((int) $item->dimension)->toBe(2)
        ->and((int) $item->digit_slot)->toBe(3)
        ->and((int) $item->combination_count)->toBe(5000)
        ->and($ruleSnapshot['dimension'] ?? null)->toBe('D2')
        ->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3)
        ->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue()
        ->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse();
});

test('ticket preview rejects invalid line amount per validation rules', function (): void {
    $player = ticketPlayerWithWallet();
    ticketOpenDraw();

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'trace-invalid-amt',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 0],
            ],
        ])
        ->assertStatus(422);
});

/**
 * §10.1.2 混合成功失败：同一订单两行共享号码时，首条占用成功，第二条额度不足则该注项失败。
 * big + amount 120 → estimated_payout 3000（maxOdds 250000 / 10000 = 25）。
 */
test('ticket place records partial failed when mid-order acquire fails', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    $draw = ticketOpenDraw();

    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,
    ]);

    $payload = [
        'draw_id' => '20260511-001',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-rollback',
        'lines' => [
            ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
            ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
        ],
    ];

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value)
        ->assertJsonPath('data.summary.success_count', 1)
        ->assertJsonPath('data.summary.failure_count', 1);

    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);
});

/** §13.5 并发下注（顺序挤出）：先成功者占用额度，后一盘同一号码收到售罄。 */
test('ticket place sold out for second player after first consumes shared pool', function (): void {
    $draw = ticketOpenDraw();
    $playerA = ticketPlayerWithWallet();
    $playerB = ticketPlayerWithWallet();

    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,
    ]);

    $payload = [
        'draw_id' => '20260511-001',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-race-a',
        'lines' => [
            ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
        ],
    ];

    $this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
        ->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-a']))
        ->assertOk()
        ->assertJsonPath('code', ErrorCode::Success->value);

    $this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
        ->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-b']))
        ->assertStatus(400)
        ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);

    expect(TicketOrder::query()->count())->toBe(1);

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

test('ticket preview and place apply base rebate plus player add-on rebate for wallet player', function (): void {
    $player = ticketPlayerWithWallet(500_000);
    ticketOpenDraw();

    $oddsVersionId = OddsVersion::query()
        ->where('status', ConfigVersionStatus::Active->value)
        ->value('id');
    expect($oddsVersionId)->not->toBeNull();

    DB::table('odds_items')
        ->where('version_id', $oddsVersionId)
        ->where('play_code', 'big')
        ->update(['rebate_rate' => 0.01]);

    DB::table('player_rebate_profiles')->insert([
        'player_id' => $player->id,
        'game_type' => 'big',
        'rebate_rate' => 0.005,
        'extra_rebate_rate' => 0.002,
        'inherit_from_agent' => false,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $payload = [
        'draw_id' => '20260511-001',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-wallet-rebate-stack',
        'lines' => [
            ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
        ],
    ];

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', $payload)
        ->assertOk()
        ->assertJsonPath('data.summary.total_bet_amount', 10_000)
        ->assertJsonPath('data.summary.total_actual_deduct', 9_830)
        ->assertJsonPath('data.summary.total_rebate_amount', 170)
        ->assertJsonPath('data.lines.0.rebate_rate', '0.0170')
        ->assertJsonPath('data.lines.0.rebate_amount', 170)
        ->assertJsonPath('data.lines.0.actual_deduct_amount', 9_830)
        ->assertJsonPath('data.lines.0.rule_snapshot_json.base_rebate_rate', '0.0100')
        ->assertJsonPath('data.lines.0.rule_snapshot_json.player_addon_rebate_rate', '0.0070')
        ->assertJsonPath('data.lines.0.rule_snapshot_json.rebate_inherited_from_agent', false);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertOk()
        ->assertJsonPath('data.summary.total_bet_amount', 10_000)
        ->assertJsonPath('data.summary.total_actual_deduct', 9_830);

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

    expect((string) $item->rebate_rate_snapshot)->toBe('0.0170')
        ->and((int) $item->actual_deduct_amount)->toBe(9_830)
        ->and($ruleSnapshot['base_rebate_rate'] ?? null)->toBe('0.0100')
        ->and($ruleSnapshot['player_addon_rebate_rate'] ?? null)->toBe('0.0070');

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

test('credit player ticket preview shows period rebate estimate without instant deduct', function (): void {
    $site = DB::table('admin_sites')->where('is_default', true)->first();
    $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');

    $player = Player::query()->create([
        'site_code' => (string) $site->code,
        'agent_node_id' => $rootId,
        'site_player_id' => 'native:credit-rebate-preview',
        'auth_source' => 'lottery_native',
        'funding_mode' => 'credit',
        'username' => 'credit_rebate_user',
        'password_hash' => bcrypt('secret-pass'),
        'nickname' => null,
        'default_currency' => 'NPR',
        'status' => 0,
    ]);

    DB::table('player_credit_accounts')->insert([
        'player_id' => $player->id,
        'credit_limit' => 500_000,
        'used_credit' => 0,
        'frozen_credit' => 0,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    DB::table('player_rebate_profiles')->insert([
        'player_id' => $player->id,
        'game_type' => '*',
        'rebate_rate' => 0.10,
        'extra_rebate_rate' => 0,
        'inherit_from_agent' => false,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    ticketOpenDraw('20260511-credit-rebate');

    $payload = [
        'draw_id' => '20260511-credit-rebate',
        'currency_code' => 'NPR',
        'client_trace_id' => 'trace-credit-rebate-preview',
        'lines' => [
            ['number' => '12', 'play_code' => 'pos_2a', 'amount' => 10_000],
        ],
    ];

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/preview', $payload)
        ->assertOk()
        ->assertJsonPath('data.summary.total_bet_amount', 10_000)
        ->assertJsonPath('data.summary.total_rebate_amount', 1_000)
        ->assertJsonPath('data.summary.total_actual_deduct', 10_000)
        ->assertJsonPath('data.summary.instant_rebate_applied', false)
        ->assertJsonPath('data.lines.0.rebate_rate', '0.1000')
        ->assertJsonPath('data.lines.0.rebate_amount', 1_000)
        ->assertJsonPath('data.lines.0.actual_deduct_amount', 10_000);
});

test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void {
    $draw = ticketOpenDraw();
    $player = ticketPlayerWithWallet();

    $order = TicketOrder::query()->create([
        'order_no' => 'TO-PENDING-001',
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'currency_code' => 'NPR',
        'total_bet_amount' => 100,
        'total_rebate_amount' => 0,
        'total_actual_deduct' => 100,
        'total_estimated_payout' => 3000,
        'status' => 'pending_confirm',
        'submit_source' => 'h5',
        'client_trace_id' => 'pending-confirm-missing-wallet',
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);
    TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);

    $item = TicketItem::query()->create([
        'ticket_no' => 'TK-PENDING-001',
        'order_id' => $order->id,
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'original_number' => '1234',
        'normalized_number' => '1234',
        'play_code' => 'big',
        'dimension' => 4,
        'digit_slot' => null,
        'bet_mode' => 'straight',
        'unit_bet_amount' => 100,
        'total_bet_amount' => 100,
        'rebate_rate_snapshot' => 0,
        'commission_rate_snapshot' => 0,
        'actual_deduct_amount' => 100,
        'odds_snapshot_json' => [],
        'rule_snapshot_json' => [],
        'combination_count' => 1,
        'estimated_max_payout' => 3000,
        'risk_locked_amount' => 3000,
        'status' => 'pending_confirm',
        'fail_reason_code' => null,
        'fail_reason_text' => null,
        'win_amount' => 0,
        'jackpot_win_amount' => 0,
        'settled_at' => null,
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);

    TicketCombination::query()->create([
        'ticket_item_id' => $item->id,
        'combination_no' => 1,
        'number_4d' => '1234',
        'bet_amount' => 100,
        'estimated_payout' => 3000,
        'created_at' => now()->subMinutes(20),
    ]);

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 5000,
        'locked_amount' => 3000,
        'remaining_amount' => 2000,
        'sold_out_status' => 0,
        'version' => 1,
    ]);

    $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
        ->expectsOutputToContain('refunded: 1')
        ->assertExitCode(0);

    expect($order->fresh()->status)->toBe('refunded')
        ->and($item->fresh()->status)->toBe('refunded')
        ->and($item->fresh()->fail_reason_text)->toBe('pending_confirm_timeout_refund');

    $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
    expect((int) $pool->locked_amount)->toBe(0)
        ->and((int) $pool->remaining_amount)->toBe(5000)
        ->and(WalletTxn::query()->where('biz_no', 'TO-PENDING-001')->count())->toBe(0);
});

test('ticket pending confirmation reconcile confirms order when wallet deduction exists', function (): void {
    $draw = ticketOpenDraw();
    $player = ticketPlayerWithWallet(10_000);
    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();

    $order = TicketOrder::query()->create([
        'order_no' => 'TO-PENDING-002',
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'currency_code' => 'NPR',
        'total_bet_amount' => 100,
        'total_rebate_amount' => 0,
        'total_actual_deduct' => 100,
        'total_estimated_payout' => 3000,
        'status' => 'pending_confirm',
        'submit_source' => 'h5',
        'client_trace_id' => 'pending-confirm-with-wallet',
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);
    TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);

    $item = TicketItem::query()->create([
        'ticket_no' => 'TK-PENDING-002',
        'order_id' => $order->id,
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'original_number' => '1234',
        'normalized_number' => '1234',
        'play_code' => 'big',
        'dimension' => 4,
        'digit_slot' => null,
        'bet_mode' => 'straight',
        'unit_bet_amount' => 100,
        'total_bet_amount' => 100,
        'rebate_rate_snapshot' => 0,
        'commission_rate_snapshot' => 0,
        'actual_deduct_amount' => 100,
        'odds_snapshot_json' => [],
        'rule_snapshot_json' => [],
        'combination_count' => 1,
        'estimated_max_payout' => 3000,
        'risk_locked_amount' => 3000,
        'status' => 'pending_confirm',
        'fail_reason_code' => null,
        'fail_reason_text' => null,
        'win_amount' => 0,
        'jackpot_win_amount' => 0,
        'settled_at' => null,
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);

    TicketCombination::query()->create([
        'ticket_item_id' => $item->id,
        'combination_no' => 1,
        'number_4d' => '1234',
        'bet_amount' => 100,
        'estimated_payout' => 3000,
        'created_at' => now()->subMinutes(20),
    ]);

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 5000,
        'locked_amount' => 3000,
        'remaining_amount' => 2000,
        'sold_out_status' => 0,
        'version' => 1,
    ]);

    WalletTxn::query()->create([
        'txn_no' => 'WL-PENDING-002',
        'player_id' => $player->id,
        'wallet_id' => $wallet->id,
        'biz_type' => 'bet_deduct',
        'biz_no' => 'TO-PENDING-002',
        'direction' => 2,
        'amount' => 100,
        'balance_before' => 10_000,
        'balance_after' => 9_900,
        'status' => 'posted',
        'external_ref_no' => null,
        'idempotent_key' => 'pending-confirm-with-wallet',
        'remark' => null,
    ]);

    $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
        ->expectsOutputToContain('confirmed: 1')
        ->assertExitCode(0);

    expect($order->fresh()->status)->toBe('placed')
        ->and($item->fresh()->status)->toBe('pending_draw')
        ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000);
});

test('ticket place reverses wallet and releases risk when post deduction confirmation fails', function (): void {
    $player = ticketPlayerWithWallet(20_000);
    $draw = ticketOpenDraw();

    JackpotPool::query()->updateOrCreate(
        ['currency_code' => 'NPR'],
        [
            'current_amount' => 0,
            'contribution_rate' => 1,
            'trigger_threshold' => 0,
            'payout_rate' => 0,
            'force_trigger_draw_gap' => 0,
            'min_bet_amount' => 0,
            'status' => 1,
        ],
    );
    DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', [
            'draw_id' => '20260511-001',
            'currency_code' => 'NPR',
            'client_trace_id' => 'wallet-reverse-on-confirm-fail',
            'lines' => [
                ['number' => '1234', 'play_code' => 'big', 'amount' => 120],
            ],
        ])
        ->assertStatus(500);

    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
    $order = TicketOrder::query()->where('client_trace_id', 'wallet-reverse-on-confirm-fail')->firstOrFail();

    expect((int) $wallet->balance)->toBe(20_000)
        ->and($order->status)->toBe('refunded')
        ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_deduct')->count())->toBe(1)
        ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1)
        ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});

test('ticket place idempotency rejects replay for refunded order with same trace', function (): void {
    $player = ticketPlayerWithWallet();
    $draw = ticketOpenDraw();
    $trace = 'trace-refunded-replay';

    TicketOrder::query()->create([
        'order_no' => 'TO-REFUNDED-IDEM',
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'currency_code' => 'NPR',
        'total_bet_amount' => 100,
        'total_rebate_amount' => 0,
        'total_actual_deduct' => 100,
        'total_estimated_payout' => 3000,
        'status' => 'refunded',
        'submit_source' => 'h5',
        'client_trace_id' => $trace,
    ]);

    $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => $trace]);

    $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
        ->postJson('/api/v1/ticket/place', $payload)
        ->assertStatus(409)
        ->assertJsonPath('code', ErrorCode::BetIdempotentReplayRejected->value);

    expect(TicketOrder::query()->count())->toBe(1)
        ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});

test('ticket pending confirmation reconcile refunds when draw no longer accepts bets', function (): void {
    $draw = ticketOpenDraw();
    $draw->forceFill([
        'status' => DrawStatus::Closed->value,
        'close_time' => now()->subMinute(),
        'draw_time' => now()->subMinute(),
    ])->save();

    $player = ticketPlayerWithWallet(10_000);
    $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();

    $order = TicketOrder::query()->create([
        'order_no' => 'TO-PENDING-CLOSED',
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'currency_code' => 'NPR',
        'total_bet_amount' => 100,
        'total_rebate_amount' => 0,
        'total_actual_deduct' => 100,
        'total_estimated_payout' => 3000,
        'status' => 'pending_confirm',
        'submit_source' => 'h5',
        'client_trace_id' => 'pending-on-closed-draw',
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);
    TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);

    $item = TicketItem::query()->create([
        'ticket_no' => 'TK-PENDING-CLOSED',
        'order_id' => $order->id,
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'original_number' => '1234',
        'normalized_number' => '1234',
        'play_code' => 'big',
        'dimension' => 4,
        'digit_slot' => null,
        'bet_mode' => 'straight',
        'unit_bet_amount' => 100,
        'total_bet_amount' => 100,
        'rebate_rate_snapshot' => 0,
        'commission_rate_snapshot' => 0,
        'actual_deduct_amount' => 100,
        'odds_snapshot_json' => [],
        'rule_snapshot_json' => [],
        'combination_count' => 1,
        'estimated_max_payout' => 3000,
        'risk_locked_amount' => 3000,
        'status' => 'pending_confirm',
        'fail_reason_code' => null,
        'fail_reason_text' => null,
        'win_amount' => 0,
        'jackpot_win_amount' => 0,
        'settled_at' => null,
        'created_at' => now()->subMinutes(20),
        'updated_at' => now()->subMinutes(20),
    ]);

    TicketCombination::query()->create([
        'ticket_item_id' => $item->id,
        'combination_no' => 1,
        'number_4d' => '1234',
        'bet_amount' => 100,
        'estimated_payout' => 3000,
        'created_at' => now()->subMinutes(20),
    ]);

    RiskPool::query()->create([
        'draw_id' => $draw->id,
        'normalized_number' => '1234',
        'total_cap_amount' => 5000,
        'locked_amount' => 3000,
        'remaining_amount' => 2000,
        'sold_out_status' => 0,
        'version' => 1,
    ]);

    $wallet->forceFill(['balance' => 9_900])->save();

    WalletTxn::query()->create([
        'txn_no' => 'WL-PENDING-CLOSED',
        'player_id' => $player->id,
        'wallet_id' => $wallet->id,
        'biz_type' => 'bet_deduct',
        'biz_no' => 'TO-PENDING-CLOSED',
        'direction' => 2,
        'amount' => 100,
        'balance_before' => 10_000,
        'balance_after' => 9_900,
        'status' => 'posted',
        'external_ref_no' => null,
        'idempotent_key' => 'bet_deduct:TO-PENDING-CLOSED',
        'remark' => null,
    ]);

    $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
        ->expectsOutputToContain('refunded: 1')
        ->assertExitCode(0);

    expect($order->fresh()->status)->toBe('refunded')
        ->and($item->fresh()->status)->toBe('refunded')
        ->and($item->fresh()->fail_reason_code)->toBe('draw_no_longer_open')
        ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(10_000)
        ->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
        ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});
