package app

import (
	"context"
	"encoding/json"
	"runtime"
	"strings"
	"testing"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/internal/testutil/mysqlschema"
	"hyapp/pkg/appcode"
	"hyapp/pkg/gamemq"
	"hyapp/pkg/roommq"
	"hyapp/pkg/walletmq"
	mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
)

func TestStatisticsWalletTagExpressionMatchesModeledEvents(t *testing.T) {
	eventTypes := statisticsWalletEventTypes()
	want := map[string]struct{}{
		walletmq.EventTypeWalletRechargeRecorded:             {},
		walletmq.EventTypeWalletCoinSellerStockPurchased:     {},
		walletmq.EventTypeWalletLuckyGiftRewardCredited:      {},
		walletmq.EventTypeWalletTaskRewardCredited:           {},
		walletmq.EventTypeWalletWheelRewardCredited:          {},
		walletmq.EventTypeWalletRoomTurnoverRewardCredited:   {},
		walletmq.EventTypeWalletInviteActivityRewardCredited: {},
		walletmq.EventTypeWalletAgencyOpeningRewardCredited:  {},
		walletmq.EventTypeWalletGiftIncomeCredited:           {},
		walletmq.EventTypeWalletBalanceChanged:               {},
	}
	if len(eventTypes) != len(want) {
		t.Fatalf("statistics wallet event count = %d, want %d: %v", len(eventTypes), len(want), eventTypes)
	}
	for _, eventType := range eventTypes {
		if _, ok := want[eventType]; !ok {
			t.Fatalf("statistics selector contains unmodeled event %q", eventType)
		}
		delete(want, eventType)
	}
	if len(want) != 0 {
		t.Fatalf("statistics selector misses modeled events: %v", want)
	}
	expression, err := walletmq.LegacyCompatibleTagExpression(eventTypes...)
	if err != nil {
		t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
	}
	if !strings.Contains(expression, walletmq.TagWalletOutboxEvent) {
		t.Fatalf("compatibility expression misses legacy tag: %q", expression)
	}
	if strings.Contains(expression, walletmq.EventTypeWalletGiftDebited) {
		t.Fatalf("statistics selector must not receive high-volume gift debit facts: %q", expression)
	}
}

func TestStatisticsRoomTagExpressionOnlyIncludesModeledEvents(t *testing.T) {
	expression, err := roommq.LegacyCompatibleTagExpression(statisticsRoomEventTypes()...)
	if err != nil {
		t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
	}
	for _, required := range []string{roommq.TagRoomOutboxEvent, roommq.EventTypeRoomGiftSent, roommq.EventTypeRoomUserJoined} {
		if !strings.Contains(expression, required) {
			t.Fatalf("statistics room selector %q misses %q", expression, required)
		}
	}
	if strings.Contains(expression, "RoomHeatChanged") {
		t.Fatalf("statistics room selector must not receive heat events: %q", expression)
	}
}

// 这个用例只覆盖 statistics-service 对 owner outbox payload 的解析和聚合边界；
// 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。
func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) {
	ctx := context.Background()
	_, file, _, _ := runtime.Caller(0)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
		DatabasePrefix: "hy_stats_flow",
	})
	repo, err := mysqlstorage.Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open statistics repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
	dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli()
	const (
		appCode   = "lalu"
		countryID = int64(86)
		regionID  = int64(210)
		userID    = int64(42)
	)

	googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:       appCode,
		EventID:       "WalletRechargeRecorded:google:1",
		EventType:     "WalletRechargeRecorded",
		TransactionID: "wallet_tx_google_1",
		CommandID:     "google_order_1",
		UserID:        userID,
		PayloadJSON: mustJSON(t, map[string]any{
			"amount_micro":      int64(1_500_000),
			"country_id":        countryID,
			"region_id":         regionID,
			"channel":           "google",
			"recharge_sequence": int64(1),
		}),
		OccurredAtMS: occurredAt,
	})
	recharge, ok, err := rechargeEvent(googleRecharge)
	if err != nil || !ok {
		t.Fatalf("decode google recharge failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeRecharge(ctx, recharge); err != nil {
		t.Fatalf("consume google recharge failed: %v", err)
	}

	coinSellerStock := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:       appCode,
		EventID:       "WalletCoinSellerStockPurchased:1",
		EventType:     "WalletCoinSellerStockPurchased",
		TransactionID: "wallet_tx_coin_seller_stock_1",
		CommandID:     "coin_seller_stock_1",
		UserID:        userID + 10,
		PayloadJSON: mustJSON(t, map[string]any{
			"seller_user_id":            userID + 10,
			"country_id":                countryID,
			"region_id":                 regionID,
			"coin_amount":               int64(400_000),
			"paid_amount_micro":         int64(4_000_000),
			"counts_as_seller_recharge": true,
			"stock_type":                "usdt_purchase",
		}),
		OccurredAtMS: occurredAt,
	})
	stock, ok, err := coinSellerStockEvent(coinSellerStock)
	if err != nil || !ok {
		t.Fatalf("decode coin seller stock failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeCoinSellerStock(ctx, stock); err != nil {
		t.Fatalf("consume coin seller stock failed: %v", err)
	}
	coinSellerStockDeduction := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:       appCode,
		EventID:       "WalletCoinSellerStockPurchased:deduction:1",
		EventType:     "WalletCoinSellerStockPurchased",
		TransactionID: "wallet_tx_coin_seller_stock_deduction_1",
		CommandID:     "coin_seller_stock_deduction_1",
		UserID:        userID + 10,
		PayloadJSON: mustJSON(t, map[string]any{
			"seller_user_id":            userID + 10,
			"country_id":                countryID,
			"region_id":                 regionID,
			"coin_amount":               int64(-50_000),
			"paid_amount_micro":         int64(-500_000),
			"counts_as_seller_recharge": true,
			"stock_type":                "usdt_deduction",
		}),
		OccurredAtMS: occurredAt,
	})
	stockDeduction, ok, err := coinSellerStockEvent(coinSellerStockDeduction)
	if err != nil || !ok {
		t.Fatalf("decode coin seller stock deduction failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeCoinSellerStock(ctx, stockDeduction); err != nil {
		t.Fatalf("consume coin seller stock deduction failed: %v", err)
	}

	coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:       appCode,
		EventID:       "WalletRechargeRecorded:coin_seller:1",
		EventType:     "WalletRechargeRecorded",
		TransactionID: "wallet_tx_coin_seller_1",
		CommandID:     "coin_seller_transfer_1",
		UserID:        userID + 1,
		PayloadJSON: mustJSON(t, map[string]any{
			"amount":            int64(160_000),
			"recharge_type":     "coin_seller_transfer",
			"target_country_id": countryID,
			"target_region_id":  regionID,
			"recharge_sequence": int64(1),
		}),
		OccurredAtMS: occurredAt,
	})
	coinSeller, ok, err := rechargeEvent(coinSellerRecharge)
	if err != nil || !ok {
		t.Fatalf("decode coin seller recharge failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeRecharge(ctx, coinSeller); err != nil {
		t.Fatalf("consume coin seller recharge failed: %v", err)
	}

	roomGiftBody := roomGiftMessage(t, appCode, "room_gift_1", "room_1", occurredAt, &roomeventsv1.RoomGiftSent{
		SenderUserId:    userID,
		TargetUserId:    99,
		GiftId:          "rose",
		GiftCount:       10,
		GiftValue:       1_000,
		CoinSpent:       1_250,
		VisibleRegionId: regionID,
		CommandId:       "gift_command_1",
		PoolId:          "lucky_100",
	})
	gift, ok, err := roomGiftEvent(roomGiftBody)
	if err != nil || !ok {
		t.Fatalf("decode room gift failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeRoomGift(ctx, gift); err != nil {
		t.Fatalf("consume room gift failed: %v", err)
	}
	robotGiftBody := roomGiftMessage(t, appCode, "room_gift_robot_1", "room_robot_real", occurredAt, &roomeventsv1.RoomGiftSent{
		SenderUserId:       88001,
		TargetUserId:       88002,
		GiftId:             "robot-super",
		GiftCount:          1,
		GiftValue:          700,
		CoinSpent:          900,
		VisibleRegionId:    regionID,
		CommandId:          "gift_command_robot_1",
		PoolId:             "human_robot_super_lucky_display",
		GiftTypeCode:       "super_lucky",
		IsRobotGift:        true,
		RealRoomRobotGift:  true,
		SyntheticLuckyGift: true,
	})
	robotGift, ok, err := roomGiftEvent(robotGiftBody)
	if err != nil || !ok {
		t.Fatalf("decode real room robot gift failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeRoomGift(ctx, robotGift); err != nil {
		t.Fatalf("consume real room robot gift failed: %v", err)
	}

	luckyReward := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:       appCode,
		EventID:       "WalletLuckyGiftRewardCredited:1",
		EventType:     "WalletLuckyGiftRewardCredited",
		TransactionID: "wallet_tx_lucky_1",
		CommandID:     "lucky_reward:lucky_draw_1",
		UserID:        userID,
		PayloadJSON: mustJSON(t, map[string]any{
			"user_id":           userID,
			"draw_id":           "lucky_draw_1",
			"room_id":           "room_1",
			"pool_id":           "lucky_100",
			"amount":            int64(300),
			"country_id":        countryID,
			"visible_region_id": regionID,
		}),
		OccurredAtMS: occurredAt,
	})
	reward, ok, err := luckyGiftRewardEvent(luckyReward)
	if err != nil || !ok {
		t.Fatalf("decode lucky reward failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeLuckyGiftReward(ctx, reward); err != nil {
		t.Fatalf("consume lucky reward failed: %v", err)
	}

	for _, message := range []gamemq.GameOutboxMessage{
		gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, countryID, regionID, occurredAt),
		gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, countryID, regionID, occurredAt),
	} {
		body, err := gamemq.EncodeGameOutboxMessage(message)
		if err != nil {
			t.Fatalf("encode game message failed: %v", err)
		}
		event, ok, err := gameOrderEvent(body)
		if err != nil || !ok {
			t.Fatalf("decode game order failed: ok=%v err=%v", ok, err)
		}
		if err := repo.ConsumeGameOrder(ctx, event); err != nil {
			t.Fatalf("consume game order failed: %v", err)
		}
	}

	overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
		AppCode:  appCode,
		StartMS:  dayStart,
		EndMS:    dayStart + int64(24*time.Hour/time.Millisecond),
		RegionID: regionID,
	})
	if err != nil {
		t.Fatalf("query overview failed: %v", err)
	}
	if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 350 || overview.CoinSellerStockCoin != 350_000 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 500 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
		t.Fatalf("google recharge metrics mismatch: %+v", overview)
	}
	if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
		t.Fatalf("lucky gift metrics mismatch: %+v", overview)
	}
	if overview.RealRoomRobotSuperGiftCoin != 900 || overview.RealRoomRobotTotalGiftCoin != 900 || overview.RealRoomRobotGiftRoomCount != 1 || overview.RealRoomRobotAvgRoomGiftCoin != 900 {
		t.Fatalf("real room robot gift metrics mismatch: %+v", overview)
	}
	if len(overview.LuckyGiftPools) != 1 {
		t.Fatalf("lucky gift pool breakdown missing: %+v", overview.LuckyGiftPools)
	}
	pool := overview.LuckyGiftPools[0]
	if pool.PoolID != "lucky_100" || pool.TurnoverCoin != 1_250 || pool.PayoutCoin != 300 || pool.ProfitCoin != 950 {
		t.Fatalf("lucky gift pool metrics mismatch: %+v", pool)
	}
	if overview.GameTurnover != 700 || overview.GamePayout != 200 || overview.GameProfit != 500 || overview.GamePlayers != 1 {
		t.Fatalf("game metrics mismatch: %+v", overview)
	}
	if overview.ConsumedCoin != 1_950 || overview.OutputCoin != 500 || overview.ConsumeOutputDelta != 1_450 {
		t.Fatalf("consume/output metrics mismatch: %+v", overview)
	}
}

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

	occurredAt := time.Date(2026, 6, 22, 8, 0, 0, 0, time.UTC).UnixMilli()
	for _, message := range []walletmq.WalletOutboxMessage{
		{
			AppCode:       "lalu",
			EventID:       "WalletBalanceChanged:coin:topup",
			EventType:     "WalletBalanceChanged",
			TransactionID: "wallet_tx_topup",
			CommandID:     "topup",
			UserID:        88001,
			AssetType:     "COIN",
			PayloadJSON: mustJSON(t, map[string]any{
				"user_id":         int64(88001),
				"asset_type":      "COIN",
				"biz_type":        "google_play_recharge",
				"available_delta": int64(100),
				"country_id":      int64(15),
				"region_id":       int64(10),
				"created_at_ms":   occurredAt,
			}),
			OccurredAtMS: occurredAt,
		},
		{
			AppCode:       "lalu",
			EventID:       "WalletBalanceChanged:coin:gift",
			EventType:     "WalletBalanceChanged",
			TransactionID: "wallet_tx_gift",
			CommandID:     "gift",
			UserID:        88001,
			AssetType:     "COIN",
			PayloadJSON: mustJSON(t, map[string]any{
				"user_id":         int64(88001),
				"asset_type":      "COIN",
				"biz_type":        "red_packet_create",
				"available_delta": int64(-88),
				"country_id":      int64(15),
				"region_id":       int64(10),
				"created_at_ms":   occurredAt,
			}),
			OccurredAtMS: occurredAt,
		},
		{
			AppCode:       "lalu",
			EventID:       "WalletBalanceChanged:coin:gift-income-balance",
			EventType:     "WalletBalanceChanged",
			TransactionID: "wallet_tx_gift_income",
			CommandID:     "gift-income-balance",
			UserID:        88002,
			AssetType:     "COIN",
			PayloadJSON: mustJSON(t, map[string]any{
				"user_id":         int64(88002),
				"asset_type":      "COIN",
				"biz_type":        "gift_debit",
				"available_delta": int64(25),
				"country_id":      int64(15),
				"region_id":       int64(10),
				"created_at_ms":   occurredAt,
			}),
			OccurredAtMS: occurredAt,
		},
		{
			AppCode:        "lalu",
			EventID:        "WalletGiftIncomeCredited:coin:gift-income",
			EventType:      "WalletGiftIncomeCredited",
			TransactionID:  "wallet_tx_gift_income",
			CommandID:      "gift-income",
			UserID:         88002,
			AssetType:      "COIN",
			AvailableDelta: 25,
			PayloadJSON: mustJSON(t, map[string]any{
				"user_id":       int64(88002),
				"amount":        int64(25),
				"country_id":    int64(15),
				"region_id":     int64(10),
				"created_at_ms": occurredAt,
			}),
			OccurredAtMS: occurredAt,
		},
		{
			AppCode:       "lalu",
			EventID:       "WalletBalanceChanged:salary:coin-seller",
			EventType:     "WalletBalanceChanged",
			TransactionID: "wallet_tx_salary_seller",
			CommandID:     "salary-seller",
			UserID:        99001,
			AssetType:     "COIN_SELLER_COIN",
			PayloadJSON: mustJSON(t, map[string]any{
				"user_id":         int64(99001),
				"asset_type":      "COIN_SELLER_COIN",
				"biz_type":        "salary_transfer_to_coin_seller",
				"available_delta": int64(660),
				"country_id":      int64(15),
				"region_id":       int64(10),
				"metadata": map[string]any{
					"source_user_id": int64(88001),
					"region_id":      int64(10),
				},
				"created_at_ms": occurredAt,
			}),
			OccurredAtMS: occurredAt,
		},
	} {
		if err := ConsumeWalletOutboxBody(ctx, repo, walletMessage(t, message)); err != nil {
			t.Fatalf("consume wallet balance message %s failed: %v", message.EventID, err)
		}
	}

	start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
		AppCode:   "lalu",
		StartMS:   start,
		EndMS:     start + int64(24*time.Hour/time.Millisecond),
		CountryID: 15,
	})
	if err != nil {
		t.Fatalf("query overview failed: %v", err)
	}
	if overview.CoinTotal != 37 || overview.ConsumedCoin != 88 || overview.OutputCoin != 25 || overview.SalaryTransferCoin != 660 {
		t.Fatalf("wallet balance report metrics mismatch: %+v", overview)
	}
}

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

	occurredAt := time.Date(2026, 7, 11, 2, 0, 0, 0, time.UTC).UnixMilli()
	walletBody := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:        "lalu",
		EventID:        "WalletBalanceChanged:gift:canonical-1",
		EventType:      walletmq.EventTypeWalletBalanceChanged,
		TransactionID:  "wallet_tx_gift_canonical_1",
		CommandID:      "gift-canonical-1",
		UserID:         88001,
		AssetType:      "COIN",
		AvailableDelta: -120,
		PayloadJSON: mustJSON(t, map[string]any{
			"user_id":         int64(88001),
			"asset_type":      "COIN",
			"biz_type":        "gift_debit",
			"available_delta": int64(-120),
			"country_id":      int64(15),
			"region_id":       int64(10),
			"created_at_ms":   occurredAt,
		}),
		OccurredAtMS: occurredAt,
	})
	if err := ConsumeWalletOutboxBody(ctx, repo, walletBody); err != nil {
		t.Fatalf("consume wallet gift balance fact failed: %v", err)
	}
	if err := ConsumeRoomOutboxBody(ctx, repo, roomGiftMessage(t, "lalu", "RoomGiftSent:canonical-1", "room-1", occurredAt, &roomeventsv1.RoomGiftSent{
		SenderUserId:    88001,
		TargetUserId:    88002,
		GiftId:          "gift-1",
		GiftCount:       12,
		GiftValue:       120,
		CoinSpent:       120,
		CountryId:       15,
		VisibleRegionId: 10,
	})); err != nil {
		t.Fatalf("consume canonical room gift fact failed: %v", err)
	}
	// 多目标/微批钱包路径使用 gift_debit；它和 direct_gift_debit 一样只能更新
	// coin_total，消费额仍由对应 target 的 RoomGiftSent 事实负责。
	batchWalletBody := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:        "lalu",
		EventID:        "WalletBalanceChanged:gift:canonical-2",
		EventType:      walletmq.EventTypeWalletBalanceChanged,
		TransactionID:  "wallet_tx_gift_canonical_2",
		CommandID:      "gift-canonical-2",
		UserID:         88001,
		AssetType:      "COIN",
		AvailableDelta: -80,
		PayloadJSON: mustJSON(t, map[string]any{
			"user_id":         int64(88001),
			"asset_type":      "COIN",
			"biz_type":        "gift_debit",
			"available_delta": int64(-80),
			"country_id":      int64(15),
			"region_id":       int64(10),
			"created_at_ms":   occurredAt,
		}),
		OccurredAtMS: occurredAt,
	})
	if err := ConsumeWalletOutboxBody(ctx, repo, batchWalletBody); err != nil {
		t.Fatalf("consume batch wallet gift balance fact failed: %v", err)
	}
	if err := ConsumeRoomOutboxBody(ctx, repo, roomGiftMessage(t, "lalu", "RoomGiftSent:canonical-2", "room-1", occurredAt, &roomeventsv1.RoomGiftSent{
		SenderUserId:    88001,
		TargetUserId:    88003,
		GiftId:          "gift-2",
		GiftCount:       8,
		GiftValue:       80,
		CoinSpent:       80,
		CountryId:       15,
		VisibleRegionId: 10,
	})); err != nil {
		t.Fatalf("consume canonical batch room gift fact failed: %v", err)
	}
	// 私聊礼物没有 RoomGiftSent；direct_gift_debit 必须由钱包事件补计，否则消费总额永久漏失。
	directWalletBody := walletMessage(t, walletmq.WalletOutboxMessage{
		AppCode:        "lalu",
		EventID:        "WalletBalanceChanged:direct-gift:canonical-3",
		EventType:      walletmq.EventTypeWalletBalanceChanged,
		TransactionID:  "wallet_tx_direct_gift_canonical_3",
		CommandID:      "direct-gift-canonical-3",
		UserID:         88001,
		AssetType:      "COIN",
		AvailableDelta: -55,
		PayloadJSON: mustJSON(t, map[string]any{
			"user_id":         int64(88001),
			"asset_type":      "COIN",
			"biz_type":        "direct_gift_debit",
			"available_delta": int64(-55),
			"country_id":      int64(15),
			"region_id":       int64(10),
			"created_at_ms":   occurredAt,
		}),
		OccurredAtMS: occurredAt,
	})
	if err := ConsumeWalletOutboxBody(ctx, repo, directWalletBody); err != nil {
		t.Fatalf("consume direct gift wallet fact failed: %v", err)
	}

	dayStart := time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
	overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
		AppCode:   "lalu",
		StartMS:   dayStart,
		EndMS:     dayStart + int64(24*time.Hour/time.Millisecond),
		CountryID: 15,
	})
	if err != nil {
		t.Fatalf("query canonical gift overview failed: %v", err)
	}
	if overview.CoinTotal != -255 || overview.GiftCoinSpent != 200 || overview.ConsumedCoin != 255 {
		t.Fatalf("room gifts must use RoomGiftSent once while direct gifts use wallet once: %+v", overview)
	}
}

func TestRoomJoinActiveEventSkipsRobotJoin(t *testing.T) {
	occurredAt := time.Date(2026, 6, 25, 8, 0, 0, 0, time.UTC).UnixMilli()

	active, ok, err := roomJoinActiveEvent(roomJoinMessage(t, "lalu", "room_join_real_1", "room_1", occurredAt, &roomeventsv1.RoomUserJoined{
		UserId:    42,
		Role:      "audience",
		CountryId: 86,
		RegionId:  210,
	}))
	if err != nil || !ok {
		t.Fatalf("real room join should produce active event: ok=%v err=%v", ok, err)
	}
	if active.UserID != 42 || active.CountryID != 86 || active.RegionID != 210 || active.EventType != "RoomUserJoined" {
		t.Fatalf("real room join active payload mismatch: %+v", active)
	}

	active, ok, err = roomJoinActiveEvent(roomJoinMessage(t, "lalu", "room_join_robot_1", "room_1", occurredAt, &roomeventsv1.RoomUserJoined{
		UserId:    88001,
		Role:      "audience",
		CountryId: 86,
		RegionId:  210,
		IsRobot:   true,
	}))
	if err != nil {
		t.Fatalf("robot room join decode failed: %v", err)
	}
	if ok {
		t.Fatalf("robot room join must not produce active event: %+v", active)
	}
}

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

	occurredAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC).UnixMilli()
	dayStart := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
	const (
		appCode  = "lalu"
		regionID = int64(210)
		userID   = int64(42)
	)
	appCtx := appcode.WithContext(ctx, appCode)
	for _, eventName := range []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"} {
		if err := repo.ConsumeSelfGameH5Event(appCtx, mysqlstorage.SelfGameH5Event{
			AppCode:      appCode,
			EventID:      "h5:" + eventName,
			EventName:    eventName,
			GameID:       "dice",
			UserID:       userID,
			RegionID:     regionID,
			H5Version:    "dice-h5-test",
			EntrySource:  "room",
			DurationMS:   20,
			Success:      true,
			OccurredAtMS: occurredAt,
		}); err != nil {
			t.Fatalf("consume h5 event %s failed: %v", eventName, err)
		}
	}

	body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
		AppCode:      appCode,
		EventID:      "SelfGameMatchSettled:dice_match_1",
		EventType:    gamemq.EventTypeSelfGameMatchSettled,
		OrderID:      "",
		UserID:       userID,
		PlatformCode: "self",
		GameID:       "dice",
		PayloadJSON: mustJSON(t, map[string]any{
			"match_id":             "dice_match_1",
			"game_id":              "dice",
			"platform_code":        "self",
			"region_id":            regionID,
			"stake_coin":           int64(500),
			"status":               "settled",
			"result":               "win",
			"match_mode":           "robot",
			"forced_result":        "",
			"user_participants":    int64(1),
			"robot_participants":   int64(1),
			"winner_count":         int64(1),
			"loser_count":          int64(1),
			"draw_count":           int64(0),
			"user_stake_coin":      int64(500),
			"robot_stake_coin":     int64(500),
			"payout_coin":          int64(900),
			"refund_coin":          int64(0),
			"platform_profit_coin": int64(100),
			"pool_delta_coin":      int64(50),
			"wait_ms":              int64(1200),
			"duration_ms":          int64(4800),
			"participants": []map[string]any{
				{
					"user_id":          userID,
					"participant_type": "user",
					"stake_coin":       int64(500),
					"dice_points":      []int64{6},
					"result":           "win",
					"payout_coin":      int64(900),
					"net_win_coin":     int64(400),
				},
				{
					"user_id":          int64(0),
					"participant_type": "robot",
					"stake_coin":       int64(500),
					"dice_points":      []int64{2},
					"result":           "lose",
					"payout_coin":      int64(0),
					"net_win_coin":     int64(-500),
				},
			},
		}),
		OccurredAtMS: occurredAt,
	})
	if err != nil {
		t.Fatalf("encode self game match failed: %v", err)
	}
	match, ok, err := selfGameMatchEvent(body)
	if err != nil || !ok {
		t.Fatalf("decode self game match failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeSelfGameMatch(appCtx, match); err != nil {
		t.Fatalf("consume self game match failed: %v", err)
	}
	poolBody, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
		AppCode:      appCode,
		EventID:      "SelfGamePoolAdjusted:pool_adjust_1",
		EventType:    gamemq.EventTypeSelfGamePoolAdjusted,
		UserID:       userID,
		PlatformCode: "self",
		GameID:       "dice",
		PayloadJSON: mustJSON(t, map[string]any{
			"adjustment_id": "pool_adjust_1",
			"game_id":       "dice",
			"match_id":      "dice_match_1",
			"user_id":       userID,
			"direction":     "in",
			"amount_coin":   int64(50),
			"reason":        "match_pool_in",
			"balance_after": int64(1050),
		}),
		OccurredAtMS: occurredAt,
	})
	if err != nil {
		t.Fatalf("encode self game pool failed: %v", err)
	}
	pool, ok, err := selfGamePoolAdjustmentEvent(poolBody)
	if err != nil || !ok {
		t.Fatalf("decode self game pool failed: ok=%v err=%v", ok, err)
	}
	if err := repo.ConsumeSelfGamePoolAdjustment(appCtx, pool); err != nil {
		t.Fatalf("consume self game pool failed: %v", err)
	}
	for _, dayOffset := range []int{1, 7} {
		if err := repo.ConsumeSelfGameMatch(appCtx, mysqlstorage.SelfGameMatchEvent{
			AppCode:          appCode,
			EventID:          "SelfGameMatchSettled:dice_retention_" + string(rune('0'+dayOffset)),
			EventType:        gamemq.EventTypeSelfGameMatchSettled,
			MatchID:          "dice_retention",
			GameID:           "dice",
			RegionID:         regionID,
			StakeCoin:        500,
			UserParticipants: 1,
			WinnerCount:      1,
			UserStakeCoin:    500,
			PayoutCoin:       900,
			Participants: []mysqlstorage.SelfGameMatchParticipantEvent{{
				UserID:          userID,
				ParticipantType: "user",
				StakeCoin:       500,
				DicePoints:      []int64{5},
				Result:          "win",
				PayoutCoin:      900,
				NetWinCoin:      400,
			}},
			OccurredAtMS: time.UnixMilli(occurredAt).AddDate(0, 0, dayOffset).UnixMilli(),
		}); err != nil {
			t.Fatalf("consume retention self game match day+%d failed: %v", dayOffset, err)
		}
	}

	overview, err := repo.QuerySelfGameOverview(ctx, mysqlstorage.SelfGameOverviewQuery{
		AppCode:  appCode,
		StartMS:  dayStart,
		EndMS:    dayStart + int64(24*time.Hour/time.Millisecond),
		GameID:   "dice",
		RegionID: regionID,
	})
	if err != nil {
		t.Fatalf("query self game overview failed: %v", err)
	}
	if overview.Conversion.PageToMatchClickRate != 1 || overview.Conversion.MatchClickToSuccessRate != 1 || overview.Conversion.SuccessToSettlementRate != 1 || overview.Conversion.SettlementToPlayAgainRate != 1 {
		t.Fatalf("self game funnel mismatch: %+v", overview.Conversion)
	}
	if overview.MatchTotals.SettledMatches != 1 || overview.MatchTotals.RobotMatches != 1 || overview.MatchTotals.UserStakeCoin != 500 || overview.MatchTotals.PayoutCoin != 900 || overview.MatchTotals.PlatformProfitCoin != 100 || overview.MatchTotals.PoolDeltaCoin != 50 {
		t.Fatalf("self game match totals mismatch: %+v", overview.MatchTotals)
	}
	if len(overview.StakeBreakdown) != 1 || overview.StakeBreakdown[0].StakeCoin != 500 || overview.StakeBreakdown[0].PlatformProfitCoin != 100 {
		t.Fatalf("self game stake breakdown mismatch: %+v", overview.StakeBreakdown)
	}
	if len(overview.PoolStats) != 1 || overview.PoolStats[0].InCoin != 50 || overview.PoolStats[0].BalanceAfterCoin != 1050 {
		t.Fatalf("self game pool stats mismatch: %+v", overview.PoolStats)
	}
	if len(overview.UserRisk) != 1 || overview.UserRisk[0].UserID != userID || overview.UserRisk[0].WinCount != 1 || overview.UserRisk[0].NetWinCoin != 400 {
		t.Fatalf("self game user risk mismatch: %+v", overview.UserRisk)
	}
	if overview.Retention.CohortUsers != 1 || overview.Retention.Day1Users != 1 || overview.Retention.Day7Users != 1 || overview.Retention.Day1Rate != 1 || overview.Retention.Day7Rate != 1 {
		t.Fatalf("self game retention mismatch: %+v", overview.Retention)
	}
	if len(overview.MetricDefinitions) == 0 {
		t.Fatalf("self game metric definitions missing")
	}
}

func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte {
	t.Helper()
	body, err := walletmq.EncodeWalletOutboxMessage(message)
	if err != nil {
		t.Fatalf("encode wallet message failed: %v", err)
	}
	return body
}

func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, gift *roomeventsv1.RoomGiftSent) []byte {
	t.Helper()
	giftBody, err := proto.Marshal(gift)
	if err != nil {
		t.Fatalf("marshal gift failed: %v", err)
	}
	body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{
		EventId:      eventID,
		RoomId:       roomID,
		EventType:    "RoomGiftSent",
		RoomVersion:  1,
		OccurredAtMs: occurredAt,
		Body:         giftBody,
		AppCode:      appCode,
	})
	if err != nil {
		t.Fatalf("encode room message failed: %v", err)
	}
	return body
}

func roomJoinMessage(t *testing.T, appCode string, eventID string, roomID string, occurredAt int64, joined *roomeventsv1.RoomUserJoined) []byte {
	t.Helper()
	joinedBody, err := proto.Marshal(joined)
	if err != nil {
		t.Fatalf("marshal room join failed: %v", err)
	}
	body, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{
		EventId:      eventID,
		RoomId:       roomID,
		EventType:    "RoomUserJoined",
		RoomVersion:  1,
		OccurredAtMs: occurredAt,
		Body:         joinedBody,
		AppCode:      appCode,
	})
	if err != nil {
		t.Fatalf("encode room join message failed: %v", err)
	}
	return body
}

func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, countryID int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage {
	return gamemq.GameOutboxMessage{
		AppCode:      appCode,
		EventID:      "GameOrderSettled:" + orderID,
		EventType:    "GameOrderSettled",
		OrderID:      orderID,
		UserID:       userID,
		PlatformCode: "yomi",
		GameID:       "fish_001",
		OpType:       opType,
		CoinAmount:   amount,
		PayloadJSON: mustJSONNoT(map[string]any{
			"order_id":          orderID,
			"platform_code":     "yomi",
			"game_id":           "fish_001",
			"user_id":           userID,
			"room_id":           "room_1",
			"visible_region_id": regionID,
			"country_id":        countryID,
			"region_id":         regionID,
			"op_type":           opType,
			"coin_amount":       amount,
		}),
		OccurredAtMS: occurredAt,
	}
}

func mustJSON(t *testing.T, value map[string]any) string {
	t.Helper()
	body, err := json.Marshal(value)
	if err != nil {
		t.Fatalf("marshal json failed: %v", err)
	}
	return string(body)
}

func mustJSONNoT(value map[string]any) string {
	body, err := json.Marshal(value)
	if err != nil {
		panic(err)
	}
	return string(body)
}
