package mysql

import (
	"context"
	"runtime"
	"testing"
	"time"

	"hyapp/internal/testutil/mysqlschema"
)

func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_query_test",
	})
	activitySchema := mysqlschema.New(t, mysqlschema.Config{
		EnvVar:         "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "..", "activity-service", "deploy", "mysql", "initdb", "001_activity_service.sql"),
		DatabasePrefix: "hy_activity_query_test",
	})
	repository, err := Open(ctx, statsSchema.DSN, activitySchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_day, country_id, lucky_gift_turnover, lucky_gift_payout, updated_at_ms
		) VALUES ('lalu', '2026-06-06', 210, 100, 900, 1)`); err != nil {
		t.Fatalf("seed stale statistics overview: %v", err)
	}
	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_lucky_gift_pool_day_country (
			app_code, stat_day, country_id, pool_id, turnover_coin, payout_coin, updated_at_ms
		) VALUES ('lalu', '2026-06-06', 210, 'lucky', 100, 900, 1)`); err != nil {
		t.Fatalf("seed stale statistics pool: %v", err)
	}
	if _, err := repository.activityDB.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_day_stats (
			app_code, stat_day, visible_region_id, pool_id, gift_id, draw_count, turnover_coin, payout_coin,
			base_reward_coin, room_atmosphere_reward_coin, activity_subsidy_coin, created_at_ms, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-06', 210, 'super_lucky', '', 10, 2744832, 2401885, 2401885, 0, 0, 1, 1),
		  ('lalu', '2026-06-06', 210, 'lucky', '', 5, 148322, 126415, 126415, 0, 0, 1, 1)`); err != nil {
		t.Fatalf("seed activity databi stats: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.LuckyGiftTurnover != 2_893_154 || overview.LuckyGiftPayout != 2_528_300 || overview.LuckyGiftProfit != 364_854 {
		t.Fatalf("activity lucky gift overview not applied: %+v", overview)
	}
	if len(overview.LuckyGiftPools) != 2 {
		t.Fatalf("activity lucky gift pools not applied: %+v", overview.LuckyGiftPools)
	}
	if overview.LuckyGiftPools[0].PoolID != "super_lucky" || overview.LuckyGiftPools[0].TurnoverCoin != 2_744_832 || overview.LuckyGiftPools[0].PayoutCoin != 2_401_885 {
		t.Fatalf("super_lucky pool mismatch: %+v", overview.LuckyGiftPools[0])
	}
}

func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_registration_idempotent_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	event := UserRegisteredEvent{
		AppCode:      "lalu",
		EventID:      "user_registered:327300172394536960",
		UserID:       327300172394536960,
		CountryID:    15,
		RegionID:     10,
		OccurredAtMS: time.Date(2026, 6, 22, 1, 2, 3, 0, time.UTC).UnixMilli(),
	}
	if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
		t.Fatalf("consume registration first time: %v", err)
	}
	// 生产环境会遇到 MQ 重投或历史补偿；注册 cohort 表去重后，国家日新增只能按真实新行累加。
	event.EventID = "user_registered:327300172394536960:replay"
	if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
		t.Fatalf("consume registration replay: %v", err)
	}

	var newUsers, activeUsers int64
	if err := repository.db.QueryRowContext(ctx, `
		SELECT new_users, active_users
		FROM stat_app_day_country
		WHERE app_code = 'lalu' AND stat_tz = 'UTC' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10
	`).Scan(&newUsers, &activeUsers); err != nil {
		t.Fatalf("query app day country: %v", err)
	}
	if newUsers != 1 {
		t.Fatalf("registration replay must not duplicate new users, got %d", newUsers)
	}
	if activeUsers != 1 {
		t.Fatalf("registration must count as one same-day active user, got %d", activeUsers)
	}

	var activeRows int64
	if err := repository.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM stat_user_day_activity
		WHERE app_code = 'lalu'
		  AND stat_tz = 'UTC'
		  AND stat_day = '2026-06-22'
		  AND country_id = 15
		  AND region_id = 10
		  AND user_id = 327300172394536960
	`).Scan(&activeRows); err != nil {
		t.Fatalf("query registration active row: %v", err)
	}
	if activeRows != 1 {
		t.Fatalf("registration replay must keep one user-day active row, got %d", activeRows)
	}
}

func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_report_metrics_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	occurredAt := time.Date(2026, 6, 22, 3, 4, 5, 0, time.UTC).UnixMilli()
	for _, event := range []UserRegisteredEvent{
		{AppCode: "lalu", EventID: "user_registered:report:1", UserID: 7001, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "user_registered:report:2", UserID: 7002, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
	} {
		if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
			t.Fatalf("consume user dimension: %v", err)
		}
	}
	for _, event := range []WalletBalanceChangedEvent{
		{AppCode: "lalu", EventID: "balance:coin:recharge", UserID: 7001, AssetType: "COIN", BizType: "google_play_recharge", AvailableDelta: 1000, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:coin:red-packet-create", UserID: 7001, AssetType: "COIN", BizType: "red_packet_create", AvailableDelta: -200, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:coin:red-packet-refund", UserID: 7001, AssetType: "COIN", BizType: "red_packet_refund", AvailableDelta: 50, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:coin:checkin-resource", UserID: 7001, AssetType: "COIN", BizType: "resource_grant", CommandID: "wcheckin_claim_1", AvailableDelta: 66, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:coin:manual", UserID: 7001, AssetType: "COIN", BizType: "manual_credit", AvailableDelta: 70, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:salary:host", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "host_salary_settlement", AvailableDelta: 123, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:salary:transfer-debit", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "salary_transfer_to_coin_seller", AvailableDelta: -23, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "balance:salary:coin-seller", UserID: 9001, DimensionUserID: 7001, AssetType: "COIN_SELLER_COIN", BizType: "salary_transfer_to_coin_seller", AvailableDelta: 444, OccurredAtMS: occurredAt},
	} {
		if err := repository.ConsumeWalletBalanceChanged(ctx, event); err != nil {
			t.Fatalf("consume wallet balance changed %s: %v", event.EventID, err)
		}
	}
	if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
		AppCode: "lalu", EventID: "grant:task:1", EventType: "WalletTaskRewardCredited", UserID: 7001, Amount: 33, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume platform grant: %v", err)
	}
	if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
		AppCode: "lalu", EventID: "grant:wheel:1", EventType: "WalletWheelRewardCredited", UserID: 7001, Amount: 40, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume wheel output: %v", err)
	}
	for _, event := range []UserMicDailyStatsUpdatedEvent{
		{AppCode: "lalu", EventID: "mic:7001:first", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 60_000, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "mic:7001:second", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "mic:7002:first", UserID: 7002, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
	} {
		if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, event); err != nil {
			t.Fatalf("consume mic stats %s: %v", event.EventID, err)
		}
	}

	start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond)})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.CoinTotal != 986 || overview.ConsumedCoin != 200 || overview.OutputCoin != 90 || overview.ConsumeOutputDelta != 110 || overview.ConsumeOutputRatio != 0.45 || overview.PlatformGrantCoin != 99 || overview.ManualGrantCoin != 70 || overview.SalaryUSDMinor != 100 || overview.SalaryTransferCoin != 444 {
		t.Fatalf("report wallet metrics mismatch: %+v", overview)
	}
	if overview.MicOnlineMS != 120_000 || overview.MicOnlineUsers != 2 || overview.AvgMicOnlineMS != 60_000 {
		t.Fatalf("mic metrics mismatch: %+v", overview)
	}
	if len(overview.CountryBreakdown) != 1 {
		t.Fatalf("country breakdown missing: %+v", overview.CountryBreakdown)
	}
	country := overview.CountryBreakdown[0]
	if country.CoinTotal != 986 || country.ConsumedCoin != 200 || country.OutputCoin != 90 || country.PlatformGrantCoin != 99 || country.SalaryTransferCoin != 444 || country.AvgMicOnlineMS != 60_000 {
		t.Fatalf("country report metrics mismatch: %+v", country)
	}
	if len(overview.DailySeries) != 1 || overview.DailySeries[0].OutputCoin != 90 || overview.DailySeries[0].SalaryTransferCoin != 444 || overview.DailySeries[0].AvgMicOnlineMS != 60_000 {
		t.Fatalf("daily report metrics mismatch: %+v", overview.DailySeries)
	}
}

func TestQueryOverviewUsesEndDayBalanceSnapshotsForRange(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_coin_snapshot_range_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_day, country_id, region_id, coin_total, salary_usd_minor, consumed_coin, output_coin, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-24', 15, 10, 100, 12, 7, 3, 1),
		  ('lalu', '2026-06-25', 15, 10, 250, 34, 11, 4, 1)`); err != nil {
		t.Fatalf("seed coin snapshots: %v", err)
	}

	start := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), CountryID: 15, RegionID: 10})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.CoinTotal != 250 || overview.SalaryUSDMinor != 34 || overview.ConsumedCoin != 18 || overview.OutputCoin != 7 {
		t.Fatalf("overview must use end-day balance snapshots and range flow sums: %+v", overview)
	}
	if len(overview.DailySeries) != 2 || overview.DailySeries[0].CoinTotal != 100 || overview.DailySeries[0].SalaryUSDMinor != 12 || overview.DailySeries[1].CoinTotal != 250 || overview.DailySeries[1].SalaryUSDMinor != 34 {
		t.Fatalf("daily series must keep each day's balance snapshots: %+v", overview.DailySeries)
	}
	if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0].CoinTotal != 250 || overview.CountryBreakdown[0].SalaryUSDMinor != 34 {
		t.Fatalf("country breakdown must use end-day balance snapshots: %+v", overview.CountryBreakdown)
	}
}

func TestQueryPlatformGrantCoinDrilldown(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_platform_grant_detail_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	occurredAt := time.Date(2026, 6, 22, 3, 4, 5, 0, time.UTC).UnixMilli()
	for _, event := range []UserRegisteredEvent{
		{AppCode: "lalu", EventID: "user_registered:grant_detail:7001", UserID: 7001, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
		{AppCode: "lalu", EventID: "user_registered:grant_detail:7002", UserID: 7002, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
	} {
		if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
			t.Fatalf("consume user dimension %d: %v", event.UserID, err)
		}
	}
	if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
		AppCode: "lalu", EventID: "grant:task:7001", EventType: "WalletTaskRewardCredited", UserID: 7001, Amount: 33, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume task grant: %v", err)
	}
	if err := repository.ConsumeWalletBalanceChanged(ctx, WalletBalanceChangedEvent{
		AppCode: "lalu", EventID: "grant:resource:7001", EventType: "WalletBalanceChanged", UserID: 7001, AssetType: "COIN", BizType: "resource_grant", CommandID: "weekly_star:2026-06-22", AvailableDelta: 7, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume resource grant: %v", err)
	}
	if err := repository.ConsumeLuckyGiftReward(ctx, LuckyGiftRewardEvent{
		AppCode: "lalu", EventID: "grant:lucky:7001", UserID: 7001, CountryID: 15, RegionID: 10, Amount: 22, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume lucky grant: %v", err)
	}
	if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
		AppCode: "lalu", EventID: "grant:task:7002", EventType: "WalletTaskRewardCredited", UserID: 7002, Amount: 12, OccurredAtMS: occurredAt,
	}); err != nil {
		t.Fatalf("consume second task grant: %v", err)
	}

	users, err := repository.QueryPlatformGrantCoinUsers(ctx, PlatformGrantCoinQuery{
		AppCode:   "lalu",
		StatDay:   "2026-06-22",
		CountryID: 15,
		RegionID:  10,
		Page:      1,
		PageSize:  1,
	})
	if err != nil {
		t.Fatalf("query grant users: %v", err)
	}
	if users.Total != 2 || users.Page != 1 || users.PageSize != 1 || len(users.Items) != 1 {
		t.Fatalf("grant user page mismatch: %+v", users)
	}
	if got := users.Items[0]; got.UserID != 7001 || got.TotalCoin != 40 || got.RecordCount != 2 || got.CountryID != 15 || got.RegionID != 10 {
		t.Fatalf("first grant user mismatch: %+v", got)
	}

	records, err := repository.QueryPlatformGrantCoinRecords(ctx, PlatformGrantCoinQuery{
		AppCode:   "lalu",
		StatDay:   "2026-06-22",
		CountryID: 15,
		RegionID:  10,
		UserID:    7001,
		Page:      1,
		PageSize:  20,
	})
	if err != nil {
		t.Fatalf("query grant records: %v", err)
	}
	if records.Total != 2 || len(records.Items) != 2 {
		t.Fatalf("grant record page mismatch: %+v", records)
	}
	amountByType := map[string]int64{}
	for _, item := range records.Items {
		amountByType[item.EventType] += item.Amount
		if item.UserID != 7001 || item.CountryID != 15 || item.RegionID != 10 {
			t.Fatalf("grant record dimension mismatch: %+v", item)
		}
	}
	if amountByType["WalletTaskRewardCredited"] != 33 || amountByType["WalletBalanceChanged"] != 7 || amountByType["WalletLuckyGiftRewardCredited"] != 0 {
		t.Fatalf("grant record sources mismatch: %+v", amountByType)
	}
	taskRecords, err := repository.QueryPlatformGrantCoinRecords(ctx, PlatformGrantCoinQuery{
		AppCode:   "lalu",
		StatDay:   "2026-06-22",
		CountryID: 15,
		RegionID:  10,
		UserID:    7001,
		EventType: "WalletTaskRewardCredited",
		Page:      1,
		PageSize:  20,
	})
	if err != nil {
		t.Fatalf("query source filtered grant records: %v", err)
	}
	if taskRecords.Total != 1 || len(taskRecords.Items) != 1 || taskRecords.Items[0].EventType != "WalletTaskRewardCredited" || taskRecords.Items[0].Amount != 33 {
		t.Fatalf("source filtered grant records mismatch: %+v", taskRecords)
	}
}

func TestConsumeRechargeWritesUTCAndChinaStatDays(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_stat_tz_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli()
	if err := repository.ConsumeRecharge(ctx, RechargeEvent{
		AppCode:         "lalu",
		EventID:         "recharge:tz-boundary:1",
		EventType:       "WalletRechargeRecorded",
		UserID:          9001,
		TargetCountryID: 63,
		TargetRegionID:  210,
		USDMinor:        80_000,
		RechargeType:    "mifapay",
		OccurredAtMS:    occurredAt,
	}); err != nil {
		t.Fatalf("consume recharge: %v", err)
	}

	utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli()
	utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
	if err != nil {
		t.Fatalf("query utc overview: %v", err)
	}
	if utcOverview.StatTZ != StatTZUTC || utcOverview.RechargeUSDMinor != 80_000 || utcOverview.MifaPayRechargeUSDMinor != 80_000 {
		t.Fatalf("UTC stat day mismatch: %+v", utcOverview)
	}

	beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli()
	chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
	if err != nil {
		t.Fatalf("query china overview: %v", err)
	}
	if chinaOverview.StatTZ != StatTZAsiaShanghai || chinaOverview.RechargeUSDMinor != 80_000 || len(chinaOverview.DailySeries) != 1 || chinaOverview.DailySeries[0].StatDay != "2026-06-24" {
		t.Fatalf("China stat day mismatch: %+v", chinaOverview)
	}

	nextUTCStart := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC).UnixMilli()
	nextUTCOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: nextUTCStart, EndMS: nextUTCStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
	if err != nil {
		t.Fatalf("query next utc overview: %v", err)
	}
	if nextUTCOverview.RechargeUSDMinor != 0 {
		t.Fatalf("UTC 2026-06-24 must not include previous UTC day recharge: %+v", nextUTCOverview)
	}
}

func TestConsumeRechargeWithStatTZScopeDoesNotTouchUTC(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_stat_tz_scope_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli()
	if err := repository.ConsumeRecharge(WithStatTZScope(ctx, StatTZAsiaShanghai), RechargeEvent{
		AppCode:         "lalu",
		EventID:         "recharge:tz-scope:1",
		EventType:       "WalletRechargeRecorded",
		UserID:          9002,
		TargetCountryID: 63,
		TargetRegionID:  210,
		USDMinor:        90_000,
		RechargeType:    "mifapay",
		OccurredAtMS:    occurredAt,
	}); err != nil {
		t.Fatalf("consume scoped recharge: %v", err)
	}

	utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli()
	utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
	if err != nil {
		t.Fatalf("query utc overview: %v", err)
	}
	if utcOverview.RechargeUSDMinor != 0 {
		t.Fatalf("scoped replay must not mutate UTC rows: %+v", utcOverview)
	}

	beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli()
	chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63})
	if err != nil {
		t.Fatalf("query china overview: %v", err)
	}
	if chinaOverview.RechargeUSDMinor != 90_000 || chinaOverview.StatTZ != StatTZAsiaShanghai {
		t.Fatalf("scoped replay must write China stat_tz only: %+v", chinaOverview)
	}
}

func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_query_delta_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_day, country_id, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor,
			mifapay_recharge_usd_minor, gift_coin_spent, coin_seller_transfer_coin, lucky_gift_turnover, lucky_gift_payout,
			game_turnover, game_payout, game_refund, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-05', 210, 10, 2, 100, 50, 100, 300, 80, 100, 50, 500, 100, 50, 1),
		  ('lalu', '2026-06-06', 210, 20, 5, 200, 100, 200, 600, 200, 400, 100, 1000, 400, 100, 1)`); err != nil {
		t.Fatalf("seed delta statistics: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.RechargeDeltaRate != 1 || overview.NewUserRechargeDeltaRate != 1 || overview.ActiveUsersDeltaRate != 1 {
		t.Fatalf("top delta rates mismatch: %+v", overview)
	}
	if overview.PaidUsersDeltaRate != 1.5 || overview.ARPUDeltaRate != 0 || overview.ARPPUDeltaRate != -0.2 {
		t.Fatalf("user delta rates mismatch: %+v", overview)
	}
	if overview.GiftCoinSpentDeltaRate != 1 || overview.CoinSellerTransferDeltaRate != 1.5 || overview.LuckyGiftTurnoverDeltaRate != 3 || overview.LuckyGiftPayoutDeltaRate != 1 || overview.LuckyGiftProfitDeltaRate != 5 {
		t.Fatalf("coin delta rates mismatch: %+v", overview)
	}
	if overview.GameTurnoverDeltaRate != 1 || overview.GameProfitDeltaRate < 0.428 || overview.GameProfitDeltaRate > 0.429 {
		t.Fatalf("game delta rates mismatch: %+v", overview)
	}
}

func TestQueryOverviewReturnsRealRoomRobotGiftStats(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_query_robot_gift_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_real_room_robot_gift_day_rooms (
			app_code, stat_day, country_id, region_id, room_id,
			normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-06', 210, 8, 'room-a', 100, 200, 0, 1),
		  ('lalu', '2026-06-06', 210, 8, 'room-b', 0, 0, 300, 1),
		  ('lalu', '2026-06-06', 211, 9, 'room-c', 900, 0, 0, 1)`); err != nil {
		t.Fatalf("seed real room robot gifts: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), CountryID: 210, RegionID: 8})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.RealRoomRobotNormalGiftCoin != 100 || overview.RealRoomRobotLuckyGiftCoin != 200 || overview.RealRoomRobotSuperGiftCoin != 300 {
		t.Fatalf("robot gift breakdown mismatch: %+v", overview)
	}
	if overview.RealRoomRobotTotalGiftCoin != 600 || overview.RealRoomRobotGiftRoomCount != 2 || overview.RealRoomRobotAvgRoomGiftCoin != 300 {
		t.Fatalf("robot gift aggregate mismatch: %+v", overview)
	}
}

func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_query_country_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
			coin_seller_recharge_usd_minor, coin_seller_stock_coin, google_recharge_usd_minor, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 880000, 150, 1),
		  ('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 0, 100, 1),
		  ('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 0, 900, 1)`); err != nil {
		t.Fatalf("seed country statistics: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), RegionID: 210})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.RechargeUSDMinor != 650 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerStockCoin != 880000 {
		t.Fatalf("overview recharge mismatch: %+v", overview)
	}
	if len(overview.CountryBreakdown) != 2 {
		t.Fatalf("country breakdown should come from aggregate query: %+v", overview.CountryBreakdown)
	}
	if overview.CountryBreakdown[0].CountryID != 86 || overview.CountryBreakdown[0].RechargeUSDMinor != 550 || overview.CountryBreakdown[0].CoinSellerStockCoin != 880000 || overview.CountryBreakdown[0].ARPPUUSDMinor != 275 {
		t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
	}
}

func TestQueryOverviewReturnsDailyCountryBreakdownAndSuperLuckyGift(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_query_daily_country_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
			coin_seller_stock_coin, lucky_gift_turnover, lucky_gift_payout, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-05', 86, 210, 10, 2, 100, 7000, 900, 200, 1),
		  ('lalu', '2026-06-05', 66, 210, 5, 1, 80, 1000, 100, 20, 1),
		  ('lalu', '2026-06-06', 86, 210, 12, 3, 150, 9000, 1200, 300, 1)`); err != nil {
		t.Fatalf("seed app country stats: %v", err)
	}
	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_lucky_gift_pool_day_country (
			app_code, stat_day, country_id, region_id, pool_id, turnover_coin, payout_coin, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-05', 86, 210, 'super_lucky', 700, 120, 1),
		  ('lalu', '2026-06-05', 66, 210, 'lucky', 100, 20, 1),
		  ('lalu', '2026-06-06', 86, 210, 'super_lucky_v2', 900, 180, 1)`); err != nil {
		t.Fatalf("seed lucky gift pools: %v", err)
	}

	start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), RegionID: 210})
	if err != nil {
		t.Fatalf("query overview: %v", err)
	}
	if overview.SuperLuckyGiftTurnover != 1600 || overview.SuperLuckyGiftPayout != 300 || overview.SuperLuckyGiftProfit != 1300 {
		t.Fatalf("super lucky overview mismatch: %+v", overview)
	}
	if len(overview.DailyCountryBreakdown) != 3 {
		t.Fatalf("daily country breakdown length mismatch: %+v", overview.DailyCountryBreakdown)
	}
	first := overview.DailyCountryBreakdown[0]
	if first.StatDay != "2026-06-05" || first.CountryID != 86 || first.CoinSellerStockCoin != 7000 || first.SuperLuckyGiftTurnover != 700 || first.SuperLuckyGiftProfitRate <= 0 {
		t.Fatalf("first daily country row mismatch: %+v", first)
	}
	if len(overview.ReportMetricSources) == 0 {
		t.Fatalf("report metric sources should document available and missing report fields")
	}
}

func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_self_game_profit_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_self_game_match_day (
			app_code, stat_day, game_id, stake_coin, settled_matches,
			user_stake_coin, robot_stake_coin, payout_coin, refund_coin, platform_profit_coin, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-06', 'dice', 100, 1, 1000, 1000, 700, 100, 200, 1),
		  ('lalu', '2026-06-06', 'dice', 500, 1, 2000, 2000, 1500, 100, 400, 1)`); err != nil {
		t.Fatalf("seed self game match stats: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
	if err != nil {
		t.Fatalf("query self game overview: %v", err)
	}
	if overview.MatchTotals.PlatformProfitRate != 0.2 {
		t.Fatalf("platform profit rate should use real user stake only: %+v", overview.MatchTotals)
	}
	if len(overview.StakeBreakdown) != 2 || overview.StakeBreakdown[0].PlatformProfitRate != 0.2 || overview.StakeBreakdown[1].PlatformProfitRate != 0.2 {
		t.Fatalf("stake platform profit rate should use real user stake only: %+v", overview.StakeBreakdown)
	}
}

func TestQuerySelfGameOverviewKeepsParticipantWinRateInStakeBucket(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_self_game_participant_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_self_game_participant_day (
			app_code, stat_day, game_id, stake_coin, participant_type, result, dice_point, participant_count, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-06', 'dice', 100, 'user', 'win', 6, 1, 1),
		  ('lalu', '2026-06-06', 'dice', 100, 'user', 'lose', 6, 3, 1),
		  ('lalu', '2026-06-06', 'dice', 500, 'user', 'win', 6, 4, 1)`); err != nil {
		t.Fatalf("seed self game participant stats: %v", err)
	}

	start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"})
	if err != nil {
		t.Fatalf("query self game overview: %v", err)
	}
	rates := map[int64]float64{}
	for _, item := range overview.ParticipantDistribution {
		if item.Result == "win" {
			rates[item.StakeCoin] = item.WinRate
		}
	}
	if rates[100] != 0.25 || rates[500] != 1 {
		t.Fatalf("participant win rate should stay in stake bucket: %+v", overview.ParticipantDistribution)
	}
}

func TestQuerySelfGameOverviewUsesLatestPoolBalanceSnapshot(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	statsSchema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_self_game_pool_test",
	})
	repository, err := Open(ctx, statsSchema.DSN)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() { _ = repository.Close() })

	if _, err := repository.db.ExecContext(ctx, `
		INSERT INTO stat_self_game_pool_day (
			app_code, stat_day, game_id, direction, reason, transaction_count, in_coin, out_coin, balance_after_coin, updated_at_ms
		) VALUES
		  ('lalu', '2026-06-05', 'dice', 'out', 'dice_settlement', 1, 0, 100, 5000, 1),
		  ('lalu', '2026-06-06', 'dice', 'out', 'dice_settlement', 1, 0, 200, 4800, 2)`); err != nil {
		t.Fatalf("seed self game pool stats: %v", err)
	}

	start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), GameID: "dice"})
	if err != nil {
		t.Fatalf("query self game overview: %v", err)
	}
	if len(overview.PoolStats) != 1 || overview.PoolStats[0].OutCoin != 300 || overview.PoolStats[0].BalanceAfterCoin != 4800 {
		t.Fatalf("pool balance should be latest snapshot, not max balance: %+v", overview.PoolStats)
	}
}
