package mysql

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

	"hyapp/pkg/appcode"
)

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

type Overview struct {
	AppCode                     string              `json:"app_code"`
	StartMS                     int64               `json:"start_ms"`
	EndMS                       int64               `json:"end_ms"`
	CountryID                   int64               `json:"country_id"`
	NewUsers                    int64               `json:"new_users"`
	ActiveUsers                 int64               `json:"active_users"`
	PaidUsers                   int64               `json:"paid_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"`
	MifaPayRechargeUSDMinor     int64               `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor      int64               `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin      int64               `json:"coin_seller_transfer_coin"`
	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"`
	ARPUUSDMinor                int64               `json:"arpu_usd_minor"`
	ARPPUUSDMinor               int64               `json:"arppu_usd_minor"`
	UPValueUSDMinor             int64               `json:"up_value_usd_minor"`
	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"`
	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"`
	GameRanking                 []GameRank          `json:"game_ranking"`
	LuckyGiftPools              []LuckyGiftPoolStat `json:"lucky_gift_pools"`
	CountryBreakdown            []CountryBreakdown  `json:"country_breakdown"`
}

type DailySeriesItem struct {
	StatDay                    string `json:"stat_day"`
	Label                      string `json:"label"`
	NewUsers                   int64  `json:"new_users"`
	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"`
	MifaPayRechargeUSDMinor    int64  `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor     int64  `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin     int64  `json:"coin_seller_transfer_coin"`
	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"`
	ARPUUSDMinor               int64  `json:"arpu_usd_minor"`
	ARPPUUSDMinor              int64  `json:"arppu_usd_minor"`
}

type Retention struct {
	RegisteredUsers int64   `json:"registered_users"`
	Day1Users       int64   `json:"day1_users"`
	Day7Users       int64   `json:"day7_users"`
	Day30Users      int64   `json:"day30_users"`
	Day1Rate        float64 `json:"day1_rate"`
	Day7Rate        float64 `json:"day7_rate"`
	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 CountryBreakdown struct {
	CountryID                  int64   `json:"country_id"`
	RegionID                   int64   `json:"region_id"`
	NewUsers                   int64   `json:"new_users"`
	ActiveUsers                int64   `json:"active_users"`
	PaidUsers                  int64   `json:"paid_users"`
	RechargeUSDMinor           int64   `json:"recharge_usd_minor"`
	NewUserRechargeUSDMinor    int64   `json:"new_user_recharge_usd_minor"`
	CoinSellerRechargeUSDMinor int64   `json:"coin_seller_recharge_usd_minor"`
	MifaPayRechargeUSDMinor    int64   `json:"mifapay_recharge_usd_minor"`
	GoogleRechargeUSDMinor     int64   `json:"google_recharge_usd_minor"`
	CoinSellerTransferCoin     int64   `json:"coin_seller_transfer_coin"`
	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"`
	ARPUUSDMinor               int64   `json:"arpu_usd_minor"`
	ARPPUUSDMinor              int64   `json:"arppu_usd_minor"`
}

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

type SelfGameOverview struct {
	AppCode                 string                    `json:"app_code"`
	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 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"
	}
	startDay := statDay(query.StartMS)
	endDay := statDay(query.EndMS - 1)
	previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
	overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
	previous.AppCode, previous.CountryID = app, query.CountryID
	// 新增付费用户来自日充值用户去重表和注册表的交集；只读聚合小表，避免 Databi 页面回扫钱包或订单明细。
	if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	}
	if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	}
	// Pool 明细和顶部汇总来自同一个统计窗口；Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
	pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.LuckyGiftPools = pools
	// 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准；这里仅读小表，避免 Databi 页面扫描 lucky_draw_records。
	if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	} else if ok {
		overview.LuckyGiftPools = activityPools
		overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
	}
	// 上一周期也使用相同的日聚合口径，保证“与昨日相比/前周期相比”不额外查询明细表。
	if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
		return Overview{}, err
	} else if ok {
		previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
	}
	applyOverviewDerivedMetrics(&overview)
	applyOverviewDerivedMetrics(&previous)
	overview.applyDeltaRates(previous)
	retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.Retention = retention
	ranking, err := r.queryGameRanking(ctx, app, 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, startDay, endDay, query.RegionID)
		if err != nil {
			return Overview{}, err
		}
		overview.CountryBreakdown = breakdown
	}
	seriesStartDay, seriesEndDay := startDay, endDay
	if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
		seriesStartDay = statDay(query.SeriesStartMS)
		seriesEndDay = statDay(query.SeriesEndMS - 1)
	}
	// daily_series 服务卡片内 sparkline 和弹窗折线图；一条 GROUP BY stat_day 查询返回全部指标，避免前端逐日请求。
	dailySeries, err := r.queryDailySeries(ctx, app, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
	if err != nil {
		return Overview{}, err
	}
	overview.DailySeries = dailySeries
	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"
	}
	startDay := statDay(startMS)
	endDay := statDay(endMS - 1)
	gameID := normalizeShortText(query.GameID)
	overview := SelfGameOverview{
		AppCode:   app,
		StartMS:   startMS,
		EndMS:     endMS,
		GameID:    gameID,
		CountryID: query.CountryID,
		RegionID:  query.RegionID,
	}
	events, err := r.querySelfGameEvents(ctx, app, 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, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.MatchTotals = totals
	stakes, err := r.querySelfGameStakeBreakdown(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.StakeBreakdown = stakes
	pools, err := r.querySelfGamePools(ctx, app, startDay, endDay, gameID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.PoolStats = pools
	participants, err := r.querySelfGameParticipantDistribution(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.ParticipantDistribution = participants
	risk, err := r.querySelfGameUserRisk(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
	if err != nil {
		return SelfGameOverview{}, err
	}
	overview.UserRisk = risk
	retention, err := r.querySelfGameRetention(ctx, app, 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) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
	args := []any{app, 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(active_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(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_day BETWEEN ? AND ?`+filter, args...)
	var overview Overview
	if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &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, startDay, endDay string, countryID int64, regionID int64) (int64, error) {
	args := []any{app, startDay, endDay, 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.user_id = p.user_id
		WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
		  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) queryDailySeries(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) {
	args := []any{app, 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(active_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(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_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.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &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, startDay, endDay, countryID, regionID)
	if err != nil {
		return nil, err
	}
	for index := range out {
		out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
	}
	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 out, nil
}

func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
	args := []any{app, startDay, endDay, 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.user_id = p.user_id
		WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
		  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()
}

func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
	if item == nil {
		return
	}
	channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
	if channelRechargeUSDMinor > item.RechargeUSDMinor {
		item.RechargeUSDMinor = channelRechargeUSDMinor
	}
	item.RechargeUsers = item.PaidUsers
	item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
	item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
	item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
}

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
	}
	overview.GameProfit = overview.GameTurnover - overview.GamePayout - overview.GameRefund
	overview.GameProfitRate = ratio(overview.GameProfit, overview.GameTurnover)
	overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
	overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
	overview.UPValueUSDMinor = overview.ARPPUUSDMinor
	overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
	overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
	overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
}

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.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) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Retention, error) {
	filter := ""
	args := []any{app, startDay, endDay}
	if countryID > 0 {
		filter = " AND r.country_id = ?"
		args = append(args, countryID)
	}
	if regionID > 0 {
		filter += " AND r.region_id = ?"
		args = append(args, regionID)
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*),
		       COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
		       COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0),
		       COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0)
		FROM stat_user_registration r
		WHERE r.app_code = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
	var out Retention
	if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows {
		return Retention{}, err
	}
	out.Day1Rate = ratio(out.Day1Users, out.RegisteredUsers)
	out.Day7Rate = ratio(out.Day7Users, out.RegisteredUsers)
	out.Day30Rate = ratio(out.Day30Users, out.RegisteredUsers)
	return out, nil
}

func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
	args := []any{app, 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_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, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
	args := []any{app, 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(active_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(mifapay_recharge_usd_minor),0),
		       COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),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_day BETWEEN ? AND ?`+filter+`
		GROUP BY country_id
		ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC
		LIMIT 500`, 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.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &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)
	}
	return out, rows.Err()
}

func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
	if item == nil {
		return
	}
	channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
	if channelRechargeUSDMinor > item.RechargeUSDMinor {
		item.RechargeUSDMinor = channelRechargeUSDMinor
	}
	item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
	item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
	item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
	item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
	item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
	item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
	item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
}

func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
	args := []any{app, 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_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
		}
		items[index].LuckyGiftTurnover = activity.TurnoverCoin
		items[index].LuckyGiftPayout = 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 (r *Repository) querySelfGameEvents(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
	filter, args := selfGameFilter(app, 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, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) {
	filter, args := selfGameFilter(app, 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, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) {
	filter, args := selfGameFilter(app, 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, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) {
	filter := "app_code = ? AND stat_day BETWEEN ? AND ?"
	args := []any{app, startDay, endDay}
	if gameID != "" {
		filter += " AND game_id = ?"
		args = append(args, gameID)
	}
	latestArgs := append([]any{}, args...)
	latestArgs = append(latestArgs, app)
	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.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, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) {
	filter, args := selfGameFilter(app, 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, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
	filter, args := selfGameFilter(app, 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, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) {
	cohortFilter, cohortArgs := selfGameUserDayFilter(app, startDay, endDay, gameID, countryID, regionID)
	returnFilter, returnArgs := selfGameUserDayFilter(app, 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, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
	filter := "app_code = ? AND match_count > 0"
	args := []any{app}
	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, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
	filter := "app_code = ? AND stat_day BETWEEN ? AND ?"
	args := []any{app, 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),
	}
}

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 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 ratio(numerator, denominator int64) float64 {
	if denominator <= 0 {
		return 0
	}
	return float64(numerator) / float64(denominator)
}

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 是查询窗口内同方向同原因的最新余额快照。"},
	}
}
