package mysql

import (
	"context"
	"database/sql"
	"math"
	"sort"
	"strconv"
	"strings"
	"time"

	"hyapp/pkg/appcode"
)

type OverviewQuery struct {
	AppCode       string
	StatTZ        string
	StartMS       int64
	EndMS         int64
	SeriesStartMS int64
	SeriesEndMS   int64
	CountryID     int64
	RegionID      int64
}

type Overview struct {
	AppCode                       string               `json:"app_code"`
	StatTZ                        string               `json:"stat_tz"`
	StartMS                       int64                `json:"start_ms"`
	EndMS                         int64                `json:"end_ms"`
	CountryID                     int64                `json:"country_id"`
	NewUsers                      int64                `json:"new_users"`
	RegisteredUsers               int64                `json:"registered_users"`
	Registrations                 int64                `json:"registrations"`
	ActiveUsers                   int64                `json:"active_users"`
	PaidUsers                     int64                `json:"paid_users"`
	RechargeUsers                 int64                `json:"recharge_users"`
	NewPaidUsers                  int64                `json:"new_paid_users"`
	RechargeUSDMinor              int64                `json:"recharge_usd_minor"`
	NewUserRechargeUSDMinor       int64                `json:"new_user_recharge_usd_minor"`
	CoinSellerRechargeUSDMinor    int64                `json:"coin_seller_recharge_usd_minor"`
	CoinSellerStockCoin           int64                `json:"coin_seller_stock_coin"`
	MifaPayRechargeUSDMinor       int64                `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor        int64                `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin        int64                `json:"coin_seller_transfer_coin"`
	CoinTotal                     int64                `json:"coin_total"`
	ConsumedCoin                  int64                `json:"consumed_coin"`
	OutputCoin                    int64                `json:"output_coin"`
	ConsumeOutputRatio            float64              `json:"consume_output_ratio"`
	ConsumeOutputDelta            int64                `json:"consume_output_delta"`
	PlatformGrantCoin             int64                `json:"platform_grant_coin"`
	ManualGrantCoin               int64                `json:"manual_grant_coin"`
	SalaryUSDMinor                int64                `json:"salary_usd_minor"`
	SalaryTransferCoin            int64                `json:"salary_transfer_coin"`
	MicOnlineMS                   int64                `json:"mic_online_ms"`
	MicOnlineUsers                int64                `json:"mic_online_users"`
	AvgMicOnlineMS                int64                `json:"avg_mic_online_ms"`
	GiftCoinSpent                 int64                `json:"gift_coin_spent"`
	RealRoomRobotNormalGiftCoin   int64                `json:"real_room_robot_normal_gift_coin"`
	RealRoomRobotLuckyGiftCoin    int64                `json:"real_room_robot_lucky_gift_coin"`
	RealRoomRobotSuperGiftCoin    int64                `json:"real_room_robot_super_gift_coin"`
	RealRoomRobotTotalGiftCoin    int64                `json:"real_room_robot_total_gift_coin"`
	RealRoomRobotGiftRoomCount    int64                `json:"real_room_robot_gift_room_count"`
	RealRoomRobotAvgRoomGiftCoin  int64                `json:"real_room_robot_avg_room_gift_coin"`
	LuckyGiftTurnover             int64                `json:"lucky_gift_turnover"`
	LuckyGiftPayout               int64                `json:"lucky_gift_payout"`
	LuckyGiftPayers               int64                `json:"lucky_gift_payers"`
	LuckyGiftProfit               int64                `json:"lucky_gift_profit"`
	LuckyGiftPayoutRate           float64              `json:"lucky_gift_payout_rate"`
	LuckyGiftProfitRate           float64              `json:"lucky_gift_profit_rate"`
	GameTurnover                  int64                `json:"game_turnover"`
	GamePayout                    int64                `json:"game_payout"`
	GameRefund                    int64                `json:"game_refund"`
	GamePlayers                   int64                `json:"game_players"`
	GameProfit                    int64                `json:"game_profit"`
	GameProfitRate                float64              `json:"game_profit_rate"`
	SuperLuckyGiftTurnover        int64                `json:"super_lucky_gift_turnover"`
	SuperLuckyGiftPayout          int64                `json:"super_lucky_gift_payout"`
	SuperLuckyGiftProfit          int64                `json:"super_lucky_gift_profit"`
	SuperLuckyGiftProfitRate      float64              `json:"super_lucky_gift_profit_rate"`
	ARPUUSDMinor                  int64                `json:"arpu_usd_minor"`
	ARPPUUSDMinor                 int64                `json:"arppu_usd_minor"`
	UPValueUSDMinor               int64                `json:"up_value_usd_minor"`
	PayerRate                     float64              `json:"payer_rate"`
	PaidConversionRate            float64              `json:"paid_conversion_rate"`
	RechargeConversionRate        float64              `json:"recharge_conversion_rate"`
	RechargeDeltaRate             float64              `json:"recharge_delta_rate"`
	NewUserRechargeDeltaRate      float64              `json:"new_user_recharge_delta_rate"`
	ActiveUsersDeltaRate          float64              `json:"active_users_delta_rate"`
	PaidUsersDeltaRate            float64              `json:"paid_users_delta_rate"`
	NewPaidUsersDeltaRate         float64              `json:"new_paid_users_delta_rate"`
	ARPUDeltaRate                 float64              `json:"arpu_delta_rate"`
	ARPPUDeltaRate                float64              `json:"arppu_delta_rate"`
	PayerRateDeltaPP              float64              `json:"payer_rate_delta_pp"`
	PaidConversionRateDeltaPP     float64              `json:"paid_conversion_rate_delta_pp"`
	RechargeConversionRateDeltaPP float64              `json:"recharge_conversion_rate_delta_pp"`
	GiftCoinSpentDeltaRate        float64              `json:"gift_coin_spent_delta_rate"`
	CoinSellerTransferDeltaRate   float64              `json:"coin_seller_transfer_coin_delta_rate"`
	LuckyGiftTurnoverDeltaRate    float64              `json:"lucky_gift_turnover_delta_rate"`
	LuckyGiftPayoutDeltaRate      float64              `json:"lucky_gift_payout_delta_rate"`
	LuckyGiftProfitDeltaRate      float64              `json:"lucky_gift_profit_delta_rate"`
	GameTurnoverDeltaRate         float64              `json:"game_turnover_delta_rate"`
	GameProfitDeltaRate           float64              `json:"game_profit_delta_rate"`
	Retention                     Retention            `json:"retention"`
	DailySeries                   []DailySeriesItem    `json:"daily_series"`
	DailyCountryBreakdown         []DailyCountryStat   `json:"daily_country_breakdown"`
	GameRanking                   []GameRank           `json:"game_ranking"`
	LuckyGiftPools                []LuckyGiftPoolStat  `json:"lucky_gift_pools"`
	CountryBreakdown              []CountryBreakdown   `json:"country_breakdown"`
	ReportMetricSources           []ReportMetricSource `json:"report_metric_sources"`
}

type DailySeriesItem struct {
	StatDay                    string   `json:"stat_day"`
	Label                      string   `json:"label"`
	NewUsers                   int64    `json:"new_users"`
	RegisteredUsers            int64    `json:"registered_users"`
	Registrations              int64    `json:"registrations"`
	ActiveUsers                int64    `json:"active_users"`
	PaidUsers                  int64    `json:"paid_users"`
	NewPaidUsers               int64    `json:"new_paid_users"`
	RechargeUsers              int64    `json:"recharge_users"`
	RechargeUSDMinor           int64    `json:"recharge_usd_minor"`
	NewUserRechargeUSDMinor    int64    `json:"new_user_recharge_usd_minor"`
	CoinSellerRechargeUSDMinor int64    `json:"coin_seller_recharge_usd_minor"`
	CoinSellerStockCoin        int64    `json:"coin_seller_stock_coin"`
	MifaPayRechargeUSDMinor    int64    `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor     int64    `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin     int64    `json:"coin_seller_transfer_coin"`
	CoinTotal                  int64    `json:"coin_total"`
	ConsumedCoin               int64    `json:"consumed_coin"`
	OutputCoin                 int64    `json:"output_coin"`
	ConsumeOutputRatio         float64  `json:"consume_output_ratio"`
	ConsumeOutputDelta         int64    `json:"consume_output_delta"`
	PlatformGrantCoin          int64    `json:"platform_grant_coin"`
	ManualGrantCoin            int64    `json:"manual_grant_coin"`
	SalaryUSDMinor             int64    `json:"salary_usd_minor"`
	SalaryTransferCoin         int64    `json:"salary_transfer_coin"`
	MicOnlineMS                int64    `json:"mic_online_ms"`
	MicOnlineUsers             int64    `json:"mic_online_users"`
	AvgMicOnlineMS             int64    `json:"avg_mic_online_ms"`
	GiftCoinSpent              int64    `json:"gift_coin_spent"`
	LuckyGiftTurnover          int64    `json:"lucky_gift_turnover"`
	LuckyGiftPayout            int64    `json:"lucky_gift_payout"`
	LuckyGiftProfit            int64    `json:"lucky_gift_profit"`
	GameTurnover               int64    `json:"game_turnover"`
	GameProfit                 int64    `json:"game_profit"`
	SuperLuckyGiftTurnover     int64    `json:"super_lucky_gift_turnover"`
	SuperLuckyGiftPayout       int64    `json:"super_lucky_gift_payout"`
	SuperLuckyGiftProfit       int64    `json:"super_lucky_gift_profit"`
	SuperLuckyGiftProfitRate   float64  `json:"super_lucky_gift_profit_rate"`
	ARPUUSDMinor               int64    `json:"arpu_usd_minor"`
	ARPPUUSDMinor              int64    `json:"arppu_usd_minor"`
	PayerRate                  float64  `json:"payer_rate"`
	PaidConversionRate         float64  `json:"paid_conversion_rate"`
	RechargeConversionRate     float64  `json:"recharge_conversion_rate"`
	D1RetentionUsers           *int64   `json:"d1_retention_users"`
	D1RetentionBaseUsers       *int64   `json:"d1_retention_base_users"`
	D1RetentionRate            *float64 `json:"d1_retention_rate"`
	D7RetentionUsers           *int64   `json:"d7_retention_users"`
	D7RetentionBaseUsers       *int64   `json:"d7_retention_base_users"`
	D7RetentionRate            *float64 `json:"d7_retention_rate"`
	D30RetentionUsers          *int64   `json:"d30_retention_users"`
	D30RetentionBaseUsers      *int64   `json:"d30_retention_base_users"`
	D30RetentionRate           *float64 `json:"d30_retention_rate"`
}

type Retention struct {
	RegisteredUsers int64    `json:"registered_users"`
	Day1Users       *int64   `json:"day1_users"`
	Day1BaseUsers   *int64   `json:"day1_base_users"`
	Day1Rate        *float64 `json:"day1_rate"`
	Day7Users       *int64   `json:"day7_users"`
	Day7BaseUsers   *int64   `json:"day7_base_users"`
	Day7Rate        *float64 `json:"day7_rate"`
	Day30Users      *int64   `json:"day30_users"`
	Day30BaseUsers  *int64   `json:"day30_base_users"`
	Day30Rate       *float64 `json:"day30_rate"`
}

type GameRank struct {
	PlatformCode string  `json:"platform_code"`
	GameID       string  `json:"game_id"`
	TurnoverCoin int64   `json:"turnover_coin"`
	PayoutCoin   int64   `json:"payout_coin"`
	RefundCoin   int64   `json:"refund_coin"`
	PlayerCount  int64   `json:"player_count"`
	ProfitCoin   int64   `json:"profit_coin"`
	ProfitRate   float64 `json:"profit_rate"`
}

type LuckyGiftPoolStat struct {
	PoolID       string  `json:"pool_id"`
	TurnoverCoin int64   `json:"turnover_coin"`
	PayoutCoin   int64   `json:"payout_coin"`
	ProfitCoin   int64   `json:"profit_coin"`
	PayoutRate   float64 `json:"payout_rate"`
	ProfitRate   float64 `json:"profit_rate"`
}

type superLuckyAggregate struct {
	Turnover int64
	Payout   int64
}

type CountryBreakdown struct {
	CountryID                  int64    `json:"country_id"`
	RegionID                   int64    `json:"region_id"`
	NewUsers                   int64    `json:"new_users"`
	RegisteredUsers            int64    `json:"registered_users"`
	Registrations              int64    `json:"registrations"`
	ActiveUsers                int64    `json:"active_users"`
	PaidUsers                  int64    `json:"paid_users"`
	RechargeUsers              int64    `json:"recharge_users"`
	RechargeUSDMinor           int64    `json:"recharge_usd_minor"`
	NewUserRechargeUSDMinor    int64    `json:"new_user_recharge_usd_minor"`
	CoinSellerRechargeUSDMinor int64    `json:"coin_seller_recharge_usd_minor"`
	CoinSellerStockCoin        int64    `json:"coin_seller_stock_coin"`
	MifaPayRechargeUSDMinor    int64    `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor     int64    `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin     int64    `json:"coin_seller_transfer_coin"`
	CoinTotal                  int64    `json:"coin_total"`
	ConsumedCoin               int64    `json:"consumed_coin"`
	OutputCoin                 int64    `json:"output_coin"`
	ConsumeOutputRatio         float64  `json:"consume_output_ratio"`
	ConsumeOutputDelta         int64    `json:"consume_output_delta"`
	PlatformGrantCoin          int64    `json:"platform_grant_coin"`
	ManualGrantCoin            int64    `json:"manual_grant_coin"`
	SalaryUSDMinor             int64    `json:"salary_usd_minor"`
	SalaryTransferCoin         int64    `json:"salary_transfer_coin"`
	MicOnlineMS                int64    `json:"mic_online_ms"`
	MicOnlineUsers             int64    `json:"mic_online_users"`
	AvgMicOnlineMS             int64    `json:"avg_mic_online_ms"`
	GiftCoinSpent              int64    `json:"gift_coin_spent"`
	LuckyGiftTurnover          int64    `json:"lucky_gift_turnover"`
	LuckyGiftPayout            int64    `json:"lucky_gift_payout"`
	LuckyGiftPayers            int64    `json:"lucky_gift_payers"`
	LuckyGiftProfit            int64    `json:"lucky_gift_profit"`
	LuckyGiftPayoutRate        float64  `json:"lucky_gift_payout_rate"`
	LuckyGiftProfitRate        float64  `json:"lucky_gift_profit_rate"`
	GameTurnover               int64    `json:"game_turnover"`
	GamePayout                 int64    `json:"game_payout"`
	GameRefund                 int64    `json:"game_refund"`
	GamePlayers                int64    `json:"game_players"`
	GameProfit                 int64    `json:"game_profit"`
	GameProfitRate             float64  `json:"game_profit_rate"`
	SuperLuckyGiftTurnover     int64    `json:"super_lucky_gift_turnover"`
	SuperLuckyGiftPayout       int64    `json:"super_lucky_gift_payout"`
	SuperLuckyGiftProfit       int64    `json:"super_lucky_gift_profit"`
	SuperLuckyGiftProfitRate   float64  `json:"super_lucky_gift_profit_rate"`
	ARPUUSDMinor               int64    `json:"arpu_usd_minor"`
	ARPPUUSDMinor              int64    `json:"arppu_usd_minor"`
	PayerRate                  float64  `json:"payer_rate"`
	PaidConversionRate         float64  `json:"paid_conversion_rate"`
	RechargeConversionRate     float64  `json:"recharge_conversion_rate"`
	D1RetentionUsers           *int64   `json:"d1_retention_users"`
	D1RetentionBaseUsers       *int64   `json:"d1_retention_base_users"`
	D1RetentionRate            *float64 `json:"d1_retention_rate"`
	D7RetentionUsers           *int64   `json:"d7_retention_users"`
	D7RetentionBaseUsers       *int64   `json:"d7_retention_base_users"`
	D7RetentionRate            *float64 `json:"d7_retention_rate"`
	D30RetentionUsers          *int64   `json:"d30_retention_users"`
	D30RetentionBaseUsers      *int64   `json:"d30_retention_base_users"`
	D30RetentionRate           *float64 `json:"d30_retention_rate"`
}

type DailyCountryStat struct {
	StatDay string `json:"stat_day"`
	Label   string `json:"label"`
	CountryBreakdown
}

type ReportMetricSource struct {
	Key           string `json:"key"`
	Label         string `json:"label"`
	Status        string `json:"status"`
	Source        string `json:"source"`
	Definition    string `json:"definition"`
	MissingReason string `json:"missing_reason,omitempty"`
}

type SelfGameOverviewQuery struct {
	AppCode   string
	StatTZ    string
	StartMS   int64
	EndMS     int64
	GameID    string
	CountryID int64
	RegionID  int64
}

type SelfGameOverview struct {
	AppCode                 string                    `json:"app_code"`
	StatTZ                  string                    `json:"stat_tz"`
	StartMS                 int64                     `json:"start_ms"`
	EndMS                   int64                     `json:"end_ms"`
	GameID                  string                    `json:"game_id"`
	CountryID               int64                     `json:"country_id"`
	RegionID                int64                     `json:"region_id"`
	Events                  []SelfGameEventStat       `json:"events"`
	MatchTotals             SelfGameMatchTotals       `json:"match_totals"`
	StakeBreakdown          []SelfGameStakeStat       `json:"stake_breakdown"`
	PoolStats               []SelfGamePoolStat        `json:"pool_stats"`
	ParticipantDistribution []SelfGameParticipantStat `json:"participant_distribution"`
	UserRisk                []SelfGameUserRiskStat    `json:"user_risk"`
	Retention               SelfGameRetention         `json:"retention"`
	MetricDefinitions       []MetricDefinition        `json:"metric_definitions"`
	Funnel                  []SelfGameFunnelStep      `json:"funnel"`
	Conversion              SelfGameConversion        `json:"conversion"`
}

type PlatformGrantCoinQuery struct {
	AppCode   string
	StatTZ    string
	StartMS   int64
	EndMS     int64
	StatDay   string
	CountryID int64
	RegionID  int64
	UserID    int64
	EventType string
	Page      int
	PageSize  int
}

type AppTrackingFunnelQuery struct {
	AppCode   string
	StatTZ    string
	StartMS   int64
	EndMS     int64
	CountryID int64
	RegionID  int64
	RegionIDs []int64
}

type AppTrackingFunnel struct {
	AppCode   string                    `json:"app_code"`
	StatTZ    string                    `json:"stat_tz"`
	StartMS   int64                     `json:"start_ms"`
	EndMS     int64                     `json:"end_ms"`
	CountryID int64                     `json:"country_id"`
	RegionID  int64                     `json:"region_id"`
	RegionIDs []int64                   `json:"region_ids,omitempty"`
	Steps     []AppTrackingFunnelStep   `json:"steps"`
	Cohorts   []AppTrackingFunnelCohort `json:"cohorts"`
	Totals    AppTrackingFunnelTotals   `json:"totals"`
}

type AppTrackingFunnelStep struct {
	Key                    string  `json:"key"`
	EventName              string  `json:"event_name"`
	Label                  string  `json:"label"`
	EventCount             int64   `json:"event_count"`
	UserCount              int64   `json:"user_count"`
	DeviceCount            int64   `json:"device_count"`
	OverallConversionRate  float64 `json:"overall_conversion_rate"`
	PreviousConversionRate float64 `json:"previous_conversion_rate"`
	DropoffUsers           int64   `json:"dropoff_users"`
	IsFailure              bool    `json:"is_failure,omitempty"`
}

type AppTrackingFunnelCohort struct {
	Dimension        string                       `json:"dimension"`
	DimensionLabel   string                       `json:"dimension_label"`
	Value            string                       `json:"value"`
	Label            string                       `json:"label"`
	BaseUsers        int64                        `json:"base_users"`
	D1RetentionUsers int64                        `json:"d1_retention_users"`
	D1RetentionRate  float64                      `json:"d1_retention_rate"`
	Steps            []AppTrackingCohortStepCount `json:"steps"`
}

type AppTrackingCohortStepCount struct {
	EventName string `json:"event_name"`
	Label     string `json:"label"`
	UserCount int64  `json:"user_count"`
}

type AppTrackingFunnelTotals struct {
	LoginStartUsers      int64   `json:"login_start_users"`
	LoginSuccessUsers    int64   `json:"login_success_users"`
	ProfileCompleteUsers int64   `json:"profile_complete_users"`
	RoomImpressionUsers  int64   `json:"room_impression_users"`
	RoomClickUsers       int64   `json:"room_click_users"`
	RoomJoinSuccessUsers int64   `json:"room_join_success_users"`
	RoomJoinFailUsers    int64   `json:"room_join_fail_users"`
	Stay30sUsers         int64   `json:"stay_30s_users"`
	Stay3mUsers          int64   `json:"stay_3m_users"`
	Stay10mUsers         int64   `json:"stay_10m_users"`
	SendMessageUsers     int64   `json:"send_message_users"`
	FollowHostUsers      int64   `json:"follow_host_users"`
	AddFriendUsers       int64   `json:"add_friend_users"`
	CheckInUsers         int64   `json:"check_in_users"`
	PushPermissionUsers  int64   `json:"push_permission_users"`
	D1RetentionBaseUsers int64   `json:"d1_retention_base_users"`
	D1RetentionUsers     int64   `json:"d1_retention_users"`
	D1RetentionRate      float64 `json:"d1_retention_rate"`
}

type PlatformGrantCoinUser struct {
	UserID           int64 `json:"user_id"`
	CountryID        int64 `json:"country_id"`
	RegionID         int64 `json:"region_id"`
	TotalCoin        int64 `json:"total_coin"`
	RecordCount      int64 `json:"record_count"`
	FirstGrantedAtMS int64 `json:"first_granted_at_ms"`
	LastGrantedAtMS  int64 `json:"last_granted_at_ms"`
}

type PlatformGrantCoinRecord struct {
	EventID      string `json:"event_id"`
	EventType    string `json:"event_type"`
	UserID       int64  `json:"user_id"`
	CountryID    int64  `json:"country_id"`
	RegionID     int64  `json:"region_id"`
	Amount       int64  `json:"amount"`
	OccurredAtMS int64  `json:"occurred_at_ms"`
}

type PlatformGrantCoinUserPage struct {
	Items    []PlatformGrantCoinUser `json:"items"`
	Page     int                     `json:"page"`
	PageSize int                     `json:"pageSize"`
	Total    int64                   `json:"total"`
}

type PlatformGrantCoinRecordPage struct {
	Items    []PlatformGrantCoinRecord `json:"items"`
	Page     int                       `json:"page"`
	PageSize int                       `json:"pageSize"`
	Total    int64                     `json:"total"`
}

type SelfGameEventStat struct {
	GameID        string `json:"game_id"`
	EventName     string `json:"event_name"`
	EventCount    int64  `json:"event_count"`
	UserCount     int64  `json:"user_count"`
	SuccessCount  int64  `json:"success_count"`
	ErrorCount    int64  `json:"error_count"`
	DurationMSSum int64  `json:"duration_ms_sum"`
	AvgDurationMS int64  `json:"avg_duration_ms"`
}

type SelfGameMatchTotals struct {
	CreatedMatches          int64   `json:"created_matches"`
	MatchedMatches          int64   `json:"matched_matches"`
	SettledMatches          int64   `json:"settled_matches"`
	CanceledMatches         int64   `json:"canceled_matches"`
	FailedMatches           int64   `json:"failed_matches"`
	HumanMatches            int64   `json:"human_matches"`
	RobotMatches            int64   `json:"robot_matches"`
	HumanParticipants       int64   `json:"human_participants"`
	RobotParticipants       int64   `json:"robot_participants"`
	WinnerCount             int64   `json:"winner_count"`
	LoserCount              int64   `json:"loser_count"`
	DrawCount               int64   `json:"draw_count"`
	UserStakeCoin           int64   `json:"user_stake_coin"`
	RobotStakeCoin          int64   `json:"robot_stake_coin"`
	PayoutCoin              int64   `json:"payout_coin"`
	RefundCoin              int64   `json:"refund_coin"`
	PlatformProfitCoin      int64   `json:"platform_profit_coin"`
	PoolDeltaCoin           int64   `json:"pool_delta_coin"`
	ForcedPlayerLoseMatches int64   `json:"forced_player_lose_matches"`
	WaitMSSum               int64   `json:"wait_ms_sum"`
	DurationMSSum           int64   `json:"duration_ms_sum"`
	AvgWaitMS               int64   `json:"avg_wait_ms"`
	AvgDurationMS           int64   `json:"avg_duration_ms"`
	CancelRate              float64 `json:"cancel_rate"`
	FailRate                float64 `json:"fail_rate"`
	RobotMatchRate          float64 `json:"robot_match_rate"`
	PlatformProfitRate      float64 `json:"platform_profit_rate"`
}

type SelfGameStakeStat struct {
	GameID             string  `json:"game_id"`
	StakeCoin          int64   `json:"stake_coin"`
	CreatedMatches     int64   `json:"created_matches"`
	MatchedMatches     int64   `json:"matched_matches"`
	SettledMatches     int64   `json:"settled_matches"`
	CanceledMatches    int64   `json:"canceled_matches"`
	FailedMatches      int64   `json:"failed_matches"`
	HumanParticipants  int64   `json:"human_participants"`
	RobotParticipants  int64   `json:"robot_participants"`
	UserStakeCoin      int64   `json:"user_stake_coin"`
	RobotStakeCoin     int64   `json:"robot_stake_coin"`
	PayoutCoin         int64   `json:"payout_coin"`
	RefundCoin         int64   `json:"refund_coin"`
	PlatformProfitCoin int64   `json:"platform_profit_coin"`
	PoolDeltaCoin      int64   `json:"pool_delta_coin"`
	PlatformProfitRate float64 `json:"platform_profit_rate"`
}

type SelfGamePoolStat struct {
	GameID           string `json:"game_id"`
	Direction        string `json:"direction"`
	Reason           string `json:"reason"`
	TransactionCount int64  `json:"transaction_count"`
	InCoin           int64  `json:"in_coin"`
	OutCoin          int64  `json:"out_coin"`
	BalanceAfterCoin int64  `json:"balance_after_coin"`
}

type SelfGameParticipantStat struct {
	GameID           string  `json:"game_id"`
	StakeCoin        int64   `json:"stake_coin"`
	ParticipantType  string  `json:"participant_type"`
	Result           string  `json:"result"`
	RPSGesture       string  `json:"rps_gesture"`
	DicePoint        int64   `json:"dice_point"`
	ParticipantCount int64   `json:"participant_count"`
	PayoutCoin       int64   `json:"payout_coin"`
	NetWinCoin       int64   `json:"net_win_coin"`
	WinRate          float64 `json:"win_rate"`
}

type SelfGameUserRiskStat struct {
	GameID      string  `json:"game_id"`
	UserID      int64   `json:"user_id"`
	MatchCount  int64   `json:"match_count"`
	WinCount    int64   `json:"win_count"`
	LoseCount   int64   `json:"lose_count"`
	DrawCount   int64   `json:"draw_count"`
	CancelCount int64   `json:"cancel_count"`
	StakeCoin   int64   `json:"stake_coin"`
	PayoutCoin  int64   `json:"payout_coin"`
	NetWinCoin  int64   `json:"net_win_coin"`
	WinRate     float64 `json:"win_rate"`
	CancelRate  float64 `json:"cancel_rate"`
}

type SelfGameRetention struct {
	CohortUsers int64   `json:"cohort_users"`
	Day1Users   int64   `json:"day1_users"`
	Day7Users   int64   `json:"day7_users"`
	Day1Rate    float64 `json:"day1_rate"`
	Day7Rate    float64 `json:"day7_rate"`
}

type MetricDefinition struct {
	Metric     string `json:"metric"`
	Definition string `json:"definition"`
}

type SelfGameFunnelStep struct {
	EventName  string `json:"event_name"`
	EventCount int64  `json:"event_count"`
	UserCount  int64  `json:"user_count"`
}

type SelfGameConversion struct {
	PageToMatchClickRate      float64 `json:"page_to_match_click_rate"`
	MatchClickToSuccessRate   float64 `json:"match_click_to_success_rate"`
	SuccessToSettlementRate   float64 `json:"success_to_settlement_rate"`
	SettlementToPlayAgainRate float64 `json:"settlement_to_play_again_rate"`
	PageToSettlementRate      float64 `json:"page_to_settlement_rate"`
}

func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = "lalu"
	}
	statTZ := normalizeStatTZ(query.StatTZ)
	startDay := statDayIn(query.StartMS, statTZ)
	endDay := statDayIn(query.EndMS-1, statTZ)
	previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
	overview, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	previous, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	// Revenue and business counts remain on stat_app_day_country, but every DAU
	// value and DAU-derived denominator must come from the same canonical Social
	// user-day source. Load current and previous windows before deriving ratios
	// so deltas cannot compare canonical current data with legacy previous data.
	activeCube, err := r.queryCanonicalOverviewActiveCube(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	previousActiveCube, err := r.queryCanonicalOverviewActiveCube(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.ActiveUsers = activeCube.Total
	previous.ActiveUsers = previousActiveCube.Total
	overview.AppCode, overview.StatTZ, overview.StartMS, overview.EndMS, overview.CountryID = app, statTZ, query.StartMS, query.EndMS, query.CountryID
	previous.AppCode, previous.StatTZ, previous.CountryID = app, statTZ, query.CountryID
	// 新增付费用户来自日充值用户去重表和注册表的交集；只读聚合小表，避免 Databi 页面回扫钱包或订单明细。
	if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	}
	if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	}
	if robotGifts, err := r.queryRealRoomRobotGiftOverview(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	} else {
		overview.RealRoomRobotNormalGiftCoin = robotGifts.RealRoomRobotNormalGiftCoin
		overview.RealRoomRobotLuckyGiftCoin = robotGifts.RealRoomRobotLuckyGiftCoin
		overview.RealRoomRobotSuperGiftCoin = robotGifts.RealRoomRobotSuperGiftCoin
		overview.RealRoomRobotTotalGiftCoin = robotGifts.RealRoomRobotTotalGiftCoin
		overview.RealRoomRobotGiftRoomCount = robotGifts.RealRoomRobotGiftRoomCount
		overview.RealRoomRobotAvgRoomGiftCoin = robotGifts.RealRoomRobotAvgRoomGiftCoin
	}
	// Pool 明细和顶部汇总来自同一个统计窗口；Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
	pools, err := r.queryLuckyGiftPools(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.LuckyGiftPools = pools
	overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(pools)
	if statTZ == StatTZUTC {
		// activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表；只有 UTC 查询能拿它覆盖旧统计行，北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。
		if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
			return Overview{}, err
		} else if ok {
			oldTurnover, oldPayout := overview.LuckyGiftTurnover, overview.LuckyGiftPayout
			overview.LuckyGiftPools = activityPools
			overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
			overview.ConsumedCoin = replaceEmbeddedFlow(overview.ConsumedCoin, oldTurnover, overview.LuckyGiftTurnover)
			overview.OutputCoin = replaceEmbeddedFlow(overview.OutputCoin, oldPayout, overview.LuckyGiftPayout)
			overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(activityPools)
		}
		// 上一周期也使用相同的日聚合口径，保证“与昨日相比/前周期相比”不额外查询明细表。
		if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
			return Overview{}, err
		} else if ok {
			oldTurnover, oldPayout := previous.LuckyGiftTurnover, previous.LuckyGiftPayout
			previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
			previous.ConsumedCoin = replaceEmbeddedFlow(previous.ConsumedCoin, oldTurnover, previous.LuckyGiftTurnover)
			previous.OutputCoin = replaceEmbeddedFlow(previous.OutputCoin, oldPayout, previous.LuckyGiftPayout)
		}
	}
	applyOverviewDerivedMetrics(&overview)
	applyOverviewDerivedMetrics(&previous)
	overview.applyDeltaRates(previous)
	retentionCube, err := r.queryRetentionCube(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.Retention = retentionCube.Total.toRetention()
	ranking, err := r.queryGameRanking(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.GameRanking = ranking
	if query.CountryID <= 0 {
		breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByCountry, activeCube)
		if err != nil {
			return Overview{}, err
		}
		overview.CountryBreakdown = breakdown
		dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByDayCountry, activeCube)
		if err != nil {
			return Overview{}, err
		}
		overview.DailyCountryBreakdown = dailyCountryBreakdown
	}
	seriesStartDay, seriesEndDay := startDay, endDay
	if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
		seriesStartDay = statDayIn(query.SeriesStartMS, statTZ)
		seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ)
	}
	seriesRetentionByDay := retentionCube.ByDay
	seriesActiveCube := activeCube
	if seriesStartDay != startDay || seriesEndDay != endDay {
		seriesRetentionCube, err := r.queryRetentionCube(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
		if err != nil {
			return Overview{}, err
		}
		seriesRetentionByDay = seriesRetentionCube.ByDay
		seriesActiveCube, err = r.queryCanonicalOverviewActiveCube(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
		if err != nil {
			return Overview{}, err
		}
	}
	// daily_series 服务卡片内 sparkline 和弹窗折线图；一条 GROUP BY stat_day 查询返回全部指标，避免前端逐日请求。
	dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID, seriesRetentionByDay, seriesActiveCube)
	if err != nil {
		return Overview{}, err
	}
	overview.DailySeries = dailySeries
	overview.ReportMetricSources = reportMetricSources()
	return overview, nil
}

func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOverviewQuery) (SelfGameOverview, error) {
	startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = "lalu"
	}
	statTZ := normalizeStatTZ(query.StatTZ)
	startDay := statDayIn(startMS, statTZ)
	endDay := statDayIn(endMS-1, statTZ)
	gameID := normalizeShortText(query.GameID)
	overview := SelfGameOverview{
		AppCode:   app,
		StatTZ:    statTZ,
		StartMS:   startMS,
		EndMS:     endMS,
		GameID:    gameID,
		CountryID: query.CountryID,
		RegionID:  query.RegionID,
	}
	events, err := r.querySelfGameEvents(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.Events = events
	overview.Funnel, overview.Conversion = buildSelfGameFunnel(events)
	totals, err := r.querySelfGameMatchTotals(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.MatchTotals = totals
	stakes, err := r.querySelfGameStakeBreakdown(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.StakeBreakdown = stakes
	pools, err := r.querySelfGamePools(ctx, app, statTZ, startDay, endDay, gameID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.PoolStats = pools
	participants, err := r.querySelfGameParticipantDistribution(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.ParticipantDistribution = participants
	risk, err := r.querySelfGameUserRisk(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.UserRisk = risk
	retention, err := r.querySelfGameRetention(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.Retention = retention
	overview.MetricDefinitions = selfGameMetricDefinitions()
	return overview, nil
}

func (r *Repository) QueryAppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (AppTrackingFunnel, error) {
	startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = "lalu"
	}
	statTZ := normalizeStatTZ(query.StatTZ)
	out := AppTrackingFunnel{
		AppCode:   app,
		StatTZ:    statTZ,
		StartMS:   startMS,
		EndMS:     endMS,
		CountryID: query.CountryID,
		RegionID:  query.RegionID,
		RegionIDs: normalizeTrackingRegionIDs(query.RegionIDs),
		Steps:     []AppTrackingFunnelStep{},
		Cohorts:   []AppTrackingFunnelCohort{},
	}
	steps, err := r.queryAppTrackingFunnelSteps(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
	if err != nil {
		return AppTrackingFunnel{}, err
	}
	out.Steps = buildAppTrackingFunnelSteps(steps)
	out.Totals = appTrackingTotals(out.Steps)
	d1Base, d1Users, cohorts, err := r.queryAppTrackingD1Cohorts(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
	if err != nil {
		return AppTrackingFunnel{}, err
	}
	out.Totals.D1RetentionBaseUsers = d1Base
	out.Totals.D1RetentionUsers = d1Users
	out.Totals.D1RetentionRate = ratio(d1Users, d1Base)
	out.Cohorts = cohorts
	return out, nil
}

func (r *Repository) QueryPlatformGrantCoinUsers(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinUserPage, error) {
	app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query)
	filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, 0, query.EventType)
	out := PlatformGrantCoinUserPage{Items: []PlatformGrantCoinUser{}, Page: page, PageSize: pageSize}
	// 明细下钻只读 statistics-service 自己维护的事件投影；钱包库是写账事实源，不能在报表查询时跨库扫账务流水。
	countRow := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM (
			SELECT 1
			FROM stat_platform_grant_coin_events
			WHERE `+filter+`
			GROUP BY user_id, country_id, region_id
		) grouped`, args...)
	if err := countRow.Scan(&out.Total); err != nil {
		return PlatformGrantCoinUserPage{}, err
	}
	rowsArgs := append(append([]any{}, args...), pageSize, offset)
	rows, err := r.db.QueryContext(ctx, `
		SELECT user_id, country_id, region_id,
		       COALESCE(SUM(amount),0),
		       COUNT(*),
		       COALESCE(MIN(occurred_at_ms),0),
		       COALESCE(MAX(occurred_at_ms),0)
		FROM stat_platform_grant_coin_events
		WHERE `+filter+`
		GROUP BY user_id, country_id, region_id
		ORDER BY COALESCE(SUM(amount),0) DESC, COALESCE(MAX(occurred_at_ms),0) DESC, user_id DESC
		LIMIT ? OFFSET ?`, rowsArgs...)
	if err != nil {
		return PlatformGrantCoinUserPage{}, err
	}
	defer rows.Close()
	for rows.Next() {
		var item PlatformGrantCoinUser
		if err := rows.Scan(&item.UserID, &item.CountryID, &item.RegionID, &item.TotalCoin, &item.RecordCount, &item.FirstGrantedAtMS, &item.LastGrantedAtMS); err != nil {
			return PlatformGrantCoinUserPage{}, err
		}
		out.Items = append(out.Items, item)
	}
	if err := rows.Err(); err != nil {
		return PlatformGrantCoinUserPage{}, err
	}
	return out, nil
}

func (r *Repository) QueryPlatformGrantCoinRecords(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinRecordPage, error) {
	app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query)
	filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, query.UserID, query.EventType)
	out := PlatformGrantCoinRecordPage{Items: []PlatformGrantCoinRecord{}, Page: page, PageSize: pageSize}
	// 来源页按单个用户分页读取事件事实，排序使用事件发生时间，避免同一用户大量奖励一次性塞给前端。
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM stat_platform_grant_coin_events WHERE `+filter, args...).Scan(&out.Total); err != nil {
		return PlatformGrantCoinRecordPage{}, err
	}
	rowsArgs := append(append([]any{}, args...), pageSize, offset)
	rows, err := r.db.QueryContext(ctx, `
		SELECT event_id, event_type, user_id, country_id, region_id, amount, occurred_at_ms
		FROM stat_platform_grant_coin_events
		WHERE `+filter+`
		ORDER BY occurred_at_ms DESC, event_id DESC
		LIMIT ? OFFSET ?`, rowsArgs...)
	if err != nil {
		return PlatformGrantCoinRecordPage{}, err
	}
	defer rows.Close()
	for rows.Next() {
		var item PlatformGrantCoinRecord
		if err := rows.Scan(&item.EventID, &item.EventType, &item.UserID, &item.CountryID, &item.RegionID, &item.Amount, &item.OccurredAtMS); err != nil {
			return PlatformGrantCoinRecordPage{}, err
		}
		out.Items = append(out.Items, item)
	}
	if err := rows.Err(); err != nil {
		return PlatformGrantCoinRecordPage{}, err
	}
	return out, nil
}

func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
	args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(paid_users),0),
		       COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
		       COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
		       COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
		       COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
		       COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
		       COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
		       COALESCE(SUM(gift_coin_spent),0),
		       COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
		       COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
		       COALESCE(SUM(game_players),0)
		FROM stat_app_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
	var overview Overview
	if err := row.Scan(&overview.NewUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.CoinSellerStockCoin, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.CoinTotal, &overview.ConsumedCoin, &overview.OutputCoin, &overview.PlatformGrantCoin, &overview.ManualGrantCoin, &overview.SalaryUSDMinor, &overview.SalaryTransferCoin, &overview.MicOnlineMS, &overview.MicOnlineUsers, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
		return Overview{}, err
	}
	return overview, nil
}

func (r *Repository) queryNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (int64, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND p.country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		// p 表和 r 表都已经是统计小表，这里只按充值发生地过滤，保证新增付费用户口径和付费用户卡片一致。
		filter += " AND p.region_id = ?"
		args = append(args, regionID)
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT COUNT(DISTINCT p.user_id)
		FROM stat_recharge_day_payers p
		JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id
		WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ?
		  AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
	var count int64
	if err := row.Scan(&count); err != nil && err != sql.ErrNoRows {
		return 0, err
	}
	return count, nil
}

func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT COALESCE(SUM(normal_gift_coin),0),
		       COALESCE(SUM(lucky_gift_coin),0),
		       COALESCE(SUM(super_lucky_gift_coin),0),
		       COUNT(*)
		FROM stat_real_room_robot_gift_day_rooms
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
	var overview Overview
	if err := row.Scan(&overview.RealRoomRobotNormalGiftCoin, &overview.RealRoomRobotLuckyGiftCoin, &overview.RealRoomRobotSuperGiftCoin, &overview.RealRoomRobotGiftRoomCount); err != nil {
		return Overview{}, err
	}
	overview.RealRoomRobotTotalGiftCoin = overview.RealRoomRobotNormalGiftCoin + overview.RealRoomRobotLuckyGiftCoin + overview.RealRoomRobotSuperGiftCoin
	overview.RealRoomRobotAvgRoomGiftCoin = div(overview.RealRoomRobotTotalGiftCoin, overview.RealRoomRobotGiftRoomCount)
	return overview, nil
}

func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64, retentionByDay map[string]retentionCount, activeCube canonicalOverviewActiveCube) ([]DailySeriesItem, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'),
		       COALESCE(SUM(new_users),0), COALESCE(SUM(paid_users),0),
		       COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
		       COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
		       COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
		       COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
		       COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
		       COALESCE(SUM(gift_coin_spent),0),
		       COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0),
		       COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0)
		FROM stat_app_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
		GROUP BY stat_day
		ORDER BY stat_day ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []DailySeriesItem{}
	for rows.Next() {
		var item DailySeriesItem
		var gamePayout, gameRefund int64
		if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil {
			return nil, err
		}
		item.Label = item.StatDay
		item.GameProfit = item.GameTurnover - gamePayout - gameRefund
		applyDailySeriesDerivedMetrics(&item)
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, statTZ, startDay, endDay, countryID, regionID)
	if err != nil {
		return nil, err
	}
	for index := range out {
		out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
	}
	for index := range out {
		if retention, ok := retentionByDay[out[index].StatDay]; ok {
			out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
			out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
			out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		}
	}
	// 回看口径下留存归属观察日：观察日整天零活跃时没有 stat_app_day_country 载体行，
	// 真实的 0% 留存点会随行缺失而消失。为有基数的观察日合成占位行（其余指标确为 0）。
	existingDays := make(map[string]struct{}, len(out))
	for index := range out {
		existingDays[out[index].StatDay] = struct{}{}
	}
	for day, retention := range retentionByDay {
		if _, ok := existingDays[day]; ok || !retention.hasRetentionBase() {
			continue
		}
		item := DailySeriesItem{StatDay: day, Label: day}
		item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
		item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
		item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		applyDailySeriesDerivedMetrics(&item)
		out = append(out, item)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].StatDay < out[j].StatDay })
	superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
	if err != nil {
		return nil, err
	}
	for index := range out {
		if super, ok := superByDay[out[index].StatDay]; ok {
			out[index].SuperLuckyGiftTurnover = super.Turnover
			out[index].SuperLuckyGiftPayout = super.Payout
			applyDailySeriesDerivedMetrics(&out[index])
		}
	}
	if normalizeStatTZ(statTZ) == StatTZUTC {
		if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
			return nil, err
		} else if ok {
			out = mergeActivityLuckyGiftDaily(out, activityDaily)
		}
	}
	return overlayCanonicalDailyActive(out, activeCube), nil
}

func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND p.country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND p.region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id)
		FROM stat_recharge_day_payers p
		JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id
		WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ?
		  AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
		GROUP BY p.stat_day`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]int64{}
	for rows.Next() {
		var day string
		var count int64
		if err := rows.Scan(&day, &count); err != nil {
			return nil, err
		}
		out[day] = count
	}
	return out, rows.Err()
}

type retentionCount struct {
	RegisteredUsers int64
	D1Users         int64
	D1Base          int64
	D7Users         int64
	D7Base          int64
	D30Users        int64
	D30Base         int64
	// RegionID 不是可加指标：同一国家的注册行共享同一区域，max 合并只为给
	// 合成的占位行（观察日零活跃、无 stat_app_day_country 载体行）补上区域归属。
	RegionID int64
}

func (count *retentionCount) add(next retentionCount) {
	count.RegisteredUsers += next.RegisteredUsers
	count.D1Users += next.D1Users
	count.D1Base += next.D1Base
	count.D7Users += next.D7Users
	count.D7Base += next.D7Base
	count.D30Users += next.D30Users
	count.D30Base += next.D30Base
	if next.RegionID > count.RegionID {
		count.RegionID = next.RegionID
	}
}

// hasRetentionBase 表示该计数至少有一个可观察的留存基数（合成占位行的门槛）。
func (count retentionCount) hasRetentionBase() bool {
	return count.D1Base > 0 || count.D7Base > 0 || count.D30Base > 0
}

func (count retentionCount) toRetention() Retention {
	out := Retention{RegisteredUsers: count.RegisteredUsers}
	out.Day1Users, out.Day1BaseUsers, out.Day1Rate = retentionPointers(count.D1Users, count.D1Base)
	out.Day7Users, out.Day7BaseUsers, out.Day7Rate = retentionPointers(count.D7Users, count.D7Base)
	out.Day30Users, out.Day30BaseUsers, out.Day30Rate = retentionPointers(count.D30Users, count.D30Base)
	return out
}

type retentionCube struct {
	Total        retentionCount
	ByDay        map[string]retentionCount
	ByCountry    map[int64]retentionCount
	ByDayCountry map[string]retentionCount
}

func newRetentionCube() retentionCube {
	return retentionCube{
		ByDay:        map[string]retentionCount{},
		ByCountry:    map[int64]retentionCount{},
		ByDayCountry: map[string]retentionCount{},
	}
}

func (cube *retentionCube) add(day string, countryID int64, count retentionCount) {
	cube.Total.add(count)

	dayCount := cube.ByDay[day]
	dayCount.add(count)
	cube.ByDay[day] = dayCount

	countryCount := cube.ByCountry[countryID]
	countryCount.add(count)
	cube.ByCountry[countryID] = countryCount

	dayCountryKey := dailyCountryKey(day, countryID)
	dayCountryCount := cube.ByDayCountry[dayCountryKey]
	dayCountryCount.add(count)
	cube.ByDayCountry[dayCountryKey] = dayCountryCount
}

// queryRetentionCube 按“观察日回看”口径计算留存：区间内每一天 D 的 DN 留存 =
// D-N 天注册的用户中在 D 当天活跃的比例，计数归属观察日 D（与 Yumi 的 CDC 预聚合口径一致）。
// cohort 注册日无注册时该观察日的基数保持 0，上层 retentionPointers 会把 0 基数转成 nil。
// RegisteredUsers 仍按注册日归属，只统计落在查询区间内的注册。
func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (retentionCube, error) {
	tz := normalizeStatTZ(statTZ)
	today := statDayIn(time.Now().UnixMilli(), tz)
	// 观察日不能超过今天：ISO 日期字符串可直接比较。
	obsEnd := endDay
	if today < obsEnd {
		obsEnd = today
	}
	args := []any{
		startDay, obsEnd,
		startDay, obsEnd,
		startDay, obsEnd,
		startDay, obsEnd,
		startDay, obsEnd,
		startDay, obsEnd,
		app, tz, startDay, endDay,
	}
	filter := ""
	if countryID > 0 {
		filter += " AND r.country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND r.region_id = ?"
		args = append(args, regionID)
	}
	// 注册日扫描范围放宽到 start-30：观察日落在区间内的 D30 cohort 最早注册于 start-30。
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COALESCE(MAX(r.region_id),0), COUNT(*),
		       COALESCE(SUM(CASE WHEN d1.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
		       COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0),
		       COALESCE(SUM(CASE WHEN d7.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
		       COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0),
		       COALESCE(SUM(CASE WHEN d30.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
		       COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) BETWEEN ? AND ? THEN 1 ELSE 0 END),0)
		FROM stat_user_registration r
		LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) BETWEEN ? AND ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id
		LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) BETWEEN ? AND ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id
		LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) BETWEEN ? AND ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id
		WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN DATE_SUB(?, INTERVAL 30 DAY) AND ?`+filter+`
		GROUP BY r.registered_day, r.country_id`, args...)
	if err != nil {
		return retentionCube{}, err
	}
	defer rows.Close()
	out := newRetentionCube()
	for rows.Next() {
		var day string
		var countryID int64
		var count retentionCount
		if err := rows.Scan(&day, &countryID, &count.RegionID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil {
			return retentionCube{}, err
		}
		registeredDay, err := time.Parse("2006-01-02", day)
		if err != nil {
			continue
		}
		// 注册数归属注册日；只保留查询区间内的注册日，区间前 30 天的 cohort 行不计入注册数。
		if day >= startDay && day <= endDay && count.RegisteredUsers > 0 {
			out.add(day, countryID, retentionCount{RegisteredUsers: count.RegisteredUsers, RegionID: count.RegionID})
		}
		// 留存计数归属观察日（注册日 + N）；SQL 侧的 BETWEEN 守卫保证出窗口的 offset 基数为 0。
		for _, offset := range []struct {
			days  int
			users int64
			base  int64
		}{
			{1, count.D1Users, count.D1Base},
			{7, count.D7Users, count.D7Base},
			{30, count.D30Users, count.D30Base},
		} {
			if offset.base <= 0 {
				continue
			}
			obsDay := registeredDay.AddDate(0, 0, offset.days).Format("2006-01-02")
			if obsDay < startDay || obsDay > obsEnd {
				continue
			}
			partial := retentionCount{RegionID: count.RegionID}
			switch offset.days {
			case 1:
				partial.D1Users, partial.D1Base = offset.users, offset.base
			case 7:
				partial.D7Users, partial.D7Base = offset.users, offset.base
			case 30:
				partial.D30Users, partial.D30Base = offset.users, offset.base
			}
			out.add(obsDay, countryID, partial)
		}
	}
	if err := rows.Err(); err != nil {
		return retentionCube{}, err
	}
	return out, nil
}

func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
	if item == nil {
		return
	}
	channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
	if channelRechargeUSDMinor > item.RechargeUSDMinor {
		item.RechargeUSDMinor = channelRechargeUSDMinor
	}
	// Databi 同时展示“注册”和“新增”，当前统计服务存储列名是 new_users；这里输出两个历史别名，避免前端为同一口径写 App 专属兼容。
	item.RegisteredUsers = item.NewUsers
	item.Registrations = item.NewUsers
	// 现有 Lalu 聚合表的 paid_users 来自充值用户去重表；充值用户先按同一事实输出，后续如果产品拆出其他付费行为，再新增独立事实列。
	item.RechargeUsers = item.PaidUsers
	item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
	item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin
	item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin)
	item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
	item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
	item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
	item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
	item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
	item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers)
	item.PaidConversionRate = item.PayerRate
	item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers)
}

// overlayCanonicalDailyActive makes canonical Social the only DAU source. It
// also creates days that have client activity but no legacy business aggregate,
// then recalculates every metric whose denominator is DAU.
func overlayCanonicalDailyActive(items []DailySeriesItem, cube canonicalOverviewActiveCube) []DailySeriesItem {
	indexByDay := make(map[string]int, len(items))
	for index := range items {
		indexByDay[items[index].StatDay] = index
		items[index].ActiveUsers = cube.ByDay[items[index].StatDay]
	}
	for day, activeUsers := range cube.ByDay {
		if _, ok := indexByDay[day]; ok {
			continue
		}
		items = append(items, DailySeriesItem{StatDay: day, Label: day, ActiveUsers: activeUsers})
	}
	for index := range items {
		applyDailySeriesDerivedMetrics(&items[index])
	}
	sort.SliceStable(items, func(i, j int) bool { return items[i].StatDay < items[j].StatDay })
	return items
}

func applyOverviewDerivedMetrics(overview *Overview) {
	if overview == nil {
		return
	}
	// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor；查询时用渠道合计兜底。币商进货是 USDT 充值，币商转普通金币仍只展示金币卡片。
	channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor
	if channelRechargeUSDMinor > overview.RechargeUSDMinor {
		overview.RechargeUSDMinor = channelRechargeUSDMinor
	}
	// 统计存储层保留最小事实列，HTTP 读模型补齐 Databi 历史字段名，保证 Lalu/Yumi/Aslan 三类 App 共用一套前端解析。
	overview.RegisteredUsers = overview.NewUsers
	overview.Registrations = overview.NewUsers
	overview.RechargeUsers = overview.PaidUsers
	overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
	overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
	overview.ConsumeOutputDelta = overview.ConsumedCoin - overview.OutputCoin
	overview.ConsumeOutputRatio = ratio(overview.OutputCoin, overview.ConsumedCoin)
	overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
	overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
	overview.UPValueUSDMinor = overview.ARPPUUSDMinor
	overview.AvgMicOnlineMS = div(overview.MicOnlineMS, overview.MicOnlineUsers)
	overview.PayerRate = ratio(overview.PaidUsers, overview.ActiveUsers)
	overview.PaidConversionRate = overview.PayerRate
	overview.RechargeConversionRate = ratio(overview.RechargeUsers, overview.ActiveUsers)
	overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
	overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
	overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
	overview.SuperLuckyGiftProfit = overview.SuperLuckyGiftTurnover - overview.SuperLuckyGiftPayout
	overview.SuperLuckyGiftProfitRate = ratio(overview.SuperLuckyGiftProfit, overview.SuperLuckyGiftTurnover)
}

func (overview *Overview) applyDeltaRates(previous Overview) {
	if overview == nil {
		return
	}
	overview.RechargeDeltaRate = deltaRate(overview.RechargeUSDMinor, previous.RechargeUSDMinor)
	overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
	overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
	overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
	overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers)
	overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
	overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
	overview.PayerRateDeltaPP = percentagePointDelta(overview.PayerRate, previous.PayerRate)
	overview.PaidConversionRateDeltaPP = percentagePointDelta(overview.PaidConversionRate, previous.PaidConversionRate)
	overview.RechargeConversionRateDeltaPP = percentagePointDelta(overview.RechargeConversionRate, previous.RechargeConversionRate)
	overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
	overview.CoinSellerTransferDeltaRate = deltaRate(overview.CoinSellerTransferCoin, previous.CoinSellerTransferCoin)
	overview.LuckyGiftTurnoverDeltaRate = deltaRate(overview.LuckyGiftTurnover, previous.LuckyGiftTurnover)
	overview.LuckyGiftPayoutDeltaRate = deltaRate(overview.LuckyGiftPayout, previous.LuckyGiftPayout)
	overview.LuckyGiftProfitDeltaRate = deltaRate(overview.LuckyGiftProfit, previous.LuckyGiftProfit)
	overview.GameTurnoverDeltaRate = deltaRate(overview.GameTurnover, previous.GameTurnover)
	overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
}

func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter = " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0),
		       COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0)
		FROM stat_game_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
		GROUP BY platform_code, game_id
		ORDER BY COALESCE(SUM(turnover_coin),0) DESC
		LIMIT 50`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []GameRank{}
	for rows.Next() {
		var item GameRank
		if err := rows.Scan(&item.PlatformCode, &item.GameID, &item.TurnoverCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlayerCount); err != nil {
			return nil, err
		}
		item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin - item.RefundCoin
		item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByCountry map[int64]retentionCount, activeCube canonicalOverviewActiveCube) ([]CountryBreakdown, error) {
	args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT country_id, COALESCE(MAX(region_id),0),
		       COALESCE(SUM(new_users),0), COALESCE(SUM(paid_users),0),
		       COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
		       COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
		       COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
		       COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
		       COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
		       COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
		       COALESCE(SUM(gift_coin_spent),0),
		       COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
		       COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
		       COALESCE(SUM(game_players),0)
		FROM stat_app_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
		GROUP BY country_id
		ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, country_id ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []CountryBreakdown{}
	for rows.Next() {
		var item CountryBreakdown
		if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
			return nil, err
		}
		applyCountryBreakdownDerivedMetrics(&item)
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	for index := range out {
		if retention, ok := retentionByCountry[out[index].CountryID]; ok {
			out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
			out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
			out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		}
	}
	// 区间内整段零活跃的国家没有 stat_app_day_country 行；有留存基数时合成占位行，保住真实 0%。
	existingCountries := make(map[int64]struct{}, len(out))
	for index := range out {
		existingCountries[out[index].CountryID] = struct{}{}
	}
	for retentionCountryID, retention := range retentionByCountry {
		if _, ok := existingCountries[retentionCountryID]; ok || !retention.hasRetentionBase() {
			continue
		}
		item := CountryBreakdown{CountryID: retentionCountryID, RegionID: retention.RegionID}
		item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
		item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
		item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		applyCountryBreakdownDerivedMetrics(&item)
		out = append(out, item)
	}
	superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
	if err != nil {
		return nil, err
	}
	for index := range out {
		if super, ok := superByCountry[out[index].CountryID]; ok {
			out[index].SuperLuckyGiftTurnover = super.Turnover
			out[index].SuperLuckyGiftPayout = super.Payout
			applyCountryBreakdownDerivedMetrics(&out[index])
		}
	}
	return overlayCanonicalCountryActive(out, activeCube), nil
}

func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByDayCountry map[string]retentionCount, activeCube canonicalOverviewActiveCube) ([]DailyCountryStat, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(MAX(region_id),0),
		       COALESCE(SUM(new_users),0), COALESCE(SUM(paid_users),0),
		       COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
		       COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
		       COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
		       COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
		       COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
		       COALESCE(SUM(gift_coin_spent),0),
		       COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
		       COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
		       COALESCE(SUM(game_players),0)
		FROM stat_app_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
		GROUP BY stat_day, country_id
		ORDER BY stat_day ASC, COALESCE(SUM(recharge_usd_minor),0) DESC, country_id ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []DailyCountryStat{}
	for rows.Next() {
		var item DailyCountryStat
		if err := rows.Scan(&item.StatDay, &item.CountryID, &item.RegionID, &item.NewUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.OutputCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
			return nil, err
		}
		item.Label = item.StatDay
		applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown)
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	for index := range out {
		if retention, ok := retentionByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
			out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
			out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
			out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		}
	}
	// 观察日×国家 当天零活跃时没有载体行；有留存基数时合成占位行，保住真实 0%。
	existingDayCountries := make(map[string]struct{}, len(out))
	for index := range out {
		existingDayCountries[dailyCountryKey(out[index].StatDay, out[index].CountryID)] = struct{}{}
	}
	for key, retention := range retentionByDayCountry {
		if _, ok := existingDayCountries[key]; ok || !retention.hasRetentionBase() {
			continue
		}
		separator := strings.LastIndex(key, ":")
		if separator <= 0 {
			continue
		}
		keyCountryID, err := strconv.ParseInt(key[separator+1:], 10, 64)
		if err != nil {
			continue
		}
		item := DailyCountryStat{StatDay: key[:separator], Label: key[:separator]}
		item.CountryID, item.RegionID = keyCountryID, retention.RegionID
		item.D1RetentionUsers, item.D1RetentionBaseUsers, item.D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
		item.D7RetentionUsers, item.D7RetentionBaseUsers, item.D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
		item.D30RetentionUsers, item.D30RetentionBaseUsers, item.D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
		applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown)
		out = append(out, item)
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].StatDay < out[j].StatDay })
	superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
	if err != nil {
		return nil, err
	}
	for index := range out {
		if super, ok := superByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
			out[index].SuperLuckyGiftTurnover = super.Turnover
			out[index].SuperLuckyGiftPayout = super.Payout
			applyCountryBreakdownDerivedMetrics(&out[index].CountryBreakdown)
		}
	}
	return overlayCanonicalDailyCountryActive(out, activeCube), nil
}

func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
	if item == nil {
		return
	}
	channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
	if channelRechargeUSDMinor > item.RechargeUSDMinor {
		item.RechargeUSDMinor = channelRechargeUSDMinor
	}
	item.RegisteredUsers = item.NewUsers
	item.Registrations = item.NewUsers
	item.RechargeUsers = item.PaidUsers
	item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
	item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
	item.ConsumeOutputDelta = item.ConsumedCoin - item.OutputCoin
	item.ConsumeOutputRatio = ratio(item.OutputCoin, item.ConsumedCoin)
	item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
	item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
	item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
	item.PayerRate = ratio(item.PaidUsers, item.ActiveUsers)
	item.PaidConversionRate = item.PayerRate
	item.RechargeConversionRate = ratio(item.RechargeUsers, item.ActiveUsers)
	item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
	item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
	item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
	item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
	item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
}

// overlayCanonicalCountryActive preserves monetary and business facts from the
// legacy aggregate while replacing its incomplete activity counter. A
// canonical-only valid countries are added to preserve resolved detail. Unknown-country
// DAU intentionally remains only in the top-level total, so country rows need not sum to it.
func overlayCanonicalCountryActive(items []CountryBreakdown, cube canonicalOverviewActiveCube) []CountryBreakdown {
	// 旧聚合表可能仍保存 country_id=0 的金额或历史活跃事实；总览总计会继续包含这些真实事实，
	// 但国家明细不能把“未知维度”渲染成一个国家。先过滤旧行，避免 canonical cube 不再产出 0 后仍残留空壳行。
	validItems := items[:0]
	for _, item := range items {
		if item.CountryID > 0 {
			validItems = append(validItems, item)
		}
	}
	items = validItems

	indexByCountry := make(map[int64]int, len(items))
	for index := range items {
		indexByCountry[items[index].CountryID] = index
		dimension, ok := cube.ByCountry[items[index].CountryID]
		items[index].ActiveUsers = 0
		if ok {
			items[index].ActiveUsers = dimension.ActiveUsers
			items[index].RegionID = dimension.RegionID
		}
	}
	for countryID, dimension := range cube.ByCountry {
		if _, ok := indexByCountry[countryID]; ok {
			continue
		}
		items = append(items, CountryBreakdown{
			CountryID:   countryID,
			RegionID:    dimension.RegionID,
			ActiveUsers: dimension.ActiveUsers,
		})
	}
	for index := range items {
		applyCountryBreakdownDerivedMetrics(&items[index])
	}
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].RechargeUSDMinor != items[j].RechargeUSDMinor {
			return items[i].RechargeUSDMinor > items[j].RechargeUSDMinor
		}
		if items[i].ActiveUsers != items[j].ActiveUsers {
			return items[i].ActiveUsers > items[j].ActiveUsers
		}
		return items[i].CountryID < items[j].CountryID
	})
	// Keep the existing response bound, but apply it only after canonical-only
	// countries have participated in the same ordering as legacy fact rows.
	if len(items) > 500 {
		items = items[:500]
	}
	return items
}

// overlayCanonicalDailyCountryActive is the day×country counterpart of the
// country overlay. It prevents stale legacy DAU from surviving on rows with no
// canonical activity and fills client-only detail rows before final ordering.
func overlayCanonicalDailyCountryActive(items []DailyCountryStat, cube canonicalOverviewActiveCube) []DailyCountryStat {
	// 与汇总国家行保持同一边界：未知国家事实只进入 App/日期总计，不作为 day×country 明细返回。
	validItems := items[:0]
	for _, item := range items {
		if item.CountryID > 0 {
			validItems = append(validItems, item)
		}
	}
	items = validItems

	indexByDayCountry := make(map[string]int, len(items))
	for index := range items {
		key := dailyCountryKey(items[index].StatDay, items[index].CountryID)
		indexByDayCountry[key] = index
		dimension, ok := cube.ByDayCountry[key]
		items[index].ActiveUsers = 0
		if ok {
			items[index].ActiveUsers = dimension.ActiveUsers
			items[index].RegionID = dimension.RegionID
		}
	}
	for key, dimension := range cube.ByDayCountry {
		if _, ok := indexByDayCountry[key]; ok {
			continue
		}
		separator := strings.LastIndex(key, ":")
		if separator <= 0 {
			continue
		}
		countryID, err := strconv.ParseInt(key[separator+1:], 10, 64)
		if err != nil {
			continue
		}
		day := key[:separator]
		items = append(items, DailyCountryStat{
			StatDay: day,
			Label:   day,
			CountryBreakdown: CountryBreakdown{
				CountryID:   countryID,
				RegionID:    dimension.RegionID,
				ActiveUsers: dimension.ActiveUsers,
			},
		})
	}
	for index := range items {
		applyCountryBreakdownDerivedMetrics(&items[index].CountryBreakdown)
	}
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].StatDay != items[j].StatDay {
			return items[i].StatDay < items[j].StatDay
		}
		if items[i].RechargeUSDMinor != items[j].RechargeUSDMinor {
			return items[i].RechargeUSDMinor > items[j].RechargeUSDMinor
		}
		if items[i].ActiveUsers != items[j].ActiveUsers {
			return items[i].ActiveUsers > items[j].ActiveUsers
		}
		return items[i].CountryID < items[j].CountryID
	})
	if len(items) > 5000 {
		items = items[:5000]
	}
	return items
}

func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter = " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM stat_lucky_gift_pool_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
		GROUP BY pool_id
		ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []LuckyGiftPoolStat{}
	for rows.Next() {
		var item LuckyGiftPoolStat
		if err := rows.Scan(&item.PoolID, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
			return nil, err
		}
		item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
		item.PayoutRate = ratio(item.PayoutCoin, item.TurnoverCoin)
		item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, bool, error) {
	if r == nil || r.activityDB == nil {
		return nil, false, nil
	}
	args := []any{app, startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND visible_region_id = ?"
		args = append(args, regionID)
	} else if countryID > 0 {
		filter = " AND visible_region_id = ?"
		args = append(args, countryID)
	}
	rows, err := r.activityDB.QueryContext(ctx, `
		SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM lucky_draw_pool_day_stats
		WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+`
		GROUP BY pool_id
		ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...)
	if err != nil {
		if isMissingTableError(err) {
			return nil, false, nil
		}
		return nil, false, err
	}
	defer rows.Close()
	out := []LuckyGiftPoolStat{}
	for rows.Next() {
		var item LuckyGiftPoolStat
		if err := rows.Scan(&item.PoolID, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
			return nil, false, err
		}
		item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
		item.PayoutRate = ratio(item.PayoutCoin, item.TurnoverCoin)
		item.ProfitRate = ratio(item.ProfitCoin, item.TurnoverCoin)
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, false, err
	}
	return out, len(out) > 0, nil
}

func (r *Repository) queryLuckyGiftDailyFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]LuckyGiftPoolStat, bool, error) {
	if r == nil || r.activityDB == nil {
		return nil, false, nil
	}
	args := []any{app, startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND visible_region_id = ?"
		args = append(args, regionID)
	} else if countryID > 0 {
		filter = " AND visible_region_id = ?"
		args = append(args, countryID)
	}
	rows, err := r.activityDB.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM lucky_draw_pool_day_stats
		WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+`
		GROUP BY stat_day`, args...)
	if err != nil {
		if isMissingTableError(err) {
			return nil, false, nil
		}
		return nil, false, err
	}
	defer rows.Close()
	out := map[string]LuckyGiftPoolStat{}
	for rows.Next() {
		var day string
		var item LuckyGiftPoolStat
		if err := rows.Scan(&day, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
			return nil, false, err
		}
		item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
		out[day] = item
	}
	if err := rows.Err(); err != nil {
		return nil, false, err
	}
	return out, len(out) > 0, nil
}

func mergeActivityLuckyGiftDaily(items []DailySeriesItem, activityDaily map[string]LuckyGiftPoolStat) []DailySeriesItem {
	byDay := make(map[string]int, len(items))
	for index := range items {
		byDay[items[index].StatDay] = index
	}
	for day, activity := range activityDaily {
		index, exists := byDay[day]
		if !exists {
			items = append(items, DailySeriesItem{StatDay: day, Label: day})
			index = len(items) - 1
			byDay[day] = index
		}
		oldTurnover, oldPayout := items[index].LuckyGiftTurnover, items[index].LuckyGiftPayout
		items[index].LuckyGiftTurnover = activity.TurnoverCoin
		items[index].LuckyGiftPayout = activity.PayoutCoin
		items[index].ConsumedCoin = replaceEmbeddedFlow(items[index].ConsumedCoin, oldTurnover, activity.TurnoverCoin)
		items[index].OutputCoin = replaceEmbeddedFlow(items[index].OutputCoin, oldPayout, activity.PayoutCoin)
		applyDailySeriesDerivedMetrics(&items[index])
	}
	sort.SliceStable(items, func(left, right int) bool {
		return items[left].StatDay < items[right].StatDay
	})
	return items
}

func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
	var turnover, payout int64
	for _, pool := range pools {
		turnover += pool.TurnoverCoin
		payout += pool.PayoutCoin
	}
	return turnover, payout
}

func sumSuperLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
	var turnover, payout int64
	for _, pool := range pools {
		if !isSuperLuckyPool(pool.PoolID) {
			continue
		}
		turnover += pool.TurnoverCoin
		payout += pool.PayoutCoin
	}
	return turnover, payout
}

func replaceEmbeddedFlow(total, oldValue, newValue int64) int64 {
	if total >= oldValue {
		return total - oldValue + newValue
	}
	return total + newValue
}

func (r *Repository) querySuperLuckyGiftDaily(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]superLuckyAggregate, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM stat_lucky_gift_pool_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
		GROUP BY stat_day`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]superLuckyAggregate{}
	for rows.Next() {
		var day string
		var item superLuckyAggregate
		if err := rows.Scan(&day, &item.Turnover, &item.Payout); err != nil {
			return nil, err
		}
		out[day] = item
	}
	return out, rows.Err()
}

func (r *Repository) querySuperLuckyGiftCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[int64]superLuckyAggregate, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM stat_lucky_gift_pool_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
		GROUP BY country_id`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[int64]superLuckyAggregate{}
	for rows.Next() {
		var countryID int64
		var item superLuckyAggregate
		if err := rows.Scan(&countryID, &item.Turnover, &item.Payout); err != nil {
			return nil, err
		}
		out[countryID] = item
	}
	return out, rows.Err()
}

func (r *Repository) querySuperLuckyGiftDailyCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[string]superLuckyAggregate, error) {
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	filter := ""
	if regionID > 0 {
		filter = " AND region_id = ?"
		args = append(args, regionID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
		FROM stat_lucky_gift_pool_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
		GROUP BY stat_day, country_id`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]superLuckyAggregate{}
	for rows.Next() {
		var day string
		var countryID int64
		var item superLuckyAggregate
		if err := rows.Scan(&day, &countryID, &item.Turnover, &item.Payout); err != nil {
			return nil, err
		}
		out[dailyCountryKey(day, countryID)] = item
	}
	return out, rows.Err()
}

func dailyCountryKey(day string, countryID int64) string {
	return day + ":" + strconv.FormatInt(countryID, 10)
}

func superLuckyPoolSQL() string {
	return "LOWER(pool_id) LIKE '%super_lucky%'"
}

func isSuperLuckyPool(poolID string) bool {
	return strings.Contains(strings.ToLower(strings.TrimSpace(poolID)), "super_lucky")
}

func reportMetricSources() []ReportMetricSource {
	return []ReportMetricSource{
		{Key: "new_users", Label: "注册", Status: "available", Source: "UserRegistered", Definition: "统计窗口内完成注册的用户数；registered_users 和 registrations 是同一口径别名。"},
		{Key: "active_users", Label: "活跃用户", Status: "available", Source: "stat_social_user_day + stat_social_identity_day", Definition: "Social 用户日宽表先按同一登录会话把匿名设备归并到登录账号，再按 canonical 用户日去重；多日汇总为活跃人天。"},
		{Key: "paid_users", Label: "付费用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数；当前 Lalu 付费用户等同充值用户。"},
		{Key: "recharge_users", Label: "充值用户", Status: "available", Source: "stat_recharge_day_payers", Definition: "统计窗口内发生充值的去重用户数；当前 Lalu 由 paid_users 派生。"},
		{Key: "recharge_usd_minor", Label: "总充值", Status: "available", Source: "WalletRechargeRecorded", Definition: "所有充值渠道的美元最小单位总额，历史行缺总额时用可拆分渠道合计兜底。"},
		{Key: "new_user_recharge_usd_minor", Label: "新用户充值", Status: "available", Source: "WalletRechargeRecorded + UserRegistered", Definition: "统计窗口内注册用户在同窗口产生的充值美元最小单位。"},
		{Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Status: "available", Source: "WalletCoinSellerStockPurchased / WalletRechargeRecorded", Definition: "币商进货 USDT 和未识别为 Google/MifaPay/USDT-TRC20/币商转账的充值美元最小单位。"},
		{Key: "coin_seller_stock_coin", Label: "币商充值金币", Status: "available", Source: "WalletCoinSellerStockPurchased coin_amount", Definition: "币商库存进货金币数量，扣减类库存调整按负数冲减。"},
		{Key: "coin_seller_transfer_coin", Label: "币商出货金币", Status: "available", Source: "WalletRechargeRecorded recharge_type=coin_seller_transfer|coin_seller", Definition: "币商向用户转入普通金币数量。"},
		{Key: "google_recharge_usd_minor", Label: "谷歌充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=google|google_play", Definition: "Google Play 充值美元最小单位。"},
		{Key: "mifapay_recharge_usd_minor", Label: "三方充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=mifapay|v5pay", Definition: "当前三方充值列使用 MifaPay/V5Pay 美元最小单位。"},
		{Key: "game_turnover", Label: "游戏流水/利润率", Status: "available", Source: "GameOrderSettled", Definition: "debit/bet 为流水，credit/payout 为返奖，refund/reverse 为退款，利润率=(流水-返奖-退款)/流水。"},
		{Key: "lucky_gift_turnover", Label: "幸运礼物流水/利润率", Status: "available", Source: "RoomGiftSent + WalletLuckyGiftRewardCredited", Definition: "带 pool_id 的送礼金币为流水，钱包返奖为返奖，利润率=(流水-返奖)/流水。"},
		{Key: "super_lucky_gift_turnover", Label: "超级幸运礼物流水/利润率", Status: "available", Source: "stat_lucky_gift_pool_day_country", Definition: "pool_id 包含 super_lucky 的奖池流水和返奖派生，利润率=(流水-返奖)/流水。"},
		{Key: "gift_coin_spent", Label: "礼物流水", Status: "available", Source: "RoomGiftSent", Definition: "非机器人真实送礼 coin_spent，历史事件无 coin_spent 时回退 gift_value。"},
		{Key: "coin_total", Label: "用户金币数", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "普通用户 COIN 钱包 available+frozen 余额总和；多日总览只取结束日余额快照，日序列展示每日日终快照，当天按余额变动事件滚动更新。"},
		{Key: "consumed_coin", Label: "消耗金币", Status: "available", Source: "GameOrderSettled / RoomGiftSent / WalletBalanceChanged", Definition: "游戏投入 + 普通礼物投入 + lucky 礼物投入 + 转盘投入 + 发红包；礼物和游戏分别以 room/game owner 事实为准，钱包只补转盘、红包和私聊礼物扣费。"},
		{Key: "output_coin", Label: "产出金币", Status: "available", Source: "GameOrderSettled / WalletGiftIncomeCredited / WalletLuckyGiftRewardCredited / WalletWheelRewardCredited / WalletBalanceChanged", Definition: "游戏产出 + 收礼产出 + lucky 礼物产出 + 转盘金币奖励 + 红包领取和退回；转盘礼物价值需钱包奖励事件携带金币价值后才能统计。"},
		{Key: "consume_output_ratio", Label: "消耗产出比", Status: "available", Source: "stat_app_day_country", Definition: "比例=产出金币/消耗金币，差额=消耗金币-产出金币。"},
		{Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited / WalletBalanceChanged biz_type=resource_grant", Definition: "排行榜奖励、活动奖励、签到/任务奖励和火箭奖励中的金币发放；resource_grant 只统计可识别命令前缀的 COIN 入账，不包含人工发放、装扮价值、lucky 返奖和转盘产出。"},
		{Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"},
		{Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "主播和代理工资钱包 HOST_SALARY_USD+AGENCY_SALARY_USD 的 available+frozen 余额总和；多日总览只取结束日余额快照，日序列展示每日日终快照，不包含 residual、BD/Admin 工资。"},
		{Key: "salary_transfer_coin", Label: "工资兑换金币", Status: "available", Source: "WalletBalanceChanged biz_type=salary_transfer_to_coin_seller / wallet_transactions", Definition: "用户工资钱包转给币商时，币商 COIN_SELLER_COIN 入账的金币数量；国家/区域按发起转账用户归属。"},
		{Key: "avg_mic_online_ms", Label: "平均麦上时间", Status: "available", Source: "user_mic_daily_stats", Definition: "麦上总时长 / 当日麦上用户数，只统计 mic_online_ms > 0 的用户。"},
		{Key: "mic_online_ms", Label: "麦上总时长", Status: "available", Source: "user_mic_daily_stats.mic_online_ms", Definition: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"},
		{Key: "arpu_usd_minor", Label: "ARPU", Status: "available", Source: "stat_app_day_country + canonical Social DAU", Definition: "业务事实总充值 / canonical Social 活跃用户，返回美元最小单位。"},
		{Key: "arppu_usd_minor", Label: "ARPPU", Status: "available", Source: "stat_app_day_country", Definition: "总充值 / 付费用户，返回美元最小单位。"},
		{Key: "paid_conversion_rate", Label: "付费转化率", Status: "available", Source: "stat_recharge_day_payers + canonical Social DAU", Definition: "付费用户 / canonical Social 活跃用户；当前 Lalu 付费用户等同充值用户。"},
		{Key: "recharge_conversion_rate", Label: "充值转化率", Status: "available", Source: "stat_recharge_day_payers + canonical Social DAU", Definition: "充值用户 / canonical Social 活跃用户；当前 Lalu 充值用户由 paid_users 派生。"},
	}
}

func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
	filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	rows, err := r.db.QueryContext(ctx, `
		SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0),
		       COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0)
		FROM stat_self_game_h5_event_day
		WHERE `+filter+`
		GROUP BY game_id, event_name
		ORDER BY game_id, event_name`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []SelfGameEventStat{}
	for rows.Next() {
		var item SelfGameEventStat
		if err := rows.Scan(&item.GameID, &item.EventName, &item.EventCount, &item.UserCount, &item.SuccessCount, &item.ErrorCount, &item.DurationMSSum); err != nil {
			return nil, err
		}
		item.AvgDurationMS = div(item.DurationMSSum, item.EventCount)
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) {
	filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	row := r.db.QueryRowContext(ctx, `
		SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0),
		       COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0),
		       COALESCE(SUM(robot_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0),
		       COALESCE(SUM(winner_count),0), COALESCE(SUM(loser_count),0), COALESCE(SUM(draw_count),0),
		       COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0),
		       COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0),
		       COALESCE(SUM(forced_player_lose_matches),0), COALESCE(SUM(wait_ms_sum),0), COALESCE(SUM(duration_ms_sum),0)
		FROM stat_self_game_match_day
		WHERE `+filter, args...)
	var totals SelfGameMatchTotals
	if err := row.Scan(&totals.CreatedMatches, &totals.MatchedMatches, &totals.SettledMatches, &totals.CanceledMatches, &totals.FailedMatches, &totals.HumanMatches, &totals.RobotMatches, &totals.HumanParticipants, &totals.RobotParticipants, &totals.WinnerCount, &totals.LoserCount, &totals.DrawCount, &totals.UserStakeCoin, &totals.RobotStakeCoin, &totals.PayoutCoin, &totals.RefundCoin, &totals.PlatformProfitCoin, &totals.PoolDeltaCoin, &totals.ForcedPlayerLoseMatches, &totals.WaitMSSum, &totals.DurationMSSum); err != nil {
		return SelfGameMatchTotals{}, err
	}
	applySelfGameMatchDerived(&totals)
	return totals, nil
}

func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) {
	filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	rows, err := r.db.QueryContext(ctx, `
		SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0),
		       COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0),
		       COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(user_stake_coin),0),
		       COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0),
		       COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0)
		FROM stat_self_game_match_day
		WHERE `+filter+`
		GROUP BY game_id, stake_coin
		ORDER BY game_id, stake_coin`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []SelfGameStakeStat{}
	for rows.Next() {
		var item SelfGameStakeStat
		if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil {
			return nil, err
		}
		// platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund；
		// 机器人下注只是奖池和对手侧流水，不能进入抽水率分母，否则人机局会把平台抽水率稀释一半。
		item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin)
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) querySelfGamePools(ctx context.Context, app, statTZ, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) {
	statTZ = normalizeStatTZ(statTZ)
	filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
	args := []any{app, statTZ, startDay, endDay}
	if gameID != "" {
		filter += " AND game_id = ?"
		args = append(args, gameID)
	}
	latestArgs := append([]any{}, args...)
	latestArgs = append(latestArgs, app, statTZ)
	latestArgs = append(latestArgs, args...)
	rows, err := r.db.QueryContext(ctx, `
		SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count,
		       agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0)
		FROM (
			SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count,
			       COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin
			FROM stat_self_game_pool_day
			WHERE `+filter+`
			GROUP BY game_id, direction, reason
		) agg
		LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.stat_tz = ? AND latest.game_id = agg.game_id
		  AND latest.direction = agg.direction AND latest.reason = agg.reason
		  AND latest.stat_day = (
		    SELECT p2.stat_day
		    FROM stat_self_game_pool_day p2
		    WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason
		    ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC
		    LIMIT 1
		  )
		ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []SelfGamePoolStat{}
	for rows.Next() {
		var item SelfGamePoolStat
		if err := rows.Scan(&item.GameID, &item.Direction, &item.Reason, &item.TransactionCount, &item.InCoin, &item.OutCoin, &item.BalanceAfterCoin); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) {
	filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	rows, err := r.db.QueryContext(ctx, `
		SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point,
		       COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
		FROM stat_self_game_participant_day
		WHERE `+filter+`
		GROUP BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point
		ORDER BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := []SelfGameParticipantStat{}
	totals := map[string]int64{}
	for rows.Next() {
		var item SelfGameParticipantStat
		if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil {
			return nil, err
		}
		key := selfGameParticipantRateKey(item)
		totals[key] += item.ParticipantCount
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	for index := range items {
		key := selfGameParticipantRateKey(items[index])
		if items[index].Result == "win" {
			items[index].WinRate = ratio(items[index].ParticipantCount, totals[key])
		}
	}
	return items, nil
}

func selfGameParticipantRateKey(item SelfGameParticipantStat) string {
	playKey := item.RPSGesture
	if item.DicePoint > 0 {
		playKey = strconv.FormatInt(item.DicePoint, 10)
	}
	// 玩法分布查询本身按 stake_coin 分桶返回，胜率分母也必须保留同一档位；
	// 否则同一点数或同一手势在不同下注档位的结果会互相稀释，前端每一行的胜率就不是该行口径。
	return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey
}

func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
	filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	rows, err := r.db.QueryContext(ctx, `
		SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0),
		       COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0),
		       COALESCE(SUM(stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
		FROM stat_self_game_user_day
		WHERE `+filter+`
		GROUP BY game_id, user_id
		ORDER BY SUM(match_count) DESC, SUM(net_win_coin) DESC, SUM(cancel_count) DESC
		LIMIT 100`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []SelfGameUserRiskStat{}
	for rows.Next() {
		var item SelfGameUserRiskStat
		if err := rows.Scan(&item.GameID, &item.UserID, &item.MatchCount, &item.WinCount, &item.LoseCount, &item.DrawCount, &item.CancelCount, &item.StakeCoin, &item.PayoutCoin, &item.NetWinCoin); err != nil {
			return nil, err
		}
		item.WinRate = ratio(item.WinCount, item.MatchCount)
		item.CancelRate = ratio(item.CancelCount, item.MatchCount+item.CancelCount)
		out = append(out, item)
	}
	return out, rows.Err()
}

func (r *Repository) querySelfGameRetention(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) {
	cohortFilter, cohortArgs := selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
	returnFilter, returnArgs := selfGameUserDayFilter(app, statTZ, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID)
	args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2)
	args = append(args, cohortArgs...)
	args = append(args, returnArgs...)
	args = append(args, returnArgs...)
	row := r.db.QueryRowContext(ctx, `
		SELECT COUNT(1),
		       COALESCE(SUM(CASE WHEN d1.user_id IS NULL THEN 0 ELSE 1 END), 0),
		       COALESCE(SUM(CASE WHEN d7.user_id IS NULL THEN 0 ELSE 1 END), 0)
		FROM (
			SELECT DISTINCT stat_day, user_id
			FROM stat_self_game_user_day
			WHERE `+cohortFilter+`
		) cohort
		LEFT JOIN (
			SELECT DISTINCT stat_day, user_id
			FROM stat_self_game_user_day
			WHERE `+returnFilter+`
		) d1 ON d1.user_id = cohort.user_id AND d1.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 1 DAY)
		LEFT JOIN (
			SELECT DISTINCT stat_day, user_id
			FROM stat_self_game_user_day
			WHERE `+returnFilter+`
		) d7 ON d7.user_id = cohort.user_id AND d7.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 7 DAY)`, args...)
	var retention SelfGameRetention
	if err := row.Scan(&retention.CohortUsers, &retention.Day1Users, &retention.Day7Users); err != nil {
		return SelfGameRetention{}, err
	}
	retention.Day1Rate = ratio(retention.Day1Users, retention.CohortUsers)
	retention.Day7Rate = ratio(retention.Day7Users, retention.CohortUsers)
	return retention, nil
}

func selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
	filter := "app_code = ? AND stat_tz = ? AND match_count > 0"
	args := []any{app, normalizeStatTZ(statTZ)}
	if startDay != "" && endDay != "" {
		filter += " AND stat_day BETWEEN ? AND ?"
		args = append(args, startDay, endDay)
	}
	if gameID != "" {
		filter += " AND game_id = ?"
		args = append(args, gameID)
	}
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	return filter, args
}

func selfGameFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
	filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	if gameID != "" {
		filter += " AND game_id = ?"
		args = append(args, gameID)
	}
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	return filter, args
}

func applySelfGameMatchDerived(totals *SelfGameMatchTotals) {
	if totals == nil {
		return
	}
	totals.AvgWaitMS = div(totals.WaitMSSum, totals.MatchedMatches+totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
	totals.AvgDurationMS = div(totals.DurationMSSum, totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
	totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches)
	totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches)
	totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches)
	// platform_profit_coin 与真实用户下注同口径，分母只使用 user_stake_coin；
	// robot_stake_coin 是机器人对手侧流水，展示总下注时可以合并，但不能用于真实用户侧抽水率。
	totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin)
}

func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) {
	order := []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"}
	byEvent := map[string]SelfGameFunnelStep{}
	for _, event := range events {
		step := byEvent[event.EventName]
		step.EventName = event.EventName
		step.EventCount += event.EventCount
		step.UserCount += event.UserCount
		byEvent[event.EventName] = step
	}
	funnel := make([]SelfGameFunnelStep, 0, len(order))
	for _, name := range order {
		funnel = append(funnel, byEvent[name])
		funnel[len(funnel)-1].EventName = name
	}
	return funnel, SelfGameConversion{
		PageToMatchClickRate:      ratio(byEvent["match_click"].UserCount, byEvent["page_open"].UserCount),
		MatchClickToSuccessRate:   ratio(byEvent["match_success"].UserCount, byEvent["match_click"].UserCount),
		SuccessToSettlementRate:   ratio(byEvent["settlement_show"].UserCount, byEvent["match_success"].UserCount),
		SettlementToPlayAgainRate: ratio(byEvent["play_again"].UserCount, byEvent["settlement_show"].UserCount),
		PageToSettlementRate:      ratio(byEvent["settlement_show"].UserCount, byEvent["page_open"].UserCount),
	}
}

type appTrackingStepDef struct {
	Key       string
	EventName string
	Label     string
	IsFailure bool
}

var appTrackingStepDefs = []appTrackingStepDef{
	{Key: "login_start", EventName: "login_start", Label: "登录开始"},
	{Key: "login_success", EventName: "login_success", Label: "登录成功"},
	{Key: "profile_start", EventName: "profile_start", Label: "资料开始"},
	{Key: "profile_complete", EventName: "profile_complete", Label: "资料完成"},
	{Key: "home_room_impression", EventName: "home_room_impression", Label: "首页房间曝光"},
	{Key: "home_room_click", EventName: "home_room_click", Label: "首页房间点击"},
	{Key: "room_join_success", EventName: "room_join_success", Label: "进房成功"},
	{Key: "room_join_fail", EventName: "room_join_fail", Label: "进房失败", IsFailure: true},
	{Key: "stay_30s", EventName: "stay_30s", Label: "停留 30 秒"},
	{Key: "stay_3m", EventName: "stay_3m", Label: "停留 3 分钟"},
	{Key: "stay_10m", EventName: "stay_10m", Label: "停留 10 分钟"},
	{Key: "mic_up", EventName: "mic_up", Label: "上麦"},
	{Key: "send_message", EventName: "send_message", Label: "发消息"},
	{Key: "follow_host", EventName: "follow_host", Label: "关注主播"},
	{Key: "add_friend", EventName: "add_friend", Label: "加好友"},
	{Key: "check_in", EventName: "check_in", Label: "签到"},
	{Key: "push_permission", EventName: "push_permission", Label: "Push 授权"},
}

type appTrackingCohortDef struct {
	Key   string
	Label string
}

var appTrackingCohortDefs = []appTrackingCohortDef{
	{Key: "country", Label: "国家"},
	{Key: "language", Label: "语言"},
	{Key: "channel", Label: "渠道"},
	{Key: "login_method", Label: "登录方式"},
	{Key: "first_room_stay", Label: "首房停留时长"},
}

type appTrackingStepAggregate struct {
	EventName   string
	EventCount  int64
	UserCount   int64
	DeviceCount int64
}

func (r *Repository) queryAppTrackingFunnelSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]appTrackingStepAggregate, error) {
	filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
	names := appTrackingEventNames()
	placeholders := sqlPlaceholders(len(names))
	for _, name := range names {
		args = append(args, name)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT event_name, COUNT(*), COUNT(DISTINCT `+appTrackingIdentitySQL("")+`), COUNT(DISTINCT NULLIF(device_id, ''))
		FROM app_tracking_events FORCE INDEX (idx_app_tracking_time_window)
		WHERE `+filter+` AND event_name IN (`+placeholders+`)
		GROUP BY event_name`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []appTrackingStepAggregate{}
	for rows.Next() {
		var item appTrackingStepAggregate
		if err := rows.Scan(&item.EventName, &item.EventCount, &item.UserCount, &item.DeviceCount); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, rows.Err()
}

func buildAppTrackingFunnelSteps(aggregates []appTrackingStepAggregate) []AppTrackingFunnelStep {
	byEvent := map[string]appTrackingStepAggregate{}
	for _, item := range aggregates {
		byEvent[item.EventName] = item
	}
	out := make([]AppTrackingFunnelStep, 0, len(appTrackingStepDefs))
	var baseUsers int64
	var previousUsers int64
	for index, def := range appTrackingStepDefs {
		aggregate := byEvent[def.EventName]
		if index == 0 {
			baseUsers = aggregate.UserCount
		}
		step := AppTrackingFunnelStep{
			Key:                    def.Key,
			EventName:              def.EventName,
			Label:                  def.Label,
			EventCount:             aggregate.EventCount,
			UserCount:              aggregate.UserCount,
			DeviceCount:            aggregate.DeviceCount,
			OverallConversionRate:  ratio(aggregate.UserCount, baseUsers),
			PreviousConversionRate: ratio(aggregate.UserCount, previousUsers),
			IsFailure:              def.IsFailure,
		}
		if previousUsers > aggregate.UserCount {
			step.DropoffUsers = previousUsers - aggregate.UserCount
		}
		out = append(out, step)
		previousUsers = aggregate.UserCount
	}
	return out
}

func appTrackingTotals(steps []AppTrackingFunnelStep) AppTrackingFunnelTotals {
	byEvent := map[string]int64{}
	for _, step := range steps {
		byEvent[step.EventName] = step.UserCount
	}
	return AppTrackingFunnelTotals{
		LoginStartUsers:      byEvent["login_start"],
		LoginSuccessUsers:    byEvent["login_success"],
		ProfileCompleteUsers: byEvent["profile_complete"],
		RoomImpressionUsers:  byEvent["home_room_impression"],
		RoomClickUsers:       byEvent["home_room_click"],
		RoomJoinSuccessUsers: byEvent["room_join_success"],
		RoomJoinFailUsers:    byEvent["room_join_fail"],
		Stay30sUsers:         byEvent["stay_30s"],
		Stay3mUsers:          byEvent["stay_3m"],
		Stay10mUsers:         byEvent["stay_10m"],
		SendMessageUsers:     byEvent["send_message"],
		FollowHostUsers:      byEvent["follow_host"],
		AddFriendUsers:       byEvent["add_friend"],
		CheckInUsers:         byEvent["check_in"],
		PushPermissionUsers:  byEvent["push_permission"],
	}
}

func (r *Repository) queryAppTrackingD1Cohorts(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (int64, int64, []AppTrackingFunnelCohort, error) {
	filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
	totalArgs := append([]any{}, filterArgs...)
	dimensionSQL, dimensionArgs := appTrackingCohortUnionSQL(appTrackingD1CohortSourceSQL, filter, filterArgs, "source.stat_day, source.identity, source.value")
	args := append(totalArgs, dimensionArgs...)
	args = append(args, app)
	// retention_base 对每个 stat_day+identity 只判断一次次日活跃，再与五个维度关联；旧实现会为每个维度重复执行同一 EXISTS。
	rows, err := r.db.QueryContext(ctx, `
		WITH total_base AS (
			SELECT DISTINCT stat_day, `+appTrackingIdentitySQL("")+` AS identity
			FROM app_tracking_events
			WHERE `+filter+` AND event_name = 'login_success'
		),
		dimension_base AS (`+dimensionSQL+`),
		retention_base AS (
			SELECT total_base.stat_day, total_base.identity,
			       CASE WHEN EXISTS (
			         SELECT 1 FROM app_tracking_events n
			         WHERE n.app_code = ? AND n.stat_day = DATE_ADD(total_base.stat_day, INTERVAL 1 DAY)
			           AND `+appTrackingIdentitySQL("n")+` = total_base.identity
			         LIMIT 1
			       ) THEN 1 ELSE 0 END AS retained
			FROM total_base
		),
		dimension_aggregated AS (
			SELECT dimension_base.dimension, dimension_base.value, COUNT(*) AS base_users,
			       COALESCE(SUM(retention_base.retained), 0) AS retained_users
			FROM dimension_base
			JOIN retention_base ON retention_base.stat_day = dimension_base.stat_day
			                   AND retention_base.identity = dimension_base.identity
			GROUP BY dimension_base.dimension, dimension_base.value
		),
		dimension_ranked AS (
			SELECT dimension, value, base_users, retained_users,
			       ROW_NUMBER() OVER (PARTITION BY dimension ORDER BY base_users DESC, value ASC) AS dimension_rank
			FROM dimension_aggregated
		)
		SELECT '' AS dimension, '' AS value, COUNT(*) AS base_users,
		       COALESCE(SUM(retained), 0) AS retained_users, 0 AS dimension_rank
		FROM retention_base
		UNION ALL
		SELECT dimension, value, base_users, retained_users, dimension_rank
		FROM dimension_ranked
		WHERE dimension_rank <= 50
		ORDER BY dimension ASC, dimension_rank ASC`, args...)
	if err != nil {
		return 0, 0, nil, err
	}
	defer rows.Close()
	var baseUsers int64
	var retainedUsers int64
	byDimension := map[string][]AppTrackingFunnelCohort{}
	labels := map[string]string{}
	for _, def := range appTrackingCohortDefs {
		labels[def.Key] = def.Label
	}
	for rows.Next() {
		var dimension string
		var value sql.NullString
		var rowBaseUsers, rowRetainedUsers, dimensionRank int64
		if err := rows.Scan(&dimension, &value, &rowBaseUsers, &rowRetainedUsers, &dimensionRank); err != nil {
			return 0, 0, nil, err
		}
		if dimension == "" {
			baseUsers = rowBaseUsers
			retainedUsers = rowRetainedUsers
			continue
		}
		cohortValue := normalizeCohortValue(value.String)
		byDimension[dimension] = append(byDimension[dimension], AppTrackingFunnelCohort{
			Dimension:        dimension,
			DimensionLabel:   labels[dimension],
			Value:            cohortValue,
			Label:            cohortValue,
			BaseUsers:        rowBaseUsers,
			D1RetentionUsers: rowRetainedUsers,
			D1RetentionRate:  ratio(rowRetainedUsers, rowBaseUsers),
			Steps:            []AppTrackingCohortStepCount{},
		})
	}
	if err := rows.Err(); err != nil {
		return 0, 0, nil, err
	}
	stepCounts, err := r.queryAppTrackingCohortSteps(ctx, app, startMS, endMS, countryID, regionID, regionIDs)
	if err != nil {
		return 0, 0, nil, err
	}
	out := []AppTrackingFunnelCohort{}
	for _, def := range appTrackingCohortDefs {
		for _, cohort := range byDimension[def.Key] {
			cohort.Steps = buildAppTrackingCohortSteps(stepCounts[def.Key][cohort.Value])
			out = append(out, cohort)
		}
	}
	return baseUsers, retainedUsers, out, nil
}

type appTrackingCohortSourceBuilder func(dimension string, filter string) string

func appTrackingCohortUnionSQL(builder appTrackingCohortSourceBuilder, filter string, filterArgs []any, columns string) (string, []any) {
	parts := make([]string, 0, len(appTrackingCohortDefs))
	args := []any{}
	for _, def := range appTrackingCohortDefs {
		parts = append(parts, "SELECT ? AS dimension, "+columns+" FROM ("+builder(def.Key, filter)+") source")
		args = append(args, def.Key)
		args = append(args, appTrackingCohortSourceArgs(def.Key, filterArgs)...)
	}
	return strings.Join(parts, "\nUNION ALL\n"), args
}

func (r *Repository) queryAppTrackingCohortSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (map[string]map[string]map[string]int64, error) {
	filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
	cohortSQL, args := appTrackingCohortUnionSQL(appTrackingStepCohortSourceSQL, filter, filterArgs, "source.identity, source.value")
	eventFilter, eventArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
	args = append(args, eventArgs...)
	names := appTrackingEventNames()
	placeholders := sqlPlaceholders(len(names))
	for _, name := range names {
		args = append(args, name)
	}
	rows, err := r.db.QueryContext(ctx, `
		WITH cohort AS (`+cohortSQL+`),
		     events AS (
		       SELECT DISTINCT `+appTrackingIdentitySQL("")+` AS identity, event_name
		       -- 同一窗口会覆盖几乎全部漏斗事件；强制时间优先覆盖索引，避免优化器只按 app_code 扫描主键后再回表去重。
		       FROM app_tracking_events FORCE INDEX (idx_app_tracking_time_window)
		       WHERE `+eventFilter+` AND event_name IN (`+placeholders+`)
		     )
		SELECT cohort.dimension, cohort.value, events.event_name, COUNT(DISTINCT cohort.identity)
		FROM cohort
		JOIN events ON events.identity = cohort.identity
		GROUP BY cohort.dimension, cohort.value, events.event_name`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]map[string]map[string]int64{}
	for rows.Next() {
		var dimension string
		var value sql.NullString
		var eventName string
		var userCount int64
		if err := rows.Scan(&dimension, &value, &eventName, &userCount); err != nil {
			return nil, err
		}
		cohortValue := normalizeCohortValue(value.String)
		if out[dimension] == nil {
			out[dimension] = map[string]map[string]int64{}
		}
		if out[dimension][cohortValue] == nil {
			out[dimension][cohortValue] = map[string]int64{}
		}
		out[dimension][cohortValue][eventName] = userCount
	}
	return out, rows.Err()
}

func buildAppTrackingCohortSteps(counts map[string]int64) []AppTrackingCohortStepCount {
	out := make([]AppTrackingCohortStepCount, 0, len(appTrackingStepDefs))
	for _, def := range appTrackingStepDefs {
		out = append(out, AppTrackingCohortStepCount{
			EventName: def.EventName,
			Label:     def.Label,
			UserCount: counts[def.EventName],
		})
	}
	return out
}

func appTrackingD1CohortSourceSQL(dimension string, filter string) string {
	if dimension == "first_room_stay" {
		return `
			SELECT base.stat_day, base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
			FROM (
				SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
				FROM app_tracking_events
				WHERE ` + filter + ` AND event_name = 'login_success'
			) base
			LEFT JOIN (
				SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
				       MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
				FROM app_tracking_events
				WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
				GROUP BY stat_day, identity
			) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
	}
	return `
		SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
		FROM app_tracking_events
		WHERE ` + filter + ` AND event_name = 'login_success'`
}

func appTrackingStepCohortSourceSQL(dimension string, filter string) string {
	if dimension == "first_room_stay" {
		return `
			SELECT DISTINCT base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
			FROM (
				SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
				FROM app_tracking_events
				WHERE ` + filter + ` AND event_name = 'login_success'
			) base
			LEFT JOIN (
				SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
				       MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
				FROM app_tracking_events
				WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
				GROUP BY stat_day, identity
			) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
	}
	return `
		SELECT DISTINCT ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
		FROM app_tracking_events
		WHERE ` + filter + ` AND event_name = 'login_success'`
}

func appTrackingCohortSourceArgs(dimension string, filterArgs []any) []any {
	args := append([]any{}, filterArgs...)
	if dimension == "first_room_stay" {
		args = append(args, filterArgs...)
	}
	return args
}

func appTrackingStayBucketSQL(rankExpr string) string {
	return "CASE COALESCE(" + rankExpr + ",0) WHEN 3 THEN '10m+' WHEN 2 THEN '3m-10m' WHEN 1 THEN '30s-3m' ELSE '<30s' END"
}

func appTrackingCohortValueSQL(dimension string, alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	jsonValue := func(path string) string {
		return "NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(" + prefix + "properties_json, '$." + path + "'))), '')"
	}
	switch dimension {
	case "country":
		return "COALESCE(" + jsonValue("country") + ", IF(" + prefix + "country_id > 0, CAST(" + prefix + "country_id AS CHAR), 'unknown'))"
	case "language":
		return "COALESCE(NULLIF(TRIM(" + prefix + "language), ''), " + jsonValue("language") + ", 'unknown')"
	case "channel":
		return "COALESCE(" + jsonValue("channel") + ", " + jsonValue("utm_source") + ", 'unknown')"
	case "login_method":
		return "COALESCE(" + jsonValue("login_method") + ", " + jsonValue("login_provider") + ", " + jsonValue("method") + ", " + jsonValue("provider") + ", 'unknown')"
	default:
		return "'unknown'"
	}
}

func appTrackingFilter(app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (string, []any) {
	filter := "app_code = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?"
	args := []any{app, startMS, endMS}
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
		return filter, args
	}
	regionIDs = normalizeTrackingRegionIDs(regionIDs)
	if len(regionIDs) > 0 {
		filter += " AND region_id IN (" + sqlPlaceholders(len(regionIDs)) + ")"
		for _, id := range regionIDs {
			args = append(args, id)
		}
	}
	return filter, args
}

func normalizeTrackingRegionIDs(regionIDs []int64) []int64 {
	out := make([]int64, 0, len(regionIDs))
	seen := map[int64]struct{}{}
	for _, id := range regionIDs {
		if id <= 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

func appTrackingIdentitySQL(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	// identity_key is a generated column indexed by the statistics schema.
	// Keeping identity derivation out of hot funnel SQL lets MySQL use window
	// and retention indexes instead of rebuilding CONCAT keys for every row.
	return prefix + "identity_key"
}

func appTrackingEventNames() []string {
	out := make([]string, 0, len(appTrackingStepDefs))
	for _, def := range appTrackingStepDefs {
		out = append(out, def.EventName)
	}
	return out
}

func sqlPlaceholders(count int) string {
	if count <= 0 {
		return "NULL"
	}
	parts := make([]string, count)
	for index := range parts {
		parts[index] = "?"
	}
	return strings.Join(parts, ",")
}

func normalizeCohortValue(value string) string {
	value = strings.TrimSpace(value)
	if value == "" || strings.EqualFold(value, "null") {
		return "unknown"
	}
	return value
}

func normalizeRange(startMS, endMS int64) (int64, int64) {
	if startMS <= 0 {
		now := time.Now().UTC()
		startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
	}
	if endMS <= startMS {
		endMS = startMS + 24*time.Hour.Milliseconds()
	}
	return startMS, endMS
}

func normalizePlatformGrantCoinQuery(query PlatformGrantCoinQuery) (string, string, string, string, int, int, int) {
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = "lalu"
	}
	statTZ := normalizeStatTZ(query.StatTZ)
	startDay := strings.TrimSpace(query.StatDay)
	endDay := startDay
	if startDay == "" {
		startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
		startDay = statDayIn(startMS, statTZ)
		endDay = statDayIn(endMS-1, statTZ)
	}
	page := query.Page
	if page < 1 {
		page = 1
	}
	pageSize := query.PageSize
	if pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return app, statTZ, startDay, endDay, page, pageSize, (page - 1) * pageSize
}

func platformGrantCoinFilter(app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, userID int64, eventType string) (string, []any) {
	filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?"
	args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
	if countryID > 0 {
		filter += " AND country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND region_id = ?"
		args = append(args, regionID)
	}
	if userID > 0 {
		filter += " AND user_id = ?"
		args = append(args, userID)
	}
	if normalizedEventType := strings.TrimSpace(eventType); normalizedEventType != "" && normalizedEventType != "all" {
		filter += " AND event_type = ?"
		args = append(args, normalizedEventType)
	}
	return filter, args
}

func previousStatDayRange(startDay, endDay string) (string, string) {
	start, err := time.Parse("2006-01-02", startDay)
	if err != nil {
		start = time.Now().UTC()
	}
	end, err := time.Parse("2006-01-02", endDay)
	if err != nil || end.Before(start) {
		end = start
	}
	// Databi 的日期窗口按 UTC stat_day 聚合；上一周期采用同样天数，今日就是昨天，近 7 日就是前 7 日。
	dayCount := int(end.Sub(start)/(24*time.Hour)) + 1
	if dayCount < 1 {
		dayCount = 1
	}
	previousEnd := start.AddDate(0, 0, -1)
	previousStart := start.AddDate(0, 0, -dayCount)
	return previousStart.Format("2006-01-02"), previousEnd.Format("2006-01-02")
}

func addStatDays(day string, offset int) string {
	parsed, err := time.Parse("2006-01-02", day)
	if err != nil {
		return day
	}
	return parsed.AddDate(0, 0, offset).Format("2006-01-02")
}

func div(numerator, denominator int64) int64 {
	if denominator <= 0 {
		return 0
	}
	return numerator / denominator
}

func deltaRate(current, previous int64) float64 {
	if previous == 0 {
		if current == 0 {
			return 0
		}
		if current > 0 {
			return 1
		}
		return -1
	}
	return float64(current-previous) / math.Abs(float64(previous))
}

func percentagePointDelta(current, previous float64) float64 {
	return (current - previous) * 100
}

func ratio(numerator, denominator int64) float64 {
	if denominator <= 0 {
		return 0
	}
	return float64(numerator) / float64(denominator)
}

func retentionPointers(users, base int64) (*int64, *int64, *float64) {
	if base <= 0 {
		return nil, nil, nil
	}
	usersValue := users
	baseValue := base
	rateValue := ratio(users, base)
	return &usersValue, &baseValue, &rateValue
}

func selfGameMetricDefinitions() []MetricDefinition {
	return []MetricDefinition{
		{Metric: "game_pv", Definition: "H5 page_open event_count；同一用户多次打开会重复计数。"},
		{Metric: "game_uv", Definition: "H5 page_open user_count；同一 UTC 日、同一 game_id、同一用户只计一次。"},
		{Metric: "match_click_users", Definition: "H5 match_click user_count；用户点击匹配按钮后上报。"},
		{Metric: "match_success_users", Definition: "H5 match_success user_count；匹配接口成功返回有效 match_id 后上报。"},
		{Metric: "settlement_users", Definition: "H5 settlement_show user_count；结算页展示时上报。"},
		{Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户；只统计 stat_self_game_user_day.match_count > 0。"},
		{Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局；game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
		{Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局；game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
		{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin；抽水率分母同样只用 user_stake_coin。"},
		{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合；in_coin/out_coin 是入池/出池金额，balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"},
	}
}
