package dashboard

import (
	"context"
	"database/sql"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"hyapp-admin-server/internal/config"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

const (
	aslanMongoInAppPurchaseCollection = "in_app_purchase_details"
	aslanMongoUserProfileCollection   = "user_run_profile"
	aslanMongoUserActiveCollection    = "user_daily_active_log"
	aslanMongoTeamMemberCollection    = "team_member"
	aslanMongoBankBalanceCollection   = "user_bank_balance"
	aslanMongoGiftWaterCollection     = "gift_give_running_water"
	aslanMongoGoldWaterCollection     = "user_gold_running_water_v2"
	aslanMongoChunkSize               = 800

	// 幸运礼物返奖和币商出货各自拥有稳定、单一语义的 origin 前缀，因此这两类可按前缀聚合；
	// 游戏命名空间混有排行榜/活动奖励，游戏流水必须走下方 exact code 白名单，不能复用此前缀策略。
	aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD"
	aslanMongoSellerAgentOriginPrefix     = "SELLER_AGENT"
)

var (
	aslanMongoFreightCommodityTypes  = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"}
	aslanMongoFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"}

	// 游戏流水只能消费 likei 已确认的具体 GoldOrigin code，不能用 *_GAME_% 前缀兜底：
	// 同一命名空间里已经存在 HOT_GAME_200 排行榜奖励和 BAISHUN_GAME_1085 活动奖励，
	// 前缀匹配会把平台发奖误算成游戏返奖。新增游戏必须先在 likei 的游戏配置中确认业务语义，
	// 再显式追加到这里，确保 Databi 历史口径不会随着第三方传入任意 gameUid 静默漂移。
	aslanLegacyGameEventTypes = []string{
		"HOT_GAME_201", "HOT_GAME_202", "HOT_GAME_203", "HOT_GAME_204",
		"HOT_GAME_205", "HOT_GAME_206", "HOT_GAME_207", "HOT_GAME_208",
		"HOT_GAME_209", "HOT_GAME_210", "HOT_GAME_211", "HOT_GAME_212",
		"HOT_GAME_213", "HOT_GAME_214", "HOT_GAME_215", "HOT_GAME_220",
		"HKYS_GAME_101", "HKYS_GAME_102", "HKYS_GAME_103", "HKYS_GAME_104",
		"HKYS_GAME_105", "HKYS_GAME_106", "HKYS_GAME_107", "HKYS_GAME_108",
		"HKYS_GAME_109", "HKYS_GAME_110", "HKYS_GAME_111", "HKYS_GAME_112",
		"HKYS_GAME_113", "HKYS_GAME_114", "HKYS_GAME_115", "HKYS_GAME_116",
		"HKYS_GAME_201", "HKYS_GAME_202", "HKYS_GAME_203", "HKYS_GAME_204",
		"HKYS_GAME_205", "HKYS_GAME_206", "HKYS_GAME_207", "HKYS_GAME_208",
		"HKYS_GAME_209", "HKYS_GAME_210", "HKYS_GAME_211", "HKYS_GAME_212",
		"HKYS_GAME_213", "HKYS_GAME_214", "HKYS_GAME_215", "HKYS_GAME_216",
		"BAISHUN_GAME_1001", "BAISHUN_GAME_1004", "BAISHUN_GAME_1005", "BAISHUN_GAME_1006",
		"BAISHUN_GAME_1007", "BAISHUN_GAME_1008", "BAISHUN_GAME_1009", "BAISHUN_GAME_1010",
		"BAISHUN_GAME_1012", "BAISHUN_GAME_1013", "BAISHUN_GAME_1014", "BAISHUN_GAME_1015",
		"BAISHUN_GAME_1016", "BAISHUN_GAME_1017", "BAISHUN_GAME_1018", "BAISHUN_GAME_1019",
		"BAISHUN_GAME_1020", "BAISHUN_GAME_1021", "BAISHUN_GAME_1022", "BAISHUN_GAME_1023",
		"BAISHUN_GAME_1024", "BAISHUN_GAME_1026", "BAISHUN_GAME_1031", "BAISHUN_GAME_1032",
		"BAISHUN_GAME_1035", "BAISHUN_GAME_1037", "BAISHUN_GAME_1040", "BAISHUN_GAME_1041",
		"BAISHUN_GAME_1043", "BAISHUN_GAME_1044", "BAISHUN_GAME_1048", "BAISHUN_GAME_1051",
		"BAISHUN_GAME_1053", "BAISHUN_GAME_1055", "BAISHUN_GAME_1068", "BAISHUN_GAME_1069",
		"BAISHUN_GAME_1075", "BAISHUN_GAME_1078", "BAISHUN_GAME_1083", "BAISHUN_GAME_1084",
		"BAISHUN_GAME_1090", "BAISHUN_GAME_1100", "BAISHUN_GAME_1107", "BAISHUN_GAME_1114",
		"BAISHUN_GAME_1130", "BAISHUN_GAME_1172", "BAISHUN_GAME_1182",
		// Yomi 的 gameUid 由第三方动态下发，代码仓库没有静态枚举；以下 8 个值来自 ATYOU
		// wallet_gold_count 近 30 天已出现且经游戏回调链路确认的集合，未知 gameUid 默认不纳入口径。
		"YOMI_GAME_274", "YOMI_GAME_275", "YOMI_GAME_276", "YOMI_GAME_277",
		"YOMI_GAME_278", "YOMI_GAME_279", "YOMI_GAME_281", "YOMI_GAME_282",
	}
)

type AslanMongoDashboardSource struct {
	db                   *mongo.Database
	legacyDB             *sql.DB
	legacyWalletDatabase string
	appCode              string
	appName              string
	sysOrigin            string
	statTimezone         string
	requestTimeout       time.Duration
	regionResolvers      []ExternalDashboardRegionResolver

	// 部分 likei 部署（如 Aslan 的 atyou 库）没有 user_gold_running_water_v2 集合；
	// 探测结果缓存住，避免把"集合不存在"算成 0（幸运礼物利润率会假性 100%）。
	goldWaterMu        sync.Mutex
	goldWaterChecked   bool
	goldWaterAvailable bool

	// 留存链路的两个 Mongo 触点可注入，供单元测试用假数据驱动 applyRetention 的归属逻辑；
	// 为 nil 时走真实实现。
	retentionCohortsLoader func(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error)
	activeUserSetLoader    func(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error)
}

func (s *AslanMongoDashboardSource) retentionCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
	if s.retentionCohortsLoader != nil {
		return s.retentionCohortsLoader(ctx, startDate, endDate, countryFilter)
	}
	return s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
}

func (s *AslanMongoDashboardSource) activeUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
	if s.activeUserSetLoader != nil {
		return s.activeUserSetLoader(ctx, activeDate, userIDs)
	}
	return s.loadActiveUserSet(ctx, activeDate, userIDs)
}

func (s *AslanMongoDashboardSource) hasGoldWater(ctx context.Context) bool {
	s.goldWaterMu.Lock()
	defer s.goldWaterMu.Unlock()
	if s.goldWaterChecked {
		return s.goldWaterAvailable
	}
	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	count, err := s.db.Collection(aslanMongoGoldWaterCollection).EstimatedDocumentCount(queryCtx)
	if err != nil {
		// 探测失败不缓存结论（下次请求重试）；本次按不可用处理，宁缺毋错。
		return false
	}
	s.goldWaterChecked = true
	s.goldWaterAvailable = count > 0
	return s.goldWaterAvailable
}

// NewAslanMongoDashboardSource 直接读取 likei 平台（Aslan/Yumi 同构）Mongo 里的已存在事实集合。
// 这里只做查询聚合和字段映射，不把数据复制到 hyapp statistics-service，也不回退旧 console HTTP 接口。
func NewAslanMongoDashboardSource(database *mongo.Database, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
	return newAslanMongoDashboardSource(database, nil, sourceConfig, regionResolvers...)
}

// NewAslanMongoDashboardSourceWithLegacyDB 在 Mongo 事实之外接入 likei legacy MySQL 的补充口径。
// legacyDB 由调用方负责生命周期；nil 表示对应指标不可用，而不是按 0 处理。
func NewAslanMongoDashboardSourceWithLegacyDB(database *mongo.Database, legacyDB *sql.DB, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
	return newAslanMongoDashboardSource(database, legacyDB, sourceConfig, regionResolvers...)
}

func newAslanMongoDashboardSource(database *mongo.Database, legacyDB *sql.DB, sourceConfig config.DashboardExternalSourceConfig, regionResolvers ...ExternalDashboardRegionResolver) (ExternalDashboardSource, error) {
	if database == nil || !sourceConfig.Enabled {
		return nil, nil
	}
	appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
	sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
	if appCode == "" || sysOrigin == "" {
		return nil, nil
	}
	statTimezone := strings.TrimSpace(sourceConfig.StatTimezone)
	if statTimezone == "" {
		statTimezone = "Asia/Shanghai"
	}
	if _, err := time.LoadLocation(statTimezone); err != nil {
		return nil, fmt.Errorf("load aslan mongo storage timezone %s: %w", statTimezone, err)
	}
	timeout := sourceConfig.RequestTimeout
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	return &AslanMongoDashboardSource{
		db:                   database,
		legacyDB:             legacyDB,
		legacyWalletDatabase: strings.TrimSpace(sourceConfig.LegacyWalletDatabase),
		appCode:              appCode,
		appName:              strings.TrimSpace(sourceConfig.AppName),
		sysOrigin:            sysOrigin,
		statTimezone:         statTimezone,
		requestTimeout:       timeout,
		regionResolvers:      compactExternalDashboardRegionResolvers(regionResolvers),
	}, nil
}

func (s *AslanMongoDashboardSource) AppCode() string {
	if s == nil {
		return ""
	}
	return s.appCode
}

func (s *AslanMongoDashboardSource) legacyGameTotalsAvailable(countryFilter externalDashboardCountryFilter) bool {
	// wallet_gold_count 只有 App×日×事件类型，没有用户或国家键；区域筛选必须显式关闭该口径，
	// 否则区域卡片会混入全 App 游戏流水，数值虽非零却属于错误的数据权限和统计语义。
	return s.legacyDB != nil && strings.TrimSpace(s.legacyWalletDatabase) != "" && !countryFilter.Applied
}

func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
	if s == nil || s.db == nil {
		return nil, fmt.Errorf("aslan mongo dashboard source is not configured")
	}
	countryFilter, err := s.countryFilter(ctx, query)
	if err != nil {
		return nil, err
	}
	requestTimezone := strings.TrimSpace(query.StatTZ)
	if requestTimezone == "" {
		requestTimezone = s.statTimezone
	}
	requestLocation, err := time.LoadLocation(requestTimezone)
	if err != nil {
		return nil, fmt.Errorf("load aslan mongo request timezone %s: %w", requestTimezone, err)
	}
	startDate, endDate, startMS, endMS := externalDashboardDateRange(query, requestLocation)

	days := countCalendarDays(startDate, endDate)
	previousStart := startDate.AddDate(0, 0, -days)
	var current, previous aslanRangeOverview
	// 当前周期与环比周期互不依赖，并行加载；环比只消费流水字段，昂贵的留存链路跳过。
	if err := runConcurrently([]func() error{
		func() error {
			out, err := s.loadRange(ctx, startDate, endDate, countryFilter, true)
			if err != nil {
				return err
			}
			current = out
			return nil
		},
		func() error {
			out, err := s.loadRange(ctx, previousStart, startDate, countryFilter, false)
			if err != nil {
				return err
			}
			previous = out
			return nil
		},
	}); err != nil {
		return nil, err
	}

	response := current.Total.toMap()
	response["app_code"] = s.appCode
	response["app_name"] = firstExternalDashboardValue(s.appName, s.appCode)
	response["sys_origin"] = s.sysOrigin
	response["stat_tz"] = s.statTimezone
	response["request_stat_tz"] = requestTimezone
	response["start_ms"] = startMS
	response["end_ms"] = endMS
	response["daily_series"] = current.DailySeries
	response["country_breakdown"] = current.CountryBreakdown
	response["daily_country_breakdown"] = current.DailyCountryBreakdown
	goldWater := s.hasGoldWater(ctx)
	legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter)
	response["retention"] = current.Total.retentionMap()
	response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil, legacyGameTotals, countryFilter.Applied)
	response["updated_at_ms"] = time.Now().UTC().UnixMilli()
	applyExternalDashboardDeltas(response, current.Total, previous.Total)
	response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor))
	applyAslanMongoUnavailableFields(response)
	applyAslanMongoGlobalGameOverview(response, current.Total, previous.Total, legacyGameTotals)
	applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil)
	if !goldWater {
		applyAslanMongoGoldWaterUnavailable(response)
		response["lucky_gift_payout_delta_rate"] = nil
		response["lucky_gift_profit_delta_rate"] = nil
	}
	return response, nil
}

func (s *AslanMongoDashboardSource) countryFilter(ctx context.Context, query StatisticsQuery) (externalDashboardCountryFilter, error) {
	if query.CountryID > 0 {
		return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s does not support numeric country_id filter", s.appCode)
	}
	if query.RegionID <= 0 {
		return externalDashboardCountryFilter{}, nil
	}
	for _, resolver := range s.regionResolvers {
		codes, handled, err := resolver.CountryCodesForRegion(ctx, s.appCode, query.RegionID)
		if err != nil {
			return externalDashboardCountryFilter{}, err
		}
		if !handled {
			continue
		}
		return externalDashboardCountryFilter{
			Applied:  true,
			RegionID: query.RegionID,
			Codes:    normalizeExternalCountryCodes(codes),
		}, nil
	}
	// likei Mongo 集合只有国家码，没有 hyapp 的数字 region_id；缺 legacy 区域映射时失败，避免误把全量数据当成某个区域返回。
	return externalDashboardCountryFilter{}, fmt.Errorf("aslan mongo dashboard source %s cannot resolve region_id %d", s.appCode, query.RegionID)
}

// legacyMongoMetric 是 likei Mongo 源按 天×国家 汇总出的事实指标；
// 消耗/产出金币是全 App 口径（按用户拆国家需要全量账变反查用户，代价过高），只挂在日行和合计上。
type legacyMongoMetric struct {
	NewUsers                   int64
	ActiveUsers                int64
	PaidUsers                  int64
	RechargeUSDMinor           int64
	GoogleUSDMinor             int64
	ThirdPartyUSDMinor         int64
	CoinSellerRechargeUSDMinor int64
	NewUserRechargeUSDMinor    int64
	GiftCoinSpent              int64
	LuckyGiftTurnover          int64
	LuckyGiftPayout            int64
	LuckyGiftPayers            int64
	CoinSellerTransferCoin     int64
	SalaryUSDMinor             int64
	D1Users                    int64
	D1Base                     int64
	D7Users                    int64
	D7Base                     int64
	D30Users                   int64
	D30Base                    int64
}

func (m *legacyMongoMetric) add(other *legacyMongoMetric) {
	if other == nil {
		return
	}
	m.NewUsers += other.NewUsers
	m.ActiveUsers += other.ActiveUsers
	m.PaidUsers += other.PaidUsers
	m.RechargeUSDMinor += other.RechargeUSDMinor
	m.GoogleUSDMinor += other.GoogleUSDMinor
	m.ThirdPartyUSDMinor += other.ThirdPartyUSDMinor
	m.CoinSellerRechargeUSDMinor += other.CoinSellerRechargeUSDMinor
	m.NewUserRechargeUSDMinor += other.NewUserRechargeUSDMinor
	m.GiftCoinSpent += other.GiftCoinSpent
	m.LuckyGiftTurnover += other.LuckyGiftTurnover
	m.LuckyGiftPayout += other.LuckyGiftPayout
	m.LuckyGiftPayers += other.LuckyGiftPayers
	m.CoinSellerTransferCoin += other.CoinSellerTransferCoin
	m.SalaryUSDMinor += other.SalaryUSDMinor
	m.D1Users += other.D1Users
	m.D1Base += other.D1Base
	m.D7Users += other.D7Users
	m.D7Base += other.D7Base
	m.D30Users += other.D30Users
	m.D30Base += other.D30Base
}

type legacyMongoCoinFlow struct {
	ConsumedCoin int64
	OutputCoin   int64
}

// legacyMongoGameFlow 是 wallet_gold_count 已预聚合的全 App 游戏日快照。
// 该表没有 user_id/国家维度，因此它只能进入总览与每日趋势，绝不能回填到国家或区域行。
type legacyMongoGameFlow struct {
	Turnover int64
	Payout   int64
}

func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric {
	userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor
	metric := externalDashboardMetric{
		NewUsers:                   m.NewUsers,
		ActiveUsers:                m.ActiveUsers,
		PaidUsers:                  m.PaidUsers,
		RechargeUSDMinor:           m.RechargeUSDMinor,
		UserRechargeUSDMinor:       userRecharge,
		GoogleRechargeUSDMinor:     m.GoogleUSDMinor,
		MifaPayRechargeUSDMinor:    m.ThirdPartyUSDMinor,
		CoinSellerRechargeUSDMinor: m.CoinSellerRechargeUSDMinor,
		NewUserRechargeUSDMinor:    m.NewUserRechargeUSDMinor,
		GiftCoinSpent:              m.GiftCoinSpent,
		LuckyGiftTurnover:          m.LuckyGiftTurnover,
		LuckyGiftPayout:            m.LuckyGiftPayout,
		LuckyGiftPayers:            m.LuckyGiftPayers,
		CoinSellerTransferCoin:     int64Ptr(m.CoinSellerTransferCoin),
		SalaryUSDMinor:             int64Ptr(m.SalaryUSDMinor),
		D1RetentionUsers:           m.D1Users,
		D1RetentionBaseUsers:       m.D1Base,
		D7RetentionUsers:           m.D7Users,
		D7RetentionBaseUsers:       m.D7Base,
		D30RetentionUsers:          m.D30Users,
		D30RetentionBaseUsers:      m.D30Base,
	}
	if coinFlow != nil {
		metric.ConsumedCoin = int64Ptr(coinFlow.ConsumedCoin)
		metric.OutputCoin = int64Ptr(coinFlow.OutputCoin)
	}
	metric.finalize()
	// finalize 会按拆分字段重推总充值；likei Mongo 源已经把 in-app 与 legacy 币商金额合成总额，最终以事实聚合值为准。
	metric.RechargeUSDMinor = m.RechargeUSDMinor
	metric.UserRechargeUSDMinor = userRecharge
	return metric
}

func (m *legacyMongoMetric) toExternalWithGlobalFlows(coinFlow *legacyMongoCoinFlow, gameFlow *legacyMongoGameFlow) externalDashboardMetric {
	metric := m.toExternal(coinFlow)
	if gameFlow == nil {
		return metric
	}
	metric.GameTurnover = gameFlow.Turnover
	metric.GamePayout = gameFlow.Payout
	// 游戏利润和利润率属于投注/返奖的派生口径；在同一个 finalize 入口重算，避免总览与 Databi 二次汇总出现公式分叉。
	metric.finalize()
	return metric
}

type legacyMongoGrid struct {
	days          []string
	daySet        map[string]struct{}
	metrics       map[string]map[string]*legacyMongoMetric
	coinFlows     map[string]*legacyMongoCoinFlow
	gameFlows     map[string]*legacyMongoGameFlow
	rechargeUsers map[string]map[string]struct{}
	// 各 loader 并行写入：map 结构由锁保护；不同 loader 写 metric 的不同字段，无字段级竞争。
	mu sync.Mutex
}

func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid {
	grid := &legacyMongoGrid{
		daySet:        map[string]struct{}{},
		metrics:       map[string]map[string]*legacyMongoMetric{},
		coinFlows:     map[string]*legacyMongoCoinFlow{},
		gameFlows:     map[string]*legacyMongoGameFlow{},
		rechargeUsers: map[string]map[string]struct{}{},
	}
	for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) {
		text := dashboardSQLDate(day)
		grid.days = append(grid.days, text)
		grid.daySet[text] = struct{}{}
	}
	return grid
}

func (g *legacyMongoGrid) at(day string, countryCode string) *legacyMongoMetric {
	g.mu.Lock()
	defer g.mu.Unlock()
	byCountry := g.metrics[day]
	if byCountry == nil {
		byCountry = map[string]*legacyMongoMetric{}
		g.metrics[day] = byCountry
	}
	metric := byCountry[countryCode]
	if metric == nil {
		metric = &legacyMongoMetric{}
		byCountry[countryCode] = metric
	}
	return metric
}

func (g *legacyMongoGrid) hasDay(day string) bool {
	_, ok := g.daySet[day]
	return ok
}

// runConcurrently 并发执行各 loader 并返回第一个错误；每个 loader 内部有独立的查询超时。
func runConcurrently(tasks []func() error) error {
	var wg sync.WaitGroup
	errs := make(chan error, len(tasks))
	for _, task := range tasks {
		wg.Add(1)
		go func(task func() error) {
			defer wg.Done()
			if err := task(); err != nil {
				errs <- err
			}
		}(task)
	}
	wg.Wait()
	close(errs)
	return <-errs
}

func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, includeRetention bool) (aslanRangeOverview, error) {
	grid := newLegacyMongoGrid(startDate, endDate)

	// 注册 cohort 是新用户充值与留存的前置依赖，先串行取；其余 loader 集合互不相交，并发执行，
	// 让整段耗时收敛到最慢一条聚合而不是全部相加（跨地域 Mongo 时差异是数量级的）。
	cohorts, err := s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
	if err != nil {
		return aslanRangeOverview{}, err
	}
	for day, byCountry := range cohorts {
		for countryCode, userIDs := range byCountry {
			grid.at(day, countryCode).NewUsers = int64(len(userIDs))
		}
	}

	goldWater := s.hasGoldWater(ctx)
	var salarySnapshot map[string]int64
	tasks := []func() error{
		func() error { return s.loadActiveCounts(ctx, startDate, endDate, countryFilter, grid) },
		func() error { return s.loadRechargeStats(ctx, startDate, endDate, countryFilter, cohorts, grid) },
		func() error { return s.loadGiftStats(ctx, startDate, endDate, countryFilter, grid) },
		func() error {
			snapshot, err := s.loadHostSalaryBalanceByCountry(ctx, countryFilter)
			if err != nil {
				return err
			}
			salarySnapshot = snapshot
			return nil
		},
	}
	if goldWater {
		tasks = append(tasks,
			func() error { return s.loadGoldOriginStats(ctx, startDate, endDate, countryFilter, grid) },
			func() error { return s.loadCoinFlows(ctx, startDate, endDate, grid) },
		)
	}
	legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter)
	if legacyGameTotals {
		// wallet_gold_count 已由 likei wallet-service 按日聚合，区间查询只扫描唯一键范围内的小量日快照；
		// 这里不回查账变明细，避免 Databi 请求把在线钱包库变成临时报表引擎。
		tasks = append(tasks, func() error { return s.loadLegacyGameFlows(ctx, startDate, endDate, grid) })
	}
	if includeRetention {
		tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) })
	}
	if err := runConcurrently(tasks); err != nil {
		return aslanRangeOverview{}, err
	}
	// user_bank_balance 是余额快照而不是日报表；这里按当前快照填充每一天，区间汇总只保留一份快照，避免把余额按天累加。
	for _, day := range grid.days {
		for countryCode, balance := range salarySnapshot {
			grid.at(day, countryCode).SalaryUSDMinor = balance
		}
	}

	return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil, legacyGameTotals), nil
}

func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool, legacyGameTotals bool) aslanRangeOverview {
	dailySeries := []map[string]any{}
	dailyCountries := []map[string]any{}
	totalFlow := legacyMongoMetric{}
	totalCoinFlow := legacyMongoCoinFlow{}
	totalGameFlow := legacyMongoGameFlow{}
	countryTotals := map[string]*legacyMongoMetric{}

	for _, day := range grid.days {
		byCountry := grid.metrics[day]
		dayFlow := legacyMongoMetric{}
		countryCodes := make([]string, 0, len(byCountry))
		for countryCode := range byCountry {
			countryCodes = append(countryCodes, countryCode)
		}
		sort.Strings(countryCodes)
		for _, countryCode := range countryCodes {
			metric := byCountry[countryCode]
			dayFlow.add(metric)
			countryTotal := countryTotals[countryCode]
			if countryTotal == nil {
				countryTotal = &legacyMongoMetric{}
				countryTotals[countryCode] = countryTotal
			}
			salary := metric.SalaryUSDMinor
			countryTotal.add(metric)
			countryTotal.SalaryUSDMinor = salary

			row := metric.toExternal(nil).toMap()
			row["label"] = day
			row["stat_day"] = day
			row["country_id"] = 0
			row["country_code"] = countryCode
			row["country_name"] = countryCode
			row["country"] = countryCode
			if countryFilter.RegionID > 0 {
				row["region_id"] = countryFilter.RegionID
			}
			applyAslanMongoUnavailableFields(row)
			applyAslanMongoLegacyUnavailableFields(row, legacyRecharge)
			if !goldWater {
				applyAslanMongoGoldWaterUnavailable(row)
			}
			dailyCountries = append(dailyCountries, row)
		}
		coinFlow := grid.coinFlows[day]
		if coinFlow != nil {
			totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin
			totalCoinFlow.OutputCoin += coinFlow.OutputCoin
		}
		gameFlow := grid.gameFlows[day]
		if gameFlow != nil {
			totalGameFlow.Turnover += gameFlow.Turnover
			totalGameFlow.Payout += gameFlow.Payout
		}
		// 工资余额是快照：日行取当日各国快照之和，区间合计不能跨天累加。
		daySalary := dayFlow.SalaryUSDMinor
		if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 {
			// 国家拆分仍保留各国家充值人数；日合计按 user_id 去重，避免同一用户因不同事实源国家码不一致而被算两次。
			dayFlow.PaidUsers = int64(len(dayRechargeUsers))
		}
		totalFlow.add(&dayFlow)
		totalFlow.SalaryUSDMinor = daySalary

		dayRow := dayFlow.toExternalWithGlobalFlows(coinFlow, gameFlow).toMap()
		dayRow["label"] = day
		dayRow["stat_day"] = day
		applyAslanMongoUnavailableFields(dayRow)
		applyAslanMongoGlobalGameFields(dayRow, gameFlow, legacyGameTotals)
		applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge)
		if !goldWater {
			applyAslanMongoGoldWaterUnavailable(dayRow)
		}
		dailySeries = append(dailySeries, dayRow)
	}

	countryRows := make([]map[string]any, 0, len(countryTotals))
	countryCodes := make([]string, 0, len(countryTotals))
	for countryCode := range countryTotals {
		countryCodes = append(countryCodes, countryCode)
	}
	sort.Strings(countryCodes)
	for _, countryCode := range countryCodes {
		row := countryTotals[countryCode].toExternal(nil).toMap()
		row["country_id"] = 0
		row["country_code"] = countryCode
		row["country_name"] = countryCode
		row["country"] = countryCode
		if countryFilter.RegionID > 0 {
			row["region_id"] = countryFilter.RegionID
		}
		applyAslanMongoUnavailableFields(row)
		applyAslanMongoLegacyUnavailableFields(row, legacyRecharge)
		if !goldWater {
			applyAslanMongoGoldWaterUnavailable(row)
		}
		countryRows = append(countryRows, row)
	}
	sort.Slice(countryRows, func(i, j int) bool {
		left, _ := countryRows[i]["recharge_usd_minor"].(int64)
		right, _ := countryRows[j]["recharge_usd_minor"].(int64)
		if left == right {
			return fmt.Sprint(countryRows[i]["country_code"]) < fmt.Sprint(countryRows[j]["country_code"])
		}
		return left > right
	})

	var gameTotal *legacyMongoGameFlow
	if legacyGameTotals {
		// 可用口径即使区间内没有流水也应返回真实 0；nil 只表示数据源或筛选维度不支持。
		gameTotal = &totalGameFlow
	}
	return aslanRangeOverview{
		Total:                 totalFlow.toExternalWithGlobalFlows(&totalCoinFlow, gameTotal),
		DailySeries:           dailySeries,
		CountryBreakdown:      countryRows,
		DailyCountryBreakdown: dailyCountries,
	}
}

// loadRegistrationCohorts 返回 天→国家→注册用户ID；同时用于新增数、新用户充值和留存 cohort。
func (s *AslanMongoDashboardSource) loadRegistrationCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
	match := bson.M{
		"originSys":     s.sysOrigin,
		"del":           bson.M{"$ne": true},
		"createTime":    bson.M{"$gte": startDate, "$lt": endDate},
		"countryCode":   bson.M{"$exists": true, "$ne": ""},
		"accountStatus": bson.M{"$ne": "DELETED"},
	}
	aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
	pipeline := mongo.Pipeline{
		{{Key: "$match", Value: match}},
		{{Key: "$group", Value: bson.D{
			{Key: "_id", Value: bson.D{
				{Key: "day", Value: s.dayExpression("$createTime")},
				{Key: "country", Value: "$countryCode"},
			}},
			{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$_id"}}},
		}}},
	}
	out := map[string]map[string][]int64{}
	err := s.aggregateEach(ctx, aslanMongoUserProfileCollection, pipeline, func(row bson.M) error {
		day, countryCode := aslanMongoDayCountry(row["_id"])
		if day == "" || countryCode == "" {
			return nil
		}
		userIDs := aslanMongoInt64Values(row["users"])
		if len(userIDs) == 0 {
			return nil
		}
		byCountry := out[day]
		if byCountry == nil {
			byCountry = map[string][]int64{}
			out[day] = byCountry
		}
		byCountry[countryCode] = userIDs
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("aggregate registration cohorts: %w", err)
	}
	return out, nil
}

func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
	// user_daily_active_log 的 activeDate 是 yyyy-MM-dd 字符串（likei 固定按其统计时区落盘），ISO 日期字符串可直接做范围比较。
	match := bson.M{
		"sysOrigin":           s.sysOrigin,
		"activeDate":          bson.M{"$gte": dashboardSQLDate(startDate), "$lt": dashboardSQLDate(endDate)},
		"registerCountryCode": bson.M{"$exists": true, "$ne": ""},
	}
	aslanMongoApplyCountryFilter(match, "registerCountryCode", countryFilter)
	pipeline := mongo.Pipeline{
		{{Key: "$match", Value: match}},
		{{Key: "$group", Value: bson.D{
			{Key: "_id", Value: bson.D{
				{Key: "day", Value: "$activeDate"},
				{Key: "country", Value: "$registerCountryCode"},
			}},
			{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$userId"}}},
		}}},
	}
	err := s.aggregateEach(ctx, aslanMongoUserActiveCollection, pipeline, func(row bson.M) error {
		day, countryCode := aslanMongoDayCountry(row["_id"])
		if day == "" || countryCode == "" || !grid.hasDay(day) {
			return nil
		}
		grid.at(day, countryCode).ActiveUsers = aslanMongoDistinctValueCount(row["users"])
		return nil
	})
	if err != nil {
		return fmt.Errorf("aggregate active users: %w", err)
	}
	return nil
}

// loadLegacyGameFlows 读取 likei wallet-service 已持久化的 App×日×事件累计快照。
// group_num 的日界线由 likei 固定按 Asia/Riyadh 生成；这里按 [start,end) 的日期号查询并原样映射，
// 不使用 MySQL session 时区做二次转换，避免同一自然日被移到相邻日期。
func (s *AslanMongoDashboardSource) loadLegacyGameFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error {
	if s.legacyDB == nil || strings.TrimSpace(s.legacyWalletDatabase) == "" || len(aslanLegacyGameEventTypes) == 0 {
		return nil
	}

	// likei 新迁移的唯一键为 (sys_origin, group_num, type, event_type)；固定 App、日期范围和 type 后
	// 只扫描目标日快照。event_type 使用 exact IN 白名单收口业务语义，不能改成 LIKE '*_GAME_%'，
	// 否则 HOT_GAME_200、BAISHUN_GAME_1085 等奖励 code 会被误计。
	placeholders := strings.TrimSuffix(strings.Repeat("?,", len(aslanLegacyGameEventTypes)), ",")
	query := fmt.Sprintf(`SELECT group_num, type, COALESCE(SUM(amount), 0)
FROM %s.wallet_gold_count
WHERE sys_origin = ?
  AND group_num >= ?
  AND group_num < ?
  AND type IN (0, 1)
  AND event_type IN (%s)
GROUP BY group_num, type`, quoteMySQLIdentifier(s.legacyWalletDatabase), placeholders)
	args := make([]any, 0, len(aslanLegacyGameEventTypes)+3)
	args = append(args, s.sysOrigin, dashboardDateNumber(startDate), dashboardDateNumber(endDate))
	for _, eventType := range aslanLegacyGameEventTypes {
		args = append(args, eventType)
	}

	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	rows, err := s.legacyDB.QueryContext(queryCtx, query, args...)
	if err != nil {
		return fmt.Errorf("query legacy game day snapshots: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var groupNum string
		var receiptType int
		var pennyAmount int64
		if err := rows.Scan(&groupNum, &receiptType, &pennyAmount); err != nil {
			return fmt.Errorf("scan legacy game day snapshot: %w", err)
		}
		day, ok := dashboardDayFromNumber(groupNum)
		if !ok || !grid.hasDay(day) {
			continue
		}
		flow := grid.gameFlows[day]
		if flow == nil {
			flow = &legacyMongoGameFlow{}
			grid.gameFlows[day] = flow
		}
		// GoldReceiptCmd 以 PennyAmount 写入统计缓存，wallet_gold_count.amount 因此是 1/100 金币；
		// 先在数据库按日汇总再统一四舍五入，避免逐 event_type 除法造成累计舍入误差。
		amount := roundMinorToWhole(pennyAmount)
		switch receiptType {
		case 1:
			flow.Turnover += amount
		case 0:
			flow.Payout += amount
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate legacy game day snapshots: %w", err)
	}
	return nil
}

type aslanMongoDayCountryUsers struct {
	day     string
	country string
}

// loadRechargeStats 一次聚合出 天×国家×渠道 的充值金额与充值用户，再按当日注册 cohort 拆出新用户充值。
func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
	match := bson.M{
		"sysOrigin":   s.sysOrigin,
		"status":      "SUCCESS",
		"trialPeriod": bson.M{"$ne": true},
		"createTime":  bson.M{"$gte": startDate, "$lt": endDate},
	}
	aslanMongoExcludeFreightGoldRecharge(match)
	// Aslan 原 Top 充值统计按 acceptUserId 聚合；createUser 只在 acceptUserId 缺失的旧单据上兜底。
	userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
	pipeline := mongo.Pipeline{
		{{Key: "$match", Value: match}},
	}
	pipeline = append(pipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
	pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{
		{Key: "_id", Value: bson.D{
			{Key: "day", Value: s.dayExpression("$createTime")},
			{Key: "country", Value: "$aslanBillCountry"},
			{Key: "factory", Value: bson.D{{Key: "$toUpper", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$factory.factoryCode", ""}}}}}},
		}},
		{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
		{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$aslanBillUserID"}}},
	}}})
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
	err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
		key := aslanMongoDocument(row["_id"])
		if key == nil {
			return nil
		}
		day := strings.TrimSpace(fmt.Sprint(key["day"]))
		countryCode := aslanMongoCountryCode(key["country"])
		factory := strings.TrimSpace(fmt.Sprint(key["factory"]))
		if day == "" || countryCode == "" || !grid.hasDay(day) {
			return nil
		}
		amount, err := aslanMongoDecimalMinor(row["amount"])
		if err != nil {
			return fmt.Errorf("parse recharge amount for %s/%s: %w", day, countryCode, err)
		}
		metric := grid.at(day, countryCode)
		metric.RechargeUSDMinor += amount
		if factory == "GOOGLE" {
			metric.GoogleUSDMinor += amount
		} else {
			metric.ThirdPartyUSDMinor += amount
		}
		usersKey := aslanMongoDayCountryUsers{day: day, country: countryCode}
		userSet := distinctUsers[usersKey]
		if userSet == nil {
			userSet = map[string]struct{}{}
			distinctUsers[usersKey] = userSet
		}
		for _, value := range aslanMongoInt64Values(row["users"]) {
			userSet[strconv.FormatInt(value, 10)] = struct{}{}
		}
		return nil
	})
	if err != nil {
		return fmt.Errorf("aggregate recharge stats: %w", err)
	}

	// 新用户充值：按 天×用户 聚合当日充值，再与当日注册 cohort 求交集；cohort 自带国家归属。
	perUserPipeline := mongo.Pipeline{
		{{Key: "$match", Value: match}},
	}
	perUserPipeline = append(perUserPipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
	perUserPipeline = append(perUserPipeline, bson.D{{Key: "$group", Value: bson.D{
		{Key: "_id", Value: bson.D{
			{Key: "day", Value: s.dayExpression("$createTime")},
			{Key: "user", Value: "$aslanBillUserID"},
		}},
		{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
	}}})
	perUser := map[string]map[int64]int64{}
	err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
		key := aslanMongoDocument(row["_id"])
		if key == nil {
			return nil
		}
		day := strings.TrimSpace(fmt.Sprint(key["day"]))
		userID := aslanMongoWholeInt64(key["user"])
		if day == "" || userID <= 0 || !grid.hasDay(day) {
			return nil
		}
		amount, err := aslanMongoDecimalMinor(row["amount"])
		if err != nil {
			return fmt.Errorf("parse per-user recharge amount for %s: %w", day, err)
		}
		byUser := perUser[day]
		if byUser == nil {
			byUser = map[int64]int64{}
			perUser[day] = byUser
		}
		byUser[userID] += amount
		return nil
	})
	if err != nil {
		return fmt.Errorf("aggregate per-user recharge: %w", err)
	}
	if err := s.loadLegacyOtherRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
		return err
	}
	if err := s.loadLegacyCoinSellerRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
		return err
	}
	applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)
	return nil
}

func aslanMongoRechargeCountryStages(userExpression any, countryFilter externalDashboardCountryFilter) mongo.Pipeline {
	// Google 账单普遍没有 countryCode；finance 口径已按付款用户资料兜底，dashboard/social 也必须一致，
	// 否则全 App、按 App 对比和国家拆分会漏掉这些真实充值。
	stages := mongo.Pipeline{
		bson.D{{Key: "$addFields", Value: bson.M{"aslanBillUserID": userExpression}}},
		bson.D{{Key: "$lookup", Value: bson.M{
			"from":         aslanMongoUserProfileCollection,
			"localField":   "aslanBillUserID",
			"foreignField": "_id",
			"as":           "aslanBillUser",
		}}},
		bson.D{{Key: "$addFields", Value: bson.M{
			"aslanBillCountry": bson.M{"$toUpper": bson.M{"$cond": bson.A{
				bson.M{"$ne": bson.A{bson.M{"$ifNull": bson.A{"$countryCode", ""}}, ""}},
				"$countryCode",
				bson.M{"$ifNull": bson.A{bson.M{"$first": "$aslanBillUser.countryCode"}, ""}},
			}}},
		}}},
		bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$ne": ""}}}},
	}
	if countryFilter.Applied {
		codes := make([]string, 0, len(countryFilter.Codes))
		for _, code := range countryFilter.Codes {
			if normalized := aslanMongoCountryCode(code); normalized != "" {
				codes = append(codes, normalized)
			}
		}
		if len(codes) == 0 {
			codes = append(codes, "__NO_COUNTRY__")
		}
		stages = append(stages, bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$in": codes}}}})
	}
	return stages
}

func applyAslanRechargeUserMetrics(cohorts map[string]map[string][]int64, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) {
	for key, userSet := range distinctUsers {
		grid.at(key.day, key.country).PaidUsers = int64(len(userSet))
		dayUsers := grid.rechargeUsers[key.day]
		if dayUsers == nil {
			dayUsers = map[string]struct{}{}
			grid.rechargeUsers[key.day] = dayUsers
		}
		for userID := range userSet {
			dayUsers[userID] = struct{}{}
		}
	}
	for day, byCountry := range cohorts {
		byUser := perUser[day]
		if len(byUser) == 0 {
			continue
		}
		for countryCode, userIDs := range byCountry {
			total := int64(0)
			for _, userID := range userIDs {
				total += byUser[userID]
			}
			if total != 0 {
				grid.at(day, countryCode).NewUserRechargeUSDMinor = total
			}
		}
	}
}

func (s *AslanMongoDashboardSource) loadLegacyOtherRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
	if s.legacyDB == nil {
		return nil
	}
	where := []string{
		"oor.sys_origin = ?",
		"oor.payment_date_number >= ?",
		"oor.payment_date_number < ?",
		"ubi.country_code IS NOT NULL",
		"ubi.country_code != ''",
	}
	args := []any{s.sysOrigin, dashboardDateNumber(startDate), dashboardDateNumber(endDate)}
	if countryFilter.Applied {
		if len(countryFilter.Codes) == 0 {
			return nil
		}
		placeholders := make([]string, 0, len(countryFilter.Codes))
		for _, code := range countryFilter.Codes {
			normalized := aslanMongoCountryCode(code)
			if normalized == "" {
				continue
			}
			placeholders = append(placeholders, "?")
			args = append(args, normalized)
		}
		if len(placeholders) == 0 {
			return nil
		}
		where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
	}

	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(STR_TO_DATE(oor.payment_date_number, '%Y%m%d'), '%Y-%m-%d') AS stat_day,
       UPPER(ubi.country_code) AS country_code,
       oor.user_id,
       CAST(COALESCE(SUM(oor.amount), 0) AS CHAR) AS amount
FROM order_other_recharge oor
       INNER JOIN user_base_info ubi ON ubi.id = oor.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(STR_TO_DATE(oor.payment_date_number, '%Y%m%d'), '%Y-%m-%d'), UPPER(ubi.country_code), oor.user_id`, args...)
	if err != nil {
		return fmt.Errorf("query legacy other recharge: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var day string
		var countryCode string
		var userID int64
		var amountText string
		if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
			return fmt.Errorf("scan legacy other recharge: %w", err)
		}
		day = strings.TrimSpace(day)
		countryCode = aslanMongoCountryCode(countryCode)
		if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
			continue
		}
		amount, err := decimalTextToMinor(amountText)
		if err != nil {
			return fmt.Errorf("parse legacy other recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
		}
		addLegacyStandardRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate legacy other recharge: %w", err)
	}
	return nil
}

func (s *AslanMongoDashboardSource) loadLegacyCoinSellerRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
	if s.legacyDB == nil {
		return nil
	}
	if err := s.loadLegacyGoldCoinRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
		return err
	}
	if err := s.loadLegacyFreightStockRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
		return err
	}
	return nil
}

func (s *AslanMongoDashboardSource) loadLegacyGoldCoinRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
	where := []string{
		"ugcr.sys_origin = ?",
		"ugcr.gold_coin_source = 2",
		"ugcr.create_time >= ?",
		"ugcr.create_time < ?",
		"ubi.country_code IS NOT NULL",
		"ubi.country_code != ''",
	}
	args := []any{s.sysOrigin, startDate, endDate}
	if countryFilter.Applied {
		if len(countryFilter.Codes) == 0 {
			return nil
		}
		placeholders := make([]string, 0, len(countryFilter.Codes))
		for _, code := range countryFilter.Codes {
			normalized := aslanMongoCountryCode(code)
			if normalized == "" {
				continue
			}
			placeholders = append(placeholders, "?")
			args = append(args, normalized)
		}
		if len(placeholders) == 0 {
			return nil
		}
		where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
	}

	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(ugcr.create_time, '%Y-%m-%d') AS stat_day,
       UPPER(ubi.country_code) AS country_code,
       ugcr.user_id,
       CAST(COALESCE(SUM(ugcr.recharge_amount), 0) AS CHAR) AS amount
FROM user_gold_coin_recharge ugcr
       INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(ugcr.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), ugcr.user_id`, args...)
	if err != nil {
		return fmt.Errorf("query legacy coin seller recharge: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var day string
		var countryCode string
		var userID int64
		var amountText string
		if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
			return fmt.Errorf("scan legacy coin seller recharge: %w", err)
		}
		day = strings.TrimSpace(day)
		countryCode = aslanMongoCountryCode(countryCode)
		if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
			continue
		}
		amount, err := decimalTextToMinor(amountText)
		if err != nil {
			return fmt.Errorf("parse legacy coin seller recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
		}
		addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate legacy coin seller recharge: %w", err)
	}
	return nil
}

func (s *AslanMongoDashboardSource) loadLegacyFreightStockRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
	walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
	if walletDatabase == "" {
		return nil
	}
	// 后台“金币代理 > 发货”实际是给代理进货：origin=PURCHASE/type=0，表单金额落 amount；
	// “扣减”是库存/USDT 冲减：origin=DEDUCTION/type=1，amount 在源系统仍是正数，统计时必须转成负数。
	// App/经销商路径可能只落 usd_quantity，因此金额优先取 amount，空值再回退 usd_quantity。
	where := []string{
		"fbrw.sys_origin = ?",
		"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
		"fbrw.create_time >= ?",
		"fbrw.create_time < ?",
		"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
		"ubi.country_code IS NOT NULL",
		"ubi.country_code != ''",
	}
	args := []any{s.sysOrigin, startDate, endDate}
	if countryFilter.Applied {
		if len(countryFilter.Codes) == 0 {
			return nil
		}
		placeholders := make([]string, 0, len(countryFilter.Codes))
		for _, code := range countryFilter.Codes {
			normalized := aslanMongoCountryCode(code)
			if normalized == "" {
				continue
			}
			placeholders = append(placeholders, "?")
			args = append(args, normalized)
		}
		if len(placeholders) == 0 {
			return nil
		}
		where = append(where, "UPPER(ubi.country_code) IN ("+strings.Join(placeholders, ",")+")")
	}

	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT DATE_FORMAT(fbrw.create_time, '%Y-%m-%d') AS stat_day,
       UPPER(ubi.country_code) AS country_code,
       fbrw.user_id,
       CAST(COALESCE(SUM(CASE
         WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
           WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
           ELSE COALESCE(fbrw.usd_quantity, 0)
         END)
         WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
         ELSE COALESCE(fbrw.usd_quantity, 0)
       END), 0) AS CHAR) AS amount
FROM `+quoteMySQLIdentifier(walletDatabase)+`.user_freight_balance_running_water fbrw
       INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbrw.user_id`, args...)
	if err != nil {
		return fmt.Errorf("query legacy freight stock recharge: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var day string
		var countryCode string
		var userID int64
		var amountText string
		if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
			return fmt.Errorf("scan legacy freight stock recharge: %w", err)
		}
		day = strings.TrimSpace(day)
		countryCode = aslanMongoCountryCode(countryCode)
		if day == "" || countryCode == "" || userID <= 0 || !grid.hasDay(day) {
			continue
		}
		amount, err := decimalTextToMinor(amountText)
		if err != nil {
			return fmt.Errorf("parse legacy freight stock recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
		}
		addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate legacy freight stock recharge: %w", err)
	}
	return nil
}

func addLegacyCoinSellerRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
	if amount == 0 {
		return
	}
	metric := grid.at(day, countryCode)
	metric.RechargeUSDMinor += amount
	metric.CoinSellerRechargeUSDMinor += amount
	// 扣减流水按负数冲减金额，但不是一次新的付费行为，不能增加付费用户或新用户充值。
	if amount > 0 {
		addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
	}
}

func addLegacyStandardRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
	if amount <= 0 {
		return
	}
	metric := grid.at(day, countryCode)
	metric.RechargeUSDMinor += amount
	metric.ThirdPartyUSDMinor += amount
	addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}

func addLegacyRechargeUser(distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
	usersKey := aslanMongoDayCountryUsers{day: day, country: countryCode}
	userSet := distinctUsers[usersKey]
	if userSet == nil {
		userSet = map[string]struct{}{}
		distinctUsers[usersKey] = userSet
	}
	userSet[strconv.FormatInt(userID, 10)] = struct{}{}

	byUser := perUser[day]
	if byUser == nil {
		byUser = map[int64]int64{}
		perUser[day] = byUser
	}
	byUser[userID] += amount
}

// loadGiftStats 按 天×赠送人 聚合礼物价值（金币），再经用户资料把流水拆到国家；
// luckyGift=true 的部分同时计入幸运礼物流水。
func (s *AslanMongoDashboardSource) loadGiftStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
	match := bson.M{
		"sysOrigin":  s.sysOrigin,
		"createTime": bson.M{"$gte": startDate, "$lt": endDate},
		"refunded":   bson.M{"$ne": true},
	}
	giftValueExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.actualAmount", bson.D{{Key: "$ifNull", Value: bson.A{"$giftValue.giftValue", 0}}}}}}
	pipeline := mongo.Pipeline{
		{{Key: "$match", Value: match}},
		{{Key: "$group", Value: bson.D{
			{Key: "_id", Value: bson.D{
				{Key: "day", Value: s.dayExpression("$createTime")},
				{Key: "user", Value: "$userId"},
			}},
			{Key: "total", Value: bson.D{{Key: "$sum", Value: giftValueExpression}}},
			{Key: "lucky", Value: bson.D{{Key: "$sum", Value: bson.D{{Key: "$cond", Value: bson.A{
				bson.D{{Key: "$eq", Value: bson.A{"$luckyGift", true}}},
				giftValueExpression,
				0,
			}}}}}},
		}}},
	}
	type giftEntry struct {
		day    string
		userID int64
		total  int64
		lucky  int64
	}
	entries := []giftEntry{}
	err := s.aggregateEach(ctx, aslanMongoGiftWaterCollection, pipeline, func(row bson.M) error {
		key := aslanMongoDocument(row["_id"])
		if key == nil {
			return nil
		}
		day := strings.TrimSpace(fmt.Sprint(key["day"]))
		userID := aslanMongoWholeInt64(key["user"])
		if day == "" || userID <= 0 || !grid.hasDay(day) {
			return nil
		}
		total, err := aslanMongoDecimalMinor(row["total"])
		if err != nil {
			return fmt.Errorf("parse gift total for %s: %w", day, err)
		}
		lucky, err := aslanMongoDecimalMinor(row["lucky"])
		if err != nil {
			return fmt.Errorf("parse lucky gift total for %s: %w", day, err)
		}
		// 礼物价值以金币计，amount 十进制只是 Decimal128 载体，这里换回整数金币。
		entries = append(entries, giftEntry{day: day, userID: userID, total: roundMinorToWhole(total), lucky: roundMinorToWhole(lucky)})
		return nil
	})
	if err != nil {
		return fmt.Errorf("aggregate gift stats: %w", err)
	}
	if len(entries) == 0 {
		return nil
	}
	userIDs := make([]int64, 0, len(entries))
	seen := map[int64]struct{}{}
	for _, entry := range entries {
		if _, ok := seen[entry.userID]; ok {
			continue
		}
		seen[entry.userID] = struct{}{}
		userIDs = append(userIDs, entry.userID)
	}
	countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		countryCode := countryByUser[entry.userID]
		if countryCode == "" {
			continue
		}
		metric := grid.at(entry.day, countryCode)
		metric.GiftCoinSpent += entry.total
		metric.LuckyGiftTurnover += entry.lucky
		if entry.lucky > 0 {
			metric.LuckyGiftPayers++
		}
	}
	return nil
}

// loadGoldOriginStats 聚合金币流水里两类来源：幸运礼物返奖（LUCKY_GIFT_GOLD_REWARD*）与币商出货（SELLER_AGENT*）。
func (s *AslanMongoDashboardSource) loadGoldOriginStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid) error {
	assigners := []struct {
		originPrefix string
		assign       func(metric *legacyMongoMetric, amount int64)
	}{
		{aslanMongoLuckyGiftPayoutOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.LuckyGiftPayout += amount }},
		{aslanMongoSellerAgentOriginPrefix, func(metric *legacyMongoMetric, amount int64) { metric.CoinSellerTransferCoin += amount }},
	}
	for _, item := range assigners {
		match := bson.M{
			"sysOrigin":  s.sysOrigin,
			"createTime": bson.M{"$gte": startDate, "$lt": endDate},
			"type":       0,
			"origin":     bson.M{"$regex": "^" + item.originPrefix},
		}
		pipeline := mongo.Pipeline{
			{{Key: "$match", Value: match}},
			{{Key: "$group", Value: bson.D{
				{Key: "_id", Value: bson.D{
					{Key: "day", Value: s.dayExpression("$createTime")},
					{Key: "user", Value: "$userId"},
				}},
				{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
			}}},
		}
		type goldEntry struct {
			day    string
			userID int64
			amount int64
		}
		entries := []goldEntry{}
		err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
			key := aslanMongoDocument(row["_id"])
			if key == nil {
				return nil
			}
			day := strings.TrimSpace(fmt.Sprint(key["day"]))
			userID := aslanMongoWholeInt64(key["user"])
			if day == "" || userID <= 0 || !grid.hasDay(day) {
				return nil
			}
			amount, err := aslanMongoDecimalMinor(row["amount"])
			if err != nil {
				return fmt.Errorf("parse gold origin %s amount for %s: %w", item.originPrefix, day, err)
			}
			entries = append(entries, goldEntry{day: day, userID: userID, amount: roundMinorToWhole(amount)})
			return nil
		})
		if err != nil {
			return fmt.Errorf("aggregate gold origin %s: %w", item.originPrefix, err)
		}
		if len(entries) == 0 {
			continue
		}
		userIDs := make([]int64, 0, len(entries))
		seen := map[int64]struct{}{}
		for _, entry := range entries {
			if _, ok := seen[entry.userID]; ok {
				continue
			}
			seen[entry.userID] = struct{}{}
			userIDs = append(userIDs, entry.userID)
		}
		countryByUser, err := s.loadUserCountries(ctx, userIDs, countryFilter)
		if err != nil {
			return err
		}
		for _, entry := range entries {
			countryCode := countryByUser[entry.userID]
			if countryCode == "" {
				continue
			}
			item.assign(grid.at(entry.day, countryCode), entry.amount)
		}
	}
	return nil
}

// loadCoinFlows 汇总每日全 App 消耗/产出金币；type=1 是消耗、type=0 是收入（产出）。
// 区域筛选时无法低成本把全量账变拆到国家，保持整段 nil 交由上层展示 "--"。
func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error {
	pipeline := mongo.Pipeline{
		{{Key: "$match", Value: bson.M{
			"sysOrigin":  s.sysOrigin,
			"createTime": bson.M{"$gte": startDate, "$lt": endDate},
		}}},
		{{Key: "$group", Value: bson.D{
			{Key: "_id", Value: bson.D{
				{Key: "day", Value: s.dayExpression("$createTime")},
				{Key: "type", Value: "$type"},
			}},
			{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$quantity"}}},
		}}},
	}
	err := s.aggregateEach(ctx, aslanMongoGoldWaterCollection, pipeline, func(row bson.M) error {
		key := aslanMongoDocument(row["_id"])
		if key == nil {
			return nil
		}
		day := strings.TrimSpace(fmt.Sprint(key["day"]))
		if day == "" || !grid.hasDay(day) {
			return nil
		}
		amount, err := aslanMongoDecimalMinor(row["amount"])
		if err != nil {
			return fmt.Errorf("parse coin flow amount for %s: %w", day, err)
		}
		flow := grid.coinFlows[day]
		if flow == nil {
			flow = &legacyMongoCoinFlow{}
			grid.coinFlows[day] = flow
		}
		if aslanMongoWholeInt64(key["type"]) == 1 {
			flow.ConsumedCoin += roundMinorToWhole(amount)
		} else {
			flow.OutputCoin += roundMinorToWhole(amount)
		}
		return nil
	})
	if err != nil {
		return fmt.Errorf("aggregate coin flows: %w", err)
	}
	return nil
}

// applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30：
// 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例，计数归属观察日 D
// （与 Yumi 的 CDC 预聚合、statistics-service 口径一致）。cohort 日无注册时基数保持 0，
// 上层会把 0 基数转成 nil，避免把“无 cohort”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
	location, err := time.LoadLocation(s.statTimezone)
	if err != nil {
		return err
	}
	// 观察日 D 的 cohort 注册日最早是 D-30；区间起点之前的注册日不在主 cohorts 里，单独补一段。
	// 主 cohorts 覆盖 [start, end)，这里只补 [start-30, start)，两边合起来覆盖全部回看窗口。
	earlier, err := s.retentionCohorts(ctx, startDate.AddDate(0, 0, -30), startDate, countryFilter)
	if err != nil {
		return fmt.Errorf("load retention cohorts: %w", err)
	}
	cohortFor := func(day string) map[string][]int64 {
		main, hasMain := cohorts[day]
		extra, hasExtra := earlier[day]
		if !hasExtra {
			return main
		}
		if !hasMain {
			return extra
		}
		// 请求时区与源统计时区不一致时，同一 cohort 日可能被 createTime 边界切进两个 map，
		// 两边用户集互斥（加载区间不相交），合并后才是完整 cohort。
		merged := make(map[string][]int64, len(main)+len(extra))
		for countryCode, userIDs := range main {
			merged[countryCode] = userIDs
		}
		for countryCode, userIDs := range extra {
			merged[countryCode] = append(append([]int64{}, merged[countryCode]...), userIDs...)
		}
		return merged
	}
	today := dashboardSQLDate(time.Now().In(location))
	offsets := []struct {
		days   int
		assign func(metric *legacyMongoMetric, users int64, base int64)
	}{
		{1, func(metric *legacyMongoMetric, users, base int64) { metric.D1Users, metric.D1Base = users, base }},
		{7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }},
		{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
	}
	for _, day := range grid.days {
		if day > today {
			continue
		}
		dayTime, err := time.ParseInLocation("2006-01-02", day, location)
		if err != nil {
			continue
		}
		// 三个 offset 的 cohort 用户并集只查一次活跃集合，把每天的 Mongo 点查从 3 次收敛到 1 次。
		cohortsByOffset := make([]map[string][]int64, len(offsets))
		unionIDs := []int64{}
		seen := map[int64]struct{}{}
		for index, offset := range offsets {
			byCountry := cohortFor(dashboardSQLDate(dayTime.AddDate(0, 0, -offset.days)))
			cohortsByOffset[index] = byCountry
			for _, userIDs := range byCountry {
				for _, userID := range userIDs {
					if _, ok := seen[userID]; !ok {
						seen[userID] = struct{}{}
						unionIDs = append(unionIDs, userID)
					}
				}
			}
		}
		if len(unionIDs) == 0 {
			continue
		}
		activeSet, err := s.activeUserSet(ctx, day, unionIDs)
		if err != nil {
			return err
		}
		for index, offset := range offsets {
			for countryCode, userIDs := range cohortsByOffset[index] {
				if len(userIDs) == 0 {
					continue
				}
				active := int64(0)
				for _, userID := range userIDs {
					if _, ok := activeSet[userID]; ok {
						active++
					}
				}
				offset.assign(grid.at(day, countryCode), active, int64(len(userIDs)))
			}
		}
	}
	return nil
}

func (s *AslanMongoDashboardSource) loadActiveUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
	out := map[int64]struct{}{}
	for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
		queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
		cursor, err := s.db.Collection(aslanMongoUserActiveCollection).Find(queryCtx, bson.M{
			"sysOrigin":  s.sysOrigin,
			"activeDate": activeDate,
			"userId":     bson.M{"$in": chunk},
		}, options.Find().SetProjection(bson.M{"userId": 1}))
		if err != nil {
			cancel()
			return nil, fmt.Errorf("query retention actives for %s: %w", activeDate, err)
		}
		for cursor.Next(queryCtx) {
			row := bson.M{}
			if err := cursor.Decode(&row); err != nil {
				_ = cursor.Close(queryCtx)
				cancel()
				return nil, fmt.Errorf("decode retention active row for %s: %w", activeDate, err)
			}
			if userID := aslanMongoWholeInt64(row["userId"]); userID > 0 {
				out[userID] = struct{}{}
			}
		}
		err = cursor.Err()
		_ = cursor.Close(queryCtx)
		cancel()
		if err != nil {
			return nil, fmt.Errorf("iterate retention actives for %s: %w", activeDate, err)
		}
	}
	return out, nil
}

func (s *AslanMongoDashboardSource) loadHostSalaryBalanceByCountry(ctx context.Context, countryFilter externalDashboardCountryFilter) (map[string]int64, error) {
	memberIDs, err := s.loadTeamMemberIDs(ctx)
	if err != nil {
		return nil, err
	}
	if len(memberIDs) == 0 {
		return map[string]int64{}, nil
	}
	countryByUser, err := s.loadUserCountries(ctx, memberIDs, countryFilter)
	if err != nil {
		return nil, err
	}
	if len(countryByUser) == 0 {
		return map[string]int64{}, nil
	}

	out := map[string]int64{}
	for _, chunk := range aslanMongoInt64Chunks(memberIDs, aslanMongoChunkSize) {
		queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
		cursor, err := s.db.Collection(aslanMongoBankBalanceCollection).Find(queryCtx, bson.M{
			"sysOrigin": s.sysOrigin,
			"_id":       bson.M{"$in": chunk},
		}, options.Find().SetProjection(bson.M{"_id": 1, "balance": 1}))
		if err != nil {
			cancel()
			return nil, fmt.Errorf("query user_bank_balance: %w", err)
		}
		for cursor.Next(queryCtx) {
			row := bson.M{}
			if err := cursor.Decode(&row); err != nil {
				_ = cursor.Close(queryCtx)
				cancel()
				return nil, fmt.Errorf("decode user_bank_balance: %w", err)
			}
			userID := aslanMongoWholeInt64(row["_id"])
			countryCode := countryByUser[userID]
			if countryCode == "" {
				continue
			}
			// user_bank_balance.balance 是 PennyAmount 的 long 值；admin 响应也用 USD minor，所以不用再做美元小数换算。
			out[countryCode] += aslanMongoWholeInt64(row["balance"])
		}
		err = cursor.Err()
		_ = cursor.Close(queryCtx)
		cancel()
		if err != nil {
			return nil, fmt.Errorf("iterate user_bank_balance: %w", err)
		}
	}
	return out, nil
}

func (s *AslanMongoDashboardSource) loadTeamMemberIDs(ctx context.Context) ([]int64, error) {
	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	cursor, err := s.db.Collection(aslanMongoTeamMemberCollection).Find(queryCtx, bson.M{
		"sysOrigin": s.sysOrigin,
		"role":      bson.M{"$in": bson.A{"MEMBER", "ADMIN"}},
	}, options.Find().SetProjection(bson.M{"memberId": 1}))
	if err != nil {
		return nil, fmt.Errorf("query team_member: %w", err)
	}
	defer cursor.Close(queryCtx)

	seen := map[int64]struct{}{}
	out := []int64{}
	for cursor.Next(queryCtx) {
		row := bson.M{}
		if err := cursor.Decode(&row); err != nil {
			return nil, fmt.Errorf("decode team_member: %w", err)
		}
		userID := aslanMongoWholeInt64(row["memberId"])
		if userID <= 0 {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		out = append(out, userID)
	}
	if err := cursor.Err(); err != nil {
		return nil, fmt.Errorf("iterate team_member: %w", err)
	}
	return out, nil
}

func (s *AslanMongoDashboardSource) loadUserCountries(ctx context.Context, userIDs []int64, countryFilter externalDashboardCountryFilter) (map[int64]string, error) {
	out := map[int64]string{}
	for _, chunk := range aslanMongoInt64Chunks(userIDs, aslanMongoChunkSize) {
		filter := bson.M{
			"_id":         bson.M{"$in": chunk},
			"originSys":   s.sysOrigin,
			"del":         bson.M{"$ne": true},
			"countryCode": bson.M{"$exists": true, "$ne": ""},
		}
		aslanMongoApplyCountryFilter(filter, "countryCode", countryFilter)
		queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
		cursor, err := s.db.Collection(aslanMongoUserProfileCollection).Find(queryCtx, filter, options.Find().SetProjection(bson.M{"_id": 1, "countryCode": 1}))
		if err != nil {
			cancel()
			return nil, fmt.Errorf("query user_run_profile countries: %w", err)
		}
		for cursor.Next(queryCtx) {
			row := bson.M{}
			if err := cursor.Decode(&row); err != nil {
				_ = cursor.Close(queryCtx)
				cancel()
				return nil, fmt.Errorf("decode user_run_profile country: %w", err)
			}
			userID := aslanMongoWholeInt64(row["_id"])
			countryCode := aslanMongoCountryCode(row["countryCode"])
			if userID > 0 && countryCode != "" {
				out[userID] = countryCode
			}
		}
		err = cursor.Err()
		_ = cursor.Close(queryCtx)
		cancel()
		if err != nil {
			return nil, fmt.Errorf("iterate user_run_profile countries: %w", err)
		}
	}
	return out, nil
}

func (s *AslanMongoDashboardSource) aggregateEach(ctx context.Context, collectionName string, pipeline mongo.Pipeline, handle func(bson.M) error) error {
	queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	cursor, err := s.db.Collection(collectionName).Aggregate(queryCtx, pipeline, options.Aggregate().SetAllowDiskUse(true))
	if err != nil {
		return err
	}
	defer cursor.Close(queryCtx)
	for cursor.Next(queryCtx) {
		row := bson.M{}
		if err := cursor.Decode(&row); err != nil {
			return err
		}
		if err := handle(row); err != nil {
			return err
		}
	}
	return cursor.Err()
}

func (s *AslanMongoDashboardSource) dayExpression(field string) bson.D {
	return bson.D{{Key: "$dateToString", Value: bson.D{
		{Key: "format", Value: "%Y-%m-%d"},
		{Key: "date", Value: field},
		{Key: "timezone", Value: s.statTimezone},
	}}}
}

func aslanMongoExcludeFreightGoldRecharge(match bson.M) {
	// FREIGHT_GOLD 成功单会落入 atyou_wallet.user_freight_balance_running_water 进货流水；
	// legacy 版本商品字段不统一（顶层类型、products.code/name/content 均出现过），必须全部排除避免双算。
	for _, field := range aslanMongoFreightCommodityFields {
		match[field] = bson.M{"$nin": aslanMongoFreightCommodityTypes}
	}
}

func dashboardDateNumber(day time.Time) string {
	return day.Format("20060102")
}

func dashboardDayFromNumber(value string) (string, bool) {
	value = strings.TrimSpace(value)
	if len(value) != 8 {
		return "", false
	}
	// Parse 不只校验字符长度，还会拒绝 20260231 这类脏 group_num；输出固定 ISO 日期供 grid 精确匹配。
	day, err := time.Parse("20060102", value)
	if err != nil {
		return "", false
	}
	return day.Format("2006-01-02"), true
}

// aslanMongoDocument 把聚合结果里的嵌套文档统一转成 bson.M；
// driver 默认把嵌套文档解码成 bson.D，直接断言 bson.M 会静默丢行（表现为全零无报错）。
func aslanMongoDocument(value any) bson.M {
	switch typed := value.(type) {
	case bson.M:
		return typed
	case bson.D:
		out := make(bson.M, len(typed))
		for _, element := range typed {
			out[element.Key] = element.Value
		}
		return out
	case map[string]any:
		return bson.M(typed)
	default:
		return nil
	}
}

func aslanMongoDayCountry(value any) (string, string) {
	key := aslanMongoDocument(value)
	if key == nil {
		return "", ""
	}
	day := strings.TrimSpace(fmt.Sprint(key["day"]))
	if day == "" || day == "<nil>" {
		return "", ""
	}
	return day, aslanMongoCountryCode(key["country"])
}

func aslanMongoInt64Values(value any) []int64 {
	values, ok := value.(bson.A)
	if !ok {
		if raw, ok := value.([]any); ok {
			values = raw
		}
	}
	out := make([]int64, 0, len(values))
	seen := map[int64]struct{}{}
	for _, item := range values {
		parsed := aslanMongoWholeInt64(item)
		if parsed <= 0 {
			continue
		}
		if _, ok := seen[parsed]; ok {
			continue
		}
		seen[parsed] = struct{}{}
		out = append(out, parsed)
	}
	return out
}

func aslanMongoApplyCountryFilter(match bson.M, field string, countryFilter externalDashboardCountryFilter) {
	if !countryFilter.Applied {
		return
	}
	if len(countryFilter.Codes) == 0 {
		match[field] = bson.M{"$in": bson.A{"__NO_COUNTRY__"}}
		return
	}
	codes := bson.A{}
	for _, code := range countryFilter.Codes {
		if normalized := aslanMongoCountryCode(code); normalized != "" {
			codes = append(codes, normalized)
		}
	}
	if len(codes) == 0 {
		codes = bson.A{"__NO_COUNTRY__"}
	}
	match[field] = bson.M{"$in": codes}
}

func aslanMongoCountryCode(value any) string {
	code := strings.ToUpper(strings.TrimSpace(fmt.Sprint(value)))
	if code == "" || code == "<NIL>" || code == "NULL" {
		return ""
	}
	return code
}

func quoteMySQLIdentifier(value string) string {
	return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}

func aslanMongoDistinctValueCount(value any) int64 {
	values, ok := value.(bson.A)
	if !ok {
		if raw, ok := value.([]any); ok {
			values = raw
		}
	}
	seen := map[string]struct{}{}
	for _, item := range values {
		key := strings.TrimSpace(fmt.Sprint(item))
		if key == "" || key == "<nil>" || key == "0" {
			continue
		}
		seen[key] = struct{}{}
	}
	return int64(len(seen))
}

func aslanMongoDecimalMinor(value any) (int64, error) {
	switch typed := value.(type) {
	case nil:
		return 0, nil
	case bson.Decimal128:
		return decimalTextToMinor(typed.String())
	case string:
		return decimalTextToMinor(typed)
	case int32:
		return int64(typed) * 100, nil
	case int64:
		return typed * 100, nil
	case int:
		return int64(typed) * 100, nil
	case float32:
		return decimalTextToMinor(strconv.FormatFloat(float64(typed), 'f', -1, 32))
	case float64:
		return decimalTextToMinor(strconv.FormatFloat(typed, 'f', -1, 64))
	default:
		return decimalTextToMinor(fmt.Sprint(value))
	}
}

func aslanMongoWholeInt64(value any) int64 {
	switch typed := value.(type) {
	case nil:
		return 0
	case int64:
		return typed
	case int32:
		return int64(typed)
	case int:
		return int64(typed)
	case float64:
		return int64(typed)
	case string:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
		return parsed
	case bson.Decimal128:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(typed.String()), 10, 64)
		return parsed
	default:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
		return parsed
	}
}

func aslanMongoInt64Chunks(values []int64, size int) [][]int64 {
	if size <= 0 {
		size = aslanMongoChunkSize
	}
	out := [][]int64{}
	for start := 0; start < len(values); start += size {
		end := start + size
		if end > len(values) {
			end = len(values)
		}
		out = append(out, values[start:end])
	}
	return out
}

// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径。
// 游戏总量先置空，再由 applyAslanMongoGlobalGameFields 仅在“全 App + legacy 日快照可用”时恢复；
// 这样国家/区域行沿用同一序列化路径也不会意外泄漏全局游戏数据。
var aslanMongoUnavailableMetricFields = []string{
	"coin_seller_stock_coin",
	"new_dealer_recharge_usd_minor",
	"game_turnover",
	"game_payout",
	"game_players",
	"game_profit",
	"game_profit_rate",
	"game_payout_delta_rate",
	"game_turnover_delta_rate",
	"game_profit_delta_rate",
	"super_lucky_gift_turnover",
	"super_lucky_gift_payout",
	"super_lucky_gift_profit",
	"super_lucky_gift_profit_rate",
	"coin_total",
	"coin_total_delta_rate",
	"platform_grant_coin",
	"manual_grant_coin",
	"salary_exchange_usd_minor",
	"salary_transfer_usd_minor",
	"salary_transfer_coin",
	"mic_online_ms",
	"avg_mic_online_ms",
}

func applyAslanMongoUnavailableFields(row map[string]any) {
	for _, field := range aslanMongoUnavailableMetricFields {
		row[field] = nil
	}
}

func applyAslanMongoGlobalGameFields(row map[string]any, flow *legacyMongoGameFlow, available bool) {
	if !available {
		return
	}
	if flow == nil {
		flow = &legacyMongoGameFlow{}
	}
	profit := flow.Turnover - flow.Payout
	row["game_turnover"] = flow.Turnover
	row["game_payout"] = flow.Payout
	row["game_profit"] = profit
	row["game_profit_rate"] = ratioFloat64(profit, flow.Turnover)
	// wallet_gold_count 没有 user_id，game_players 必须继续保持 nil，不能用事件类型数量冒充玩家数。
}

func applyAslanMongoGlobalGameOverview(row map[string]any, current externalDashboardMetric, previous externalDashboardMetric, available bool) {
	if !available {
		return
	}
	applyAslanMongoGlobalGameFields(row, &legacyMongoGameFlow{Turnover: current.GameTurnover, Payout: current.GamePayout}, true)
	// 环比基于同一个 Riyadh 日快照口径计算；上一周期真实为 0 时 deltaRate 按公共规则返回 nil，
	// 不伪造无穷增长率。
	row["game_turnover_delta_rate"] = deltaRate(current.GameTurnover, previous.GameTurnover)
	row["game_payout_delta_rate"] = deltaRate(current.GamePayout, previous.GamePayout)
	row["game_profit_delta_rate"] = deltaRate(current.GameProfit, previous.GameProfit)
}

var aslanMongoLegacyMetricFields = []string{
	"coin_seller_recharge_usd_minor",
}

func applyAslanMongoLegacyUnavailableFields(row map[string]any, legacyRecharge bool) {
	if legacyRecharge {
		return
	}
	for _, field := range aslanMongoLegacyMetricFields {
		row[field] = nil
	}
}

// 金币流水集合缺失时必须显示 "--" 的字段；置 0 会让幸运礼物利润率假性 100%、币商/金币口径失真。
var aslanMongoGoldWaterMetricFields = []string{
	"lucky_gift_payout",
	"lucky_gift_payout_rate",
	"lucky_gift_profit",
	"lucky_gift_profit_rate",
	"lucky_gift_anchor_share",
	"coin_seller_transfer_coin",
	"consumed_coin",
	"output_coin",
	"consume_output_ratio",
	"consume_output_delta",
}

func applyAslanMongoGoldWaterUnavailable(row map[string]any) {
	for _, field := range aslanMongoGoldWaterMetricFields {
		row[field] = nil
	}
}

func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool, legacyGameTotals bool, countryFiltered bool) []map[string]any {
	goldWaterSources := []map[string]any{
		{"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"},
		{"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"},
		{"field": "consumed_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=1（仅全 App 口径，不拆国家）"},
		{"field": "output_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 type=0（仅全 App 口径，不拆国家）"},
		{"field": "consume_output_ratio", "available": true, "source": "consumed_coin / output_coin（仅全 App 口径）"},
	}
	if !goldWater {
		goldWaterSources = []map[string]any{
			{"field": "lucky_gift_payout", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
			{"field": "coin_seller_transfer_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
			{"field": "consumed_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
			{"field": "output_coin", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
			{"field": "consume_output_ratio", "available": false, "missing_reason": "该部署的 Mongo 没有 user_gold_running_water_v2 集合"},
		}
	}
	legacySources := []map[string]any{
		{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"},
	}
	rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId（排除 FREIGHT_GOLD，国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底）"
	rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd（排除 FREIGHT_GOLD，国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底）"
	mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE（排除 FREIGHT_GOLD，全部三方渠道合计）"
	newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details（排除 FREIGHT_GOLD）"
	if !legacyRecharge {
		legacySources = []map[string]any{
			{"field": "coin_seller_recharge_usd_minor", "available": false, "missing_reason": "未配置 legacy_mysql_dsn，无法读取 Aslan 金币代理发货充值金额"},
		}
	} else {
		rechargeUsersSource += " + legacy MySQL order_other_recharge/user_gold_coin_recharge/user_freight_balance_running_water distinct user_id"
		rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"
		mifaPaySource += " + legacy MySQL order_other_recharge amount"
		newUserRechargeSource += "/legacy 其他充值/legacy 币商充值"
	}
	gameSources := []map[string]any{}
	if legacyGameTotals {
		gameSource := "likei legacy MySQL atyou_wallet.wallet_gold_count（Asia/Riyadh 日口径；exact game event_type 白名单；type=1 投注、type=0 返奖）"
		gameSources = []map[string]any{
			{"field": "game_turnover", "available": true, "source": gameSource},
			{"field": "game_payout", "available": true, "source": gameSource},
			{"field": "game_profit", "available": true, "source": "game_turnover - game_payout"},
			{"field": "game_profit_rate", "available": true, "source": "game_profit / game_turnover"},
			{"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id，不能从日金额快照推导游戏人数"},
		}
	} else {
		reason := "未配置 legacy MySQL 钱包库，无法读取 Aslan 游戏日快照"
		if countryFiltered {
			reason = "wallet_gold_count 只有全 App 日汇总，区域筛选时不能返回全局游戏值"
		}
		gameSources = []map[string]any{
			{"field": "game_turnover", "available": false, "missing_reason": reason},
			{"field": "game_payout", "available": false, "missing_reason": reason},
			{"field": "game_profit", "available": false, "missing_reason": reason},
			{"field": "game_profit_rate", "available": false, "missing_reason": reason},
			{"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id，不能从日金额快照推导游戏人数"},
		}
	}
	baseSources := []map[string]any{
		{"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"},
		{"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"},
		{"field": "paid_users", "available": true, "source": rechargeUsersSource},
		{"field": "recharge_users", "available": true, "source": rechargeUsersSource},
		{"field": "recharge_usd_minor", "available": true, "source": rechargeAmountSource},
		{"field": "google_recharge_usd_minor", "available": true, "source": "likei Mongo in_app_purchase_details factory.factoryCode=GOOGLE"},
		{"field": "mifapay_recharge_usd_minor", "available": true, "source": mifaPaySource},
		{"field": "new_user_recharge_usd_minor", "available": true, "source": newUserRechargeSource},
		{"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount（含幸运礼物）"},
		{"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"},
		{"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"},
		{"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"},
		{"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"},
		{"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"},
		{"field": "manual_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认人工发放金币来源枚举"},
		{"field": "salary_usd_minor", "available": true, "source": "likei Mongo team_member + user_bank_balance + user_run_profile 当前快照"},
		{"field": "mic_online_ms", "available": false, "missing_reason": "likei Mongo 未确认麦上时长集合"},
		{"field": "arpu_usd_minor", "available": true, "source": "recharge_usd_minor / active_users"},
		{"field": "arppu_usd_minor", "available": true, "source": "recharge_usd_minor / paid_users"},
		{"field": "paid_conversion_rate", "available": true, "source": "paid_users / active_users"},
		{"field": "recharge_conversion_rate", "available": true, "source": "recharge_users / active_users"},
		{"field": "retention.day1_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+1"},
		{"field": "retention.day7_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+7"},
		{"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"},
	}
	out := append(baseSources, legacySources...)
	out = append(out, gameSources...)
	return append(out, goldWaterSources...)
}
