package mysql

import (
	"context"
	"strings"
	"testing"
	"time"

	"hyapp/internal/testutil/mysqlschema"
	"hyapp/pkg/appcode"
	domain "hyapp/services/activity-service/internal/domain/luckygift"
)

func TestLuckyRuntimeConfigScalesTierRewardsByActualSpendButKeepsRiskCapsAbsolute(t *testing.T) {
	config := domain.Config{
		GiftID:              domain.DefaultPoolID,
		PoolID:              domain.DefaultPoolID,
		GiftPrice:           500,
		MaxSinglePayout:     250_000,
		UserHourlyPayoutCap: 500_000,
		Tiers: []domain.Tier{
			{Pool: domain.PoolAdvanced, TierID: "adv_large_100x", RewardCoins: 50_000, Weight: 1, Enabled: true},
			{Pool: domain.PoolAdvanced, TierID: "none", RewardCoins: 0, Weight: 1, Enabled: true},
		},
	}

	runtimeConfig := luckyRuntimeConfig(config, 1_000)

	if runtimeConfig.GiftPrice != 1_000 {
		t.Fatalf("expected runtime gift price to follow actual spend, got %d", runtimeConfig.GiftPrice)
	}
	if runtimeConfig.Tiers[0].RewardCoins != 100_000 {
		t.Fatalf("expected tier reward to scale by spend, got %d", runtimeConfig.Tiers[0].RewardCoins)
	}
	if runtimeConfig.MaxSinglePayout != 250_000 || runtimeConfig.UserHourlyPayoutCap != 500_000 {
		t.Fatalf("expected risk caps to stay absolute, got single=%d hourly=%d", runtimeConfig.MaxSinglePayout, runtimeConfig.UserHourlyPayoutCap)
	}
	if config.Tiers[0].RewardCoins != 50_000 {
		t.Fatalf("runtime scaling mutated source config")
	}
}

func TestClaimPendingLuckyGiftOutboxPrioritizesRewardSettlement(t *testing.T) {
	schema := mysqlschema.New(t, mysqlschema.Config{
		EnvVar:         "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
		InitDBPath:     mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
		DatabasePrefix: "hyapp_activity_lucky_outbox_test",
	})
	repository, err := Open(context.Background(), schema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	ctx := appcode.WithContext(context.Background(), "lalu")
	nowMS := int64(1_700_000_000_000)
	_, err = repository.db.ExecContext(ctx, `
		INSERT INTO activity_outbox (
			app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
			locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
		) VALUES
			('lalu', 'lucky_legacy_draw_1', ?, '{}', 'pending', 0, ?, '', 0, '', ?, ?),
			('lalu', 'lucky_reward_draw_1', ?, '{}', 'pending', 0, ?, '', 0, '', ?, ?)`,
		domain.EventTypeLuckyGiftDrawn, nowMS-100, nowMS-200, nowMS-200,
		domain.EventTypeLuckyGiftRewardSettlement, nowMS-100, nowMS-100, nowMS-100,
	)
	if err != nil {
		t.Fatalf("seed activity_outbox: %v", err)
	}

	events, err := repository.ClaimPendingLuckyGiftOutbox(ctx, "worker-priority", nowMS, 30*time.Second, 10)
	if err != nil {
		t.Fatalf("ClaimPendingLuckyGiftOutbox failed: %v", err)
	}
	if len(events) != 1 || events[0].EventType != domain.EventTypeLuckyGiftRewardSettlement || events[0].OutboxID != "lucky_reward_draw_1" {
		t.Fatalf("claim should only return reward settlement first, got %+v", events)
	}
	var legacyStatus string
	if err := repository.db.QueryRowContext(ctx, `
		SELECT status FROM activity_outbox WHERE app_code = 'lalu' AND outbox_id = 'lucky_legacy_draw_1'`).Scan(&legacyStatus); err != nil {
		t.Fatalf("read legacy outbox status: %v", err)
	}
	if legacyStatus != "pending" {
		t.Fatalf("legacy outbox should remain pending while reward settlement exists, got %s", legacyStatus)
	}
}

func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T) {
	schema := mysqlschema.New(t, mysqlschema.Config{
		EnvVar:         "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
		InitDBPath:     mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
		DatabasePrefix: "hyapp_activity_lucky_stats_test",
	})
	repository, err := Open(context.Background(), schema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	ctx := appcode.WithContext(context.Background(), "lalu")
	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_stat_cursors (
			app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
		) VALUES ('lalu', 'draw_records', 0, '', 1, 1)`); err != nil {
		t.Fatalf("seed stats cursor: %v", err)
	}
	seedLuckyDrawRecordForStats(t, repository, "draw_001", "cmd_001", domain.StatusGranted, 100, 100, 300)
	seedLuckyDrawRecordForStats(t, repository, "draw_002", "cmd_002", domain.StatusPending, 200, 150, 0)

	processed, err := repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 300, 1)
	if err != nil {
		t.Fatalf("first refresh failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("first refresh should process one row, got %d", processed)
	}
	assertLuckyStatsTotal(t, repository, "", 1, 100, 300)
	assertLuckyStatsStatus(t, repository, "", 0, 1, 0)
	assertLuckyStatsCursor(t, repository, 100, "draw_001")
	processed, err = repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, 310, 1)
	if err != nil {
		t.Fatalf("first databi refresh failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("first databi refresh should process one row, got %d", processed)
	}
	assertLuckyDatabiDayStatsTotal(t, repository, "1970-01-01", "", 1, 100, 300)

	processed, err = repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 400, 10)
	if err != nil {
		t.Fatalf("second refresh failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("second refresh should process remaining row, got %d", processed)
	}
	assertLuckyStatsTotal(t, repository, "", 2, 250, 300)
	assertLuckyStatsStatus(t, repository, "", 1, 1, 0)
	assertLuckyStatsCursor(t, repository, 200, "draw_002")
	processed, err = repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, 410, 10)
	if err != nil {
		t.Fatalf("second databi refresh failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("second databi refresh should process remaining row, got %d", processed)
	}
	assertLuckyDatabiDayStatsTotal(t, repository, "1970-01-01", "", 2, 250, 300)
}

func seedLuckyDrawRecordForStats(t *testing.T, repository *Repository, drawID string, commandID string, status string, createdAtMS int64, spent int64, reward int64) {
	t.Helper()
	_, err := repository.db.ExecContext(context.Background(), `
		INSERT INTO lucky_draw_records (
			app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id,
			coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
			selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
			effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
			candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
			reward_transaction_id, reward_failure_reason, paid_at_ms, created_at_ms, updated_at_ms
		) VALUES (
			'lalu', ?, ?, 42, 'device-1', 'room-1', 'anchor-1', 'lucky', 'rose',
			?, 1, 'novice', 1, 1,
			'tier', ?, 0, 0,
			?, '{}', 0, 0,
			'{}', '{}', '{}', ?,
			'', '', ?, ?, ?
		)`, drawID, commandID, spent, reward, reward, status, createdAtMS, createdAtMS, createdAtMS)
	if err != nil {
		t.Fatalf("seed draw record %s: %v", drawID, err)
	}
}

func assertLuckyStatsTotal(t *testing.T, repository *Repository, giftID string, wantDraws int64, wantSpent int64, wantReward int64) {
	t.Helper()
	var draws, spent, reward int64
	if err := repository.db.QueryRowContext(context.Background(), `
		SELECT total_draws, total_spent_coins, total_reward_coins
		FROM lucky_draw_pool_stats
		WHERE app_code = 'lalu' AND pool_id = 'lucky' AND gift_id = ?`, giftID,
	).Scan(&draws, &spent, &reward); err != nil {
		t.Fatalf("read stats total: %v", err)
	}
	if draws != wantDraws || spent != wantSpent || reward != wantReward {
		t.Fatalf("stats mismatch: draws=%d spent=%d reward=%d want draws=%d spent=%d reward=%d", draws, spent, reward, wantDraws, wantSpent, wantReward)
	}
}

func assertLuckyStatsStatus(t *testing.T, repository *Repository, giftID string, wantPending int64, wantGranted int64, wantFailed int64) {
	t.Helper()
	var pending, granted, failed int64
	if err := repository.db.QueryRowContext(context.Background(), `
		SELECT pending_draws, granted_draws, failed_draws
		FROM lucky_draw_pool_stats
		WHERE app_code = 'lalu' AND pool_id = 'lucky' AND gift_id = ?`, giftID,
	).Scan(&pending, &granted, &failed); err != nil {
		t.Fatalf("read stats status: %v", err)
	}
	if pending != wantPending || granted != wantGranted || failed != wantFailed {
		t.Fatalf("stats status mismatch: pending=%d granted=%d failed=%d want pending=%d granted=%d failed=%d", pending, granted, failed, wantPending, wantGranted, wantFailed)
	}
}

func assertLuckyDatabiDayStatsTotal(t *testing.T, repository *Repository, day string, giftID string, wantDraws int64, wantSpent int64, wantReward int64) {
	t.Helper()
	var draws, spent, reward int64
	if err := repository.db.QueryRowContext(context.Background(), `
		SELECT draw_count, turnover_coin, payout_coin
		FROM lucky_draw_pool_day_stats
		WHERE app_code = 'lalu' AND stat_day = ? AND visible_region_id = 0 AND pool_id = 'lucky' AND gift_id = ?`, day, giftID,
	).Scan(&draws, &spent, &reward); err != nil {
		t.Fatalf("read databi day stats total: %v", err)
	}
	if draws != wantDraws || spent != wantSpent || reward != wantReward {
		t.Fatalf("databi day stats mismatch: draws=%d spent=%d reward=%d want draws=%d spent=%d reward=%d", draws, spent, reward, wantDraws, wantSpent, wantReward)
	}
}

func assertLuckyStatsCursor(t *testing.T, repository *Repository, wantCreatedAtMS int64, wantDrawID string) {
	t.Helper()
	var createdAtMS int64
	var drawID string
	if err := repository.db.QueryRowContext(context.Background(), `
		SELECT last_draw_created_at_ms, last_draw_id
		FROM lucky_draw_pool_stat_cursors
		WHERE app_code = 'lalu' AND cursor_name = 'draw_records'`,
	).Scan(&createdAtMS, &drawID); err != nil {
		t.Fatalf("read stats cursor: %v", err)
	}
	if createdAtMS != wantCreatedAtMS || drawID != wantDrawID {
		t.Fatalf("cursor mismatch: created_at=%d draw_id=%s want created_at=%d draw_id=%s", createdAtMS, drawID, wantCreatedAtMS, wantDrawID)
	}
}

func TestLuckyRuntimeConfigFromRuleConfigUsesV2PoolAndStageTiers(t *testing.T) {
	ruleConfig := domain.RuleConfig{
		AppCode:               "lalu",
		PoolID:                "lucky",
		RuleVersion:           7,
		Enabled:               true,
		TargetRTPPPM:          950_000,
		PoolRatePPM:           960_000,
		SettlementWindowWager: 1_000_001,
		GiftPriceReference:    100,
		MaxSinglePayout:       9_999,
		UserHourlyPayoutCap:   10_001,
		UserDailyPayoutCap:    10_002,
		DeviceDailyPayoutCap:  10_003,
		RoomHourlyPayoutCap:   10_004,
		AnchorDailyPayoutCap:  10_005,
		CreatedByAdminID:      42,
		CreatedAtMS:           123456,
		Stages: []domain.RuleStage{
			{Stage: domain.StageNovice, Tiers: []domain.RuleTier{
				{Stage: domain.StageNovice, TierID: "novice_none", MultiplierPPM: 0, BaseWeightPPM: 300_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
				{Stage: domain.StageNovice, TierID: "novice_0_3x", MultiplierPPM: 300_000, BaseWeightPPM: 220_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
			}},
			{Stage: domain.StageNormal, Tiers: []domain.RuleTier{
				{Stage: domain.StageNormal, TierID: "normal_2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 100_000, RewardSource: domain.SourceBaseRTP, Enabled: true},
			}},
			{Stage: domain.StageAdvanced, Tiers: []domain.RuleTier{
				{Stage: domain.StageAdvanced, TierID: "advanced_5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 50_000, RewardSource: domain.SourceBaseRTP, HighWaterOnly: true, Enabled: true},
			}},
		},
	}

	config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
	if err != nil {
		t.Fatalf("convert v2 rule config failed: %v", err)
	}
	if config.GiftID != "lucky" || config.PoolID != "lucky" || config.RuleVersion != 7 {
		t.Fatalf("runtime identity should come from pool version, got pool=%s gift=%s version=%d", config.PoolID, config.GiftID, config.RuleVersion)
	}
	if config.GlobalWindowDraws != 10_001 || config.GiftWindowDraws != 10_001 {
		t.Fatalf("expected wager window to be converted to equivalent draw window, got global=%d gift=%d", config.GlobalWindowDraws, config.GiftWindowDraws)
	}
	if config.UserHourlyPayoutCap != 10_001 || config.RoomHourlyPayoutCap != 10_004 {
		t.Fatalf("expected runtime risk caps to come from v2 config, got user_hour=%d room_hour=%d", config.UserHourlyPayoutCap, config.RoomHourlyPayoutCap)
	}
	expected := map[string]struct {
		pool          string
		rewardCoins   int64
		weight        int64
		highWaterOnly bool
	}{
		"novice_0_3x": {pool: domain.PoolNovice, rewardCoins: 30, weight: 220_000},
		"normal_2x":   {pool: domain.PoolIntermediate, rewardCoins: 200, weight: 100_000},
		"advanced_5x": {pool: domain.PoolAdvanced, rewardCoins: 500, weight: 50_000, highWaterOnly: true},
	}
	for _, tier := range config.Tiers {
		want, exists := expected[tier.TierID]
		if !exists {
			continue
		}
		if tier.Pool != want.pool || tier.RewardCoins != want.rewardCoins || tier.Weight != want.weight || tier.HighWaterOnly != want.highWaterOnly {
			t.Fatalf("tier %s mismatch: got %#v want %#v", tier.TierID, tier, want)
		}
		delete(expected, tier.TierID)
	}
	if len(expected) != 0 {
		t.Fatalf("missing converted v2 tiers: %#v", expected)
	}
}

func TestLuckyExperiencePoolUsesEquivalentDrawsFromCumulativeWager(t *testing.T) {
	config := domain.Config{
		NoviceMaxEquivalentDraws: 2_000,
		NormalMaxEquivalentDraws: 20_000,
	}

	cases := []struct {
		name            string
		cumulativeWager int64
		referencePrice  int64
		wantDraws       int64
		wantPool        string
	}{
		{name: "small gift advances fractionally by wager", cumulativeWager: 199_900, referencePrice: 100, wantDraws: 1_999, wantPool: domain.PoolNovice},
		{name: "novice boundary includes exact threshold", cumulativeWager: 200_000, referencePrice: 100, wantDraws: 2_000, wantPool: domain.PoolNovice},
		{name: "large gift exits novice by value not send count", cumulativeWager: 200_100, referencePrice: 100, wantDraws: 2_001, wantPool: domain.PoolIntermediate},
		{name: "advanced starts after normal threshold", cumulativeWager: 2_000_100, referencePrice: 100, wantDraws: 20_001, wantPool: domain.PoolAdvanced},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			draws := luckyEquivalentDraws(tt.cumulativeWager, tt.referencePrice)
			if draws != tt.wantDraws {
				t.Fatalf("equivalent draws = %d, want %d", draws, tt.wantDraws)
			}
			if pool := luckyExperiencePool(draws, config); pool != tt.wantPool {
				t.Fatalf("experience pool = %s, want %s", pool, tt.wantPool)
			}
		})
	}
}

func TestLuckyDrawUnitSpendSplitsBatchSpendByGiftCount(t *testing.T) {
	var total int64
	for index := int32(1); index <= 10; index++ {
		spend := luckyDrawUnitSpend(1_001, 10, index)
		total += spend
		if index == 1 && spend != 101 {
			t.Fatalf("expected remainder coin to be assigned to first draw, got %d", spend)
		}
		if index > 1 && spend != 100 {
			t.Fatalf("expected normal unit spend to be 100, got index=%d spend=%d", index, spend)
		}
	}
	if total != 1_001 {
		t.Fatalf("unit spends must preserve total spend, got %d", total)
	}
	if spend := luckyDrawUnitSpend(1_000, 10, 7); spend != 100 {
		t.Fatalf("expected 1000 coins / 10 draws to use 100 per draw, got %d", spend)
	}
}

func TestLuckyDrawSubCommandIDIsStableAndBounded(t *testing.T) {
	if got := luckyDrawSubCommandID("gift_cmd", 3, 10); got != "gift_cmd#000003" {
		t.Fatalf("short sub command id mismatch: %s", got)
	}
	longCommand := strings.Repeat("x", 160)
	first := luckyDrawSubCommandID(longCommand, 12, 20)
	second := luckyDrawSubCommandID(longCommand, 12, 20)
	if first != second {
		t.Fatalf("sub command id must be stable, got %s and %s", first, second)
	}
	if len(first) > 128 || !strings.HasSuffix(first, "#000012") {
		t.Fatalf("sub command id must fit db column and keep draw index, got len=%d id=%s", len(first), first)
	}
}

func TestLuckyAggregateDrawResultsSummarizesBatchReward(t *testing.T) {
	cmd := domain.DrawCommand{CommandID: "gift_cmd", CoinSpent: 1_000}
	results := []domain.DrawResult{
		{DrawID: "draw-1", CommandID: "gift_cmd#000001", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "none", BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusGranted},
		{DrawID: "draw-2", CommandID: "gift_cmd#000002", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "five", BaseRewardCoins: 500, EffectiveRewardCoins: 500, MultiplierPPM: 5_000_000, BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusPending, StageFeedback: true, HighMultiplier: true, CreatedAtMS: 123},
		{DrawID: "draw-3", CommandID: "gift_cmd#000003", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "two", BaseRewardCoins: 200, EffectiveRewardCoins: 200, MultiplierPPM: 2_000_000, BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusPending, CreatedAtMS: 123},
	}
	for index := 0; index < 4; index++ {
		results = append(results, domain.DrawResult{
			DrawID:               "draw-one",
			CommandID:            "gift_cmd#00000x",
			PoolID:               "lucky",
			GiftID:               "gift-1",
			RuleVersion:          7,
			ExperiencePool:       domain.PoolNovice,
			SelectedTierID:       "one",
			BaseRewardCoins:      100,
			EffectiveRewardCoins: 100,
			MultiplierPPM:        1_000_000,
			BudgetSourcesJSON:    `{"sources":["base_rtp"]}`,
			RewardStatus:         domain.StatusPending,
			CreatedAtMS:          123,
		})
	}
	for len(results) < 10 {
		results = append(results, domain.DrawResult{DrawID: "draw-none", CommandID: "gift_cmd#00000n", PoolID: "lucky", GiftID: "gift-1", RuleVersion: 7, ExperiencePool: domain.PoolNovice, SelectedTierID: "none", BudgetSourcesJSON: `{"sources":["base_rtp"]}`, RewardStatus: domain.StatusGranted})
	}

	aggregate := luckyAggregateDrawResults(cmd, results)

	if aggregate.CommandID != "gift_cmd" || aggregate.SelectedTierID != "batch" {
		t.Fatalf("aggregate identity mismatch: %#v", aggregate)
	}
	if aggregate.EffectiveRewardCoins != 1_100 || aggregate.BaseRewardCoins != 1_100 || aggregate.MultiplierPPM != 11_000_000 {
		t.Fatalf("aggregate reward mismatch: reward=%d base=%d multiplier=%d", aggregate.EffectiveRewardCoins, aggregate.BaseRewardCoins, aggregate.MultiplierPPM)
	}
	if aggregate.RewardStatus != domain.StatusPending || !aggregate.StageFeedback || !aggregate.HighMultiplier {
		t.Fatalf("aggregate flags mismatch: %#v", aggregate)
	}
	if !strings.Contains(aggregate.BudgetSourcesJSON, `"draw_count":10`) || !strings.Contains(aggregate.BudgetSourcesJSON, domain.SourceBaseRTP) {
		t.Fatalf("aggregate budget json mismatch: %s", aggregate.BudgetSourcesJSON)
	}
}

func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) {
	window := luckyRTPWindow{
		ControlDraws:      100,
		TargetRTPPPM:      950_000,
		WagerCoins:        500,
		CarryPPM:          250_000,
		PaidDraws:         1,
		TargetPayoutCoins: 475,
	}

	next := window.withCurrentWager(1_000)

	if next.WagerCoins != 1_500 {
		t.Fatalf("expected wager to include current spend, got %d", next.WagerCoins)
	}
	if next.TargetPayoutCoins != 1_425 {
		t.Fatalf("expected target payout to include current spend, got %d", next.TargetPayoutCoins)
	}
	if next.CarryPPM != 250_000 {
		t.Fatalf("expected carry to keep fractional target, got %d", next.CarryPPM)
	}
}

func TestLuckyConfiguredWeightCandidatesUsesConfiguredWeightsWithoutForcedSettlement(t *testing.T) {
	candidates := []luckyCandidate{
		{TierID: "none", BaseReward: 0, Weight: 300_000},
		{TierID: "one", BaseReward: 100, Weight: 600_000},
		{TierID: "two", BaseReward: 200, Weight: 100_000},
	}

	weighted := luckyConfiguredWeightCandidates(candidates)

	if len(weighted) != 3 {
		t.Fatalf("expected configured candidates inside control band, got %#v", weighted)
	}
	if weighted[0].TierID != "none" || weighted[0].Weight != 300_000 || weighted[2].TierID != "two" || weighted[2].Weight != 100_000 {
		t.Fatalf("expected configured weights to be preserved, got %#v", weighted)
	}
}

func TestSelectLuckyCandidateDoesNotInjectNoneTier(t *testing.T) {
	config := domain.Config{
		GiftID:                domain.DefaultPoolID,
		PoolID:                domain.DefaultPoolID,
		GiftPrice:             10,
		TargetRTPPPM:          950_000,
		HighMultiplier:        100,
		LargeTierEnabled:      true,
		PlatformPoolWeightPPM: 200_000,
		RoomPoolWeightPPM:     300_000,
		GiftPoolWeightPPM:     500_000,
		MaxSinglePayout:       10_000,
		UserHourlyPayoutCap:   10_000,
		UserDailyPayoutCap:    10_000,
		DeviceDailyPayoutCap:  10_000,
		RoomHourlyPayoutCap:   10_000,
		AnchorDailyPayoutCap:  10_000,
		Tiers: []domain.Tier{
			{Pool: domain.PoolAdvanced, TierID: "advanced_1x", RewardCoins: 10, MultiplierPPM: 1_000_000, Enabled: true},
			{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Enabled: true},
		},
	}
	pool := luckyPool{Balance: 1_000_000}
	counters := map[string]luckyCounter{
		luckyCounterKey("user", "hour"):  {},
		luckyCounterKey("user", "day"):   {},
		luckyCounterKey("device", "day"): {},
		luckyCounterKey("room", "hour"):  {},
		luckyCounterKey("anchor", "day"): {},
	}

	candidate, _, err := (&Repository{}).selectLuckyCandidate(
		config,
		domain.PoolAdvanced,
		pool,
		pool,
		pool,
		counters,
	)
	if err != nil {
		t.Fatalf("select positive-only candidate failed: %v", err)
	}
	if candidate.BaseReward <= 0 || candidate.MultiplierPPM <= 0 {
		t.Fatalf("positive-only config should not draw implicit none tier: %#v", candidate)
	}
}

func TestSelectLuckyCandidateKeepsSmallGiftEligibleAfterLargeWagerWhenAbsoluteCapAllows(t *testing.T) {
	config := domain.Config{
		GiftID:                domain.DefaultPoolID,
		PoolID:                domain.DefaultPoolID,
		GiftPrice:             10,
		TargetRTPPPM:          950_000,
		HighMultiplier:        100,
		LargeTierEnabled:      true,
		PlatformPoolWeightPPM: 200_000,
		RoomPoolWeightPPM:     300_000,
		GiftPoolWeightPPM:     500_000,
		MaxSinglePayout:       1_000_000,
		UserHourlyPayoutCap:   1_000_000,
		UserDailyPayoutCap:    1_000_000,
		DeviceDailyPayoutCap:  1_000_000,
		RoomHourlyPayoutCap:   1_000_000,
		AnchorDailyPayoutCap:  1_000_000,
		Tiers: []domain.Tier{
			{Pool: domain.PoolAdvanced, TierID: "advanced_none", RewardCoins: 0, MultiplierPPM: 0, Weight: 500_000, Enabled: true},
			{Pool: domain.PoolAdvanced, TierID: "advanced_1x", RewardCoins: 10, MultiplierPPM: 1_000_000, Weight: 300_000, Enabled: true},
			{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Weight: 200_000, Enabled: true},
		},
	}
	pool := luckyPool{Balance: 1_000_000}
	counters := map[string]luckyCounter{
		luckyCounterKey("user", "hour"):  {Payout: 999_970},
		luckyCounterKey("user", "day"):   {Payout: 999_970},
		luckyCounterKey("device", "day"): {Payout: 999_970},
		luckyCounterKey("room", "hour"):  {Payout: 999_970},
		luckyCounterKey("anchor", "day"): {Payout: 999_970},
	}

	seenPositive := false
	for i := 0; i < 100; i++ {
		candidate, limited, err := (&Repository{}).selectLuckyCandidate(config, domain.PoolAdvanced, pool, pool, pool, counters)
		if err != nil {
			t.Fatalf("select small gift candidate failed: %v", err)
		}
		if limited["risk_cap"] {
			t.Fatalf("small payable tiers should not be risk-filtered when absolute cap still has room")
		}
		if candidate.BaseReward > 0 {
			seenPositive = true
			break
		}
	}
	if !seenPositive {
		t.Fatalf("expected positive small-gift tier to remain in random candidate set")
	}
}

func TestSelectLuckyCandidateFallsBackToZeroWhenAllPositiveTiersAreUnpayable(t *testing.T) {
	config := domain.Config{
		GiftID:                domain.DefaultPoolID,
		PoolID:                domain.DefaultPoolID,
		GiftPrice:             10,
		HighMultiplier:        100,
		LargeTierEnabled:      true,
		PlatformPoolWeightPPM: 200_000,
		RoomPoolWeightPPM:     300_000,
		GiftPoolWeightPPM:     500_000,
		MaxSinglePayout:       10,
		UserHourlyPayoutCap:   10,
		UserDailyPayoutCap:    10,
		DeviceDailyPayoutCap:  10,
		RoomHourlyPayoutCap:   10,
		AnchorDailyPayoutCap:  10,
		Tiers: []domain.Tier{
			{Pool: domain.PoolAdvanced, TierID: "advanced_2x", RewardCoins: 20, MultiplierPPM: 2_000_000, Weight: 1_000_000, Enabled: true},
		},
	}
	pool := luckyPool{Balance: 1_000_000}
	counters := map[string]luckyCounter{
		luckyCounterKey("user", "hour"):  {},
		luckyCounterKey("user", "day"):   {},
		luckyCounterKey("device", "day"): {},
		luckyCounterKey("room", "hour"):  {},
		luckyCounterKey("anchor", "day"): {},
	}

	candidate, limited, err := (&Repository{}).selectLuckyCandidate(config, domain.PoolAdvanced, pool, pool, pool, counters)
	if err != nil {
		t.Fatalf("select unpayable candidate failed: %v", err)
	}
	if candidate.TierID != "no_payable_tier" || candidate.effectiveReward() != 0 {
		t.Fatalf("expected no-payable fallback, got %#v", candidate)
	}
	if !limited["risk_cap"] {
		t.Fatalf("expected risk_cap to explain filtered positive tier")
	}
}

func TestLuckyInitialRewardStatusGrantsZeroRewardWithoutOutbox(t *testing.T) {
	if status := luckyInitialRewardStatus(luckyCandidate{TierID: "none"}, false); status != domain.StatusGranted {
		t.Fatalf("zero reward without presentation should be granted, got %s", status)
	}
	if luckyDrawNeedsRewardSettlementOutbox(luckyCandidate{TierID: "none"}) {
		t.Fatalf("zero reward without presentation must not emit outbox")
	}
}

func TestLuckyInitialRewardStatusOnlyWaitsForWalletSettlement(t *testing.T) {
	tests := []struct {
		name          string
		candidate     luckyCandidate
		stageFeedback bool
		wantStatus    string
		wantReward    bool
	}{
		{name: "wallet credit", candidate: luckyCandidate{TierID: "hit", BaseReward: 10}, wantStatus: domain.StatusPending, wantReward: true},
		{name: "presentation candidate", candidate: luckyCandidate{TierID: "stage", Presentation: true}, wantStatus: domain.StatusGranted},
		{name: "stage feedback", candidate: luckyCandidate{TierID: "none"}, stageFeedback: true, wantStatus: domain.StatusGranted},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if status := luckyInitialRewardStatus(test.candidate, test.stageFeedback); status != test.wantStatus {
				t.Fatalf("reward status mismatch: got %s want %s", status, test.wantStatus)
			}
			if got := luckyDrawNeedsRewardSettlementOutbox(test.candidate); got != test.wantReward {
				t.Fatalf("reward settlement outbox mismatch: got %v want %v", got, test.wantReward)
			}
		})
	}
}
