<?php

/**
 * 验收自动化（对应测试任务 §5 / 完成标准 §12.6）：
 *
 * - 配置改动是否生效 → 赔率 / 玩法限额发布后 GET /api/v1/play/effective 可读出新值；玩法目录开关随版本草稿发布后生效。
 * - 配置改动是否影响已下注订单 → ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写（注单链路尚未实现时，用数据不变量验证）。
 * - 配置历史是否可追溯 → odds_versions 存在 archived + active；audit_logs 记录 publish。
 *
 * 运行：php artisan test tests/Feature/OperationalConfigAcceptanceTest.php
 */

use App\Models\Draw;
use App\Models\Player;
use App\Models\AuditLog;
use App\Models\PlayType;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\RiskCapVersion;
use App\Services\Ticket\PlayCatalogResolver;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Support\Facades\Hash;
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);
});

function acceptanceMintAdminToken(): string
{
    $admin = AdminUser::query()->create([
        'username' => 'acceptance_admin',
        'name' => 'Acceptance QA',
        'email' => null,
        'password' => Hash::make('secret-strong'),
        'status' => 0,
    ]);
    grantSuperAdminRole($admin);

    return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}

function acceptanceOddsPutPayloadFromDetail(array $items): array
{
    return collect($items)->map(fn (array $r) => [
        'play_code' => $r['play_code'],
        'prize_scope' => $r['prize_scope'],
        'odds_value' => (int) $r['odds_value'],
        'rebate_rate' => (float) $r['rebate_rate'],
        'commission_rate' => (float) $r['commission_rate'],
        'currency_code' => $r['currency_code'],
        'extra_config_json' => $r['extra_config_json'] ?? null,
    ])->all();
}

test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $itemPayload = [];
    foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
        $itemPayload[] = [
            'play_code' => $t->play_code,
            'category' => $t->category,
            'dimension' => $t->dimension,
            'bet_mode' => $t->bet_mode,
            'display_name' => $t->display_name ?? $t->play_code,
            'is_enabled' => true,
            'min_bet_amount' => 777,
            'max_bet_amount' => 400_000_000,
            'display_order' => (int) $t->sort_order,
            'supports_multi_number' => (bool) $t->supports_multi_number,
            'reserved_rule_json' => $t->reserved_rule_json,
        ];
    }

    $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
    expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777);
});

test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $payload = acceptanceOddsPutPayloadFromDetail($detail);
    foreach ($payload as &$row) {
        if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
            $row['odds_value'] = 333_333;
        }
    }
    unset($row);

    $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
    expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333);
});

test('§5 odds publish archives prior version lists history and writes audit log', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();

    $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $this->putJson(
        '/api/v1/admin/config/odds-versions/'.$draftId.'/items',
        ['items' => acceptanceOddsPutPayloadFromDetail($detail)],
        $auth,
    )->assertOk();

    $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
    expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);

    $list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items');
    expect(count($list))->toBeGreaterThanOrEqual(2);

    expect(
        AuditLog::query()
            ->where('module_code', 'odds')
            ->where('action_code', 'publish')
            ->exists(),
    )->toBeTrue();
});

test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row'];

    $player = Player::query()->create([
        'site_code' => 'test',
        'site_player_id' => 'p1',
        'username' => 'u1',
        'nickname' => null,
        'default_currency' => 'NPR',
        'status' => 0,
    ]);

    $draw = Draw::query()->create([
        'draw_no' => 'ACC-001',
        'business_date' => now()->toDateString(),
        'sequence_no' => 1,
        'status' => DrawStatus::Open->value,
        'start_time' => null,
        'close_time' => null,
        'draw_time' => null,
        'cooling_end_time' => null,
        'result_source' => null,
        'current_result_version' => 0,
        'settle_version' => 0,
        'is_reopened' => false,
    ]);

    $now = now();
    $orderId = DB::table('ticket_orders')->insertGetId([
        'order_no' => 'ORD-ACC-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' => 0,
        'status' => 'confirmed',
        'submit_source' => 'h5',
        'client_trace_id' => null,
        'created_at' => $now,
        'updated_at' => $now,
    ]);

    DB::table('ticket_items')->insert([
        'ticket_no' => 'TICK-ACC-001',
        'order_id' => $orderId,
        'player_id' => $player->id,
        'draw_id' => $draw->id,
        'original_number' => null,
        'normalized_number' => '1234',
        'play_code' => 'big',
        'dimension' => 2,
        'digit_slot' => null,
        'bet_mode' => null,
        'unit_bet_amount' => 100,
        'total_bet_amount' => 100,
        'rebate_rate_snapshot' => 0,
        'commission_rate_snapshot' => 0,
        'actual_deduct_amount' => 100,
        'odds_snapshot_json' => json_encode($snapshot),
        'rule_snapshot_json' => null,
        'combination_count' => 1,
        'estimated_max_payout' => 0,
        'risk_locked_amount' => 0,
        'status' => 'confirmed',
        'fail_reason_code' => null,
        'fail_reason_text' => null,
        'win_amount' => 0,
        'jackpot_win_amount' => 0,
        'settled_at' => null,
        'created_at' => $now,
        'updated_at' => $now,
    ]);

    $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $payload = acceptanceOddsPutPayloadFromDetail($detail);
    foreach ($payload as &$row) {
        if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
            $row['odds_value'] = 9_999_999;
        }
    }
    unset($row);

    $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    $stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json');
    expect(json_decode((string) $stored, true))->toBe($snapshot);

    $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
    expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999);
});

test('§12.6 published play config controls master_enabled on public catalog without code deploy', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance master'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $rows = $this->getJson('/api/v1/admin/config/play-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $payload = collect($rows)->map(fn (array $r) => [
        'play_code' => $r['play_code'],
        'category' => $r['category'],
        'dimension' => $r['dimension'],
        'bet_mode' => $r['bet_mode'],
        'display_name' => $r['display_name'],
        'is_enabled' => $r['is_enabled'],
        'min_bet_amount' => (int) $r['min_bet_amount'],
        'max_bet_amount' => (int) $r['max_bet_amount'],
        'display_order' => (int) $r['display_order'],
        'supports_multi_number' => (bool) $r['supports_multi_number'],
        'reserved_rule_json' => $r['reserved_rule_json'] ?? null,
        'rule_text_zh' => $r['rule_text_zh'] ?? null,
        'rule_text_en' => $r['rule_text_en'] ?? null,
        'rule_text_ne' => $r['rule_text_ne'] ?? null,
        'extra_config_json' => $r['extra_config_json'] ?? null,
    ])->all();

    foreach ($payload as &$row) {
        if ($row['play_code'] === 'big') {
            $row['is_enabled'] = false;
        }
    }
    unset($row);

    $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
    expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse();
});

test('§5 risk cap publish is audited and version history exists', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();

    $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $payload = collect($rows)->map(fn (array $r) => [
        'draw_id' => $r['draw_id'],
        'normalized_number' => $r['normalized_number'],
        'cap_amount' => (int) $r['cap_amount'],
        'cap_type' => $r['cap_type'],
    ])->all();

    $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);

    expect(
        AuditLog::query()
            ->where('module_code', 'risk_cap')
            ->where('action_code', 'publish')
            ->exists(),
    )->toBeTrue();

    $list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items');
    expect(count($list))->toBeGreaterThanOrEqual(2);
});

test('§10 default risk cap template applies to unconfigured numbers', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'default risk cap'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', [
        'items' => [
            [
                'draw_id' => null,
                'normalized_number' => '0000',
                'cap_amount' => 12_345,
                'cap_type' => 'default',
            ],
            [
                'draw_id' => null,
                'normalized_number' => '1234',
                'cap_amount' => 777,
                'cap_type' => 'per_number',
            ],
        ],
    ], $auth)->assertOk();

    $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    $resolver = app(PlayCatalogResolver::class);
    expect($resolver->resolveCapAmount(9999, '5678'))->toBe(12_345)
        ->and($resolver->resolveCapAmount(9999, '1234'))->toBe(777);
});

test('§5 play_config publish is audited', function (): void {
    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $itemPayload = [];
    foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
        $itemPayload[] = [
            'play_code' => $t->play_code,
            'category' => $t->category,
            'dimension' => $t->dimension,
            'bet_mode' => $t->bet_mode,
            'display_name' => $t->display_name ?? $t->play_code,
            'is_enabled' => true,
            'min_bet_amount' => 100,
            'max_bet_amount' => 500_000_000,
            'display_order' => (int) $t->sort_order,
            'supports_multi_number' => (bool) $t->supports_multi_number,
            'reserved_rule_json' => $t->reserved_rule_json,
        ];
    }

    $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    expect(
        AuditLog::query()
            ->where('module_code', 'play_config')
            ->where('action_code', 'publish')
            ->exists(),
    )->toBeTrue();
});

test('play type patch toggles active config and broadcasts instantly', function (): void {
    Event::fake([PlayToggleBroadcast::class]);
    config([
        'broadcasting.default' => 'reverb',
        'broadcasting.connections.reverb.driver' => 'reverb',
    ]);

    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)
        ->assertOk()
        ->assertJsonPath('data.play_code', 'big')
        ->assertJsonPath('data.is_enabled', false);

    Event::assertDispatched(
        PlayToggleBroadcast::class,
        fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
    );

    expect(
        AuditLog::query()
            ->where('module_code', 'play_config')
            ->where('action_code', 'toggle_active')
            ->where('target_id', 'big')
            ->exists(),
    )->toBeTrue();
});

test('§9 play_config publish broadcasts changed play toggles', function (): void {
    // fake 流程中可能触发的全部 Broadcast（PlayToggle / PlayCatalogUpdated / DrawStatusChange 等），
    // 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher，CI 环境抛 500。
    Event::fake(allBroadcastEvents());
    config([
        'broadcasting.default' => 'reverb',
        'broadcasting.connections.reverb.driver' => 'reverb',
    ]);

    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'toggle broadcast'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $itemPayload = $create->json('data.items');
    foreach ($itemPayload as &$row) {
        if ($row['play_code'] === 'big') {
            $row['is_enabled'] = false;
        }
    }
    unset($row);

    $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    Event::assertDispatched(
        PlayToggleBroadcast::class,
        fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
    );
});

test('§9 odds publish broadcasts odds update', function (): void {
    // fake 流程中可能触发的全部 Broadcast（OddsUpdate / PlayCatalogUpdated / DrawStatusChange 等），
    // 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher，CI 环境抛 500。
    Event::fake(allBroadcastEvents());
    config([
        'broadcasting.default' => 'reverb',
        'broadcasting.connections.reverb.driver' => 'reverb',
    ]);

    $token = acceptanceMintAdminToken();
    $auth = ['Authorization' => 'Bearer '.$token];

    $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'odds broadcast'], $auth);
    $create->assertOk();
    $draftId = (int) $create->json('data.id');

    $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
    $payload = acceptanceOddsPutPayloadFromDetail($detail);
    foreach ($payload as &$row) {
        if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
            $row['odds_value'] = 444_444;
        }
    }
    unset($row);

    $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
    $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();

    Event::assertDispatched(
        OddsUpdateBroadcast::class,
        fn (OddsUpdateBroadcast $event): bool => $event->versionId === $draftId,
    );
});
