package payment

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/config"
	"hyapp-admin-server/internal/integration/googleorders"
	"hyapp-admin-server/internal/model"
	"hyapp-admin-server/internal/modules/dashboard"
	"hyapp-admin-server/internal/repository"

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

// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集；用户/币商筛选属于 hyapp 语义，legacy 源不适用。
type legacyRechargeBillQuery struct {
	Keyword string
	// RechargeType 支持 google_play_recharge / third_party 的 Mongo 明细口径；
	// coin_seller 优先走 dashboard 外部源暴露的 legacy 明细，缺明细源时返回空列表。
	RechargeType string
	// PaidState=unsynced 只看谷歌实付未同步的账单。
	PaidState string
	RegionID  int64
	StartAtMS int64
	EndAtMS   int64
	// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验和聚合列表日期解释；0 保持财务页默认中国时区。
	TzOffsetMinutes int32
	Page            int
	PageSize        int
}

// RechargeBillSource 是 legacy App（Yumi 等 likei 平台）充值账单的外部只读来源；
// 命中 app_code 后，财务充值明细和汇总直接走该来源，不再请求 wallet-service。
type RechargeBillSource interface {
	AppCode() string
	AppName() string
	ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error)
	SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error)
	// SupportsGooglePaidSync 为 true 时，RefreshGooglePaidDetails 用该 App 的 Play Console 服务账号同步实付明细。
	SupportsGooglePaidSync() bool
	// ListPendingGooglePaidTransactionIDs returns newest successful Google bills that still need paid-detail synchronization.
	ListPendingGooglePaidTransactionIDs(ctx context.Context, limit int) ([]string, error)
	RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error)
	// Overview 返回财务概览聚合：按日趋势、区域分布与谷歌实付覆盖度。
	Overview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (rechargeBillOverviewDTO, error)
}

// legacyRegionCountryResolver 复用财务范围的区域→国家映射（MongoMoneyRegionSource 已实现）。
type legacyRegionCountryResolver interface {
	CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error)
}

// LegacyGooglePaidStore 缓存 legacy 谷歌账单的 Orders API 实付明细（repository.Store 实现）。
type LegacyGooglePaidStore interface {
	UpsertLegacyGooglePaidDetail(detail model.LegacyGooglePaidDetail) error
	LegacyGooglePaidDetails(appCode string, transactionIDs []string) (map[string]model.LegacyGooglePaidDetail, error)
	LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (repository.LegacyGooglePaidStats, error)
	LegacyGooglePaidTransactionIDs(appCode string) ([]string, error)
	LegacyGooglePaidRetryBlockedTransactionIDs(appCode string, nowMS int64) ([]string, error)
}

// legacyGoogleOrdersClient 按 legacy App 各自的 Play Console 服务账号查订单实付。
type legacyGoogleOrdersClient interface {
	GetOrder(ctx context.Context, orderID string) (googleorders.Order, error)
}

type legacyGoogleOrdersBatchClient interface {
	GetOrders(ctx context.Context, orderIDs []string) (map[string]googleorders.Order, error)
}

// errLegacyGooglePaidUnsupported 表示该账单源未配置谷歌包名/服务账号或实付缓存存储。
var errLegacyGooglePaidUnsupported = errors.New("legacy google paid sync is not configured")

const defaultLegacyFinanceTZOffsetMinutes int32 = 480

// MongoRechargeBillSource 读取 likei Mongo 的 in_app_purchase_details 集合作为充值账单事实。
type MongoRechargeBillSource struct {
	collection      *mongo.Collection
	database        *mongo.Database
	config          config.FinanceBillSourceConfig
	regionResolvers []legacyRegionCountryResolver
	regionSources   []MoneyRegionSource
	paidStore       LegacyGooglePaidStore
	googleOrders    legacyGoogleOrdersClient
	coinSellerStats legacyCoinSellerRechargeStatsSource
	coinSellerBills dashboard.CoinSellerRechargeBillSource
}

// NewMongoRechargeBillSource 创建 legacy 账单源；regionSources 用于把后台区域 ID 解析成国家码过滤条件。
func NewMongoRechargeBillSource(database *mongo.Database, sourceConfig config.FinanceBillSourceConfig, regionSources ...MoneyRegionSource) *MongoRechargeBillSource {
	if database == nil || !sourceConfig.Enabled {
		return nil
	}
	collectionName := strings.TrimSpace(sourceConfig.MongoCollection)
	if collectionName == "" {
		collectionName = "in_app_purchase_details"
	}
	sourceConfig.AppCode = appctx.Normalize(sourceConfig.AppCode)
	sourceConfig.AppName = strings.TrimSpace(sourceConfig.AppName)
	sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
	source := &MongoRechargeBillSource{collection: database.Collection(collectionName), database: database, config: sourceConfig}
	for _, regionSource := range regionSources {
		if regionSource == nil {
			continue
		}
		source.regionSources = append(source.regionSources, regionSource)
		if resolver, ok := regionSource.(legacyRegionCountryResolver); ok {
			source.regionResolvers = append(source.regionResolvers, resolver)
		}
	}
	return source
}

// SetGooglePaidSync 注入实付缓存存储与该 App 的 Google Orders 客户端；未注入时“查询谷歌实付”对该源不可用。
func (s *MongoRechargeBillSource) SetGooglePaidSync(paidStore LegacyGooglePaidStore, googleOrders legacyGoogleOrdersClient) {
	if s == nil {
		return
	}
	s.paidStore = paidStore
	s.googleOrders = googleOrders
}

// SetDashboardCoinSellerSource 复用 databi/social 的外接 dashboard 聚合源补齐 legacy finance 的币商充值。
// Yumi 的币商金额来自 dashboard-cdc-worker dealer_recharge，Aslan 的金币代理发货金额来自 Aslan dashboard 已补齐的 legacy 事实。
func (s *MongoRechargeBillSource) SetDashboardCoinSellerSource(source dashboard.ExternalDashboardSource) {
	if s == nil || source == nil {
		return
	}
	if appctx.Normalize(source.AppCode()) != s.config.AppCode {
		return
	}
	s.coinSellerStats = dashboardCoinSellerRechargeStatsSource{source: source}
	if billSource, ok := source.(dashboard.CoinSellerRechargeBillSource); ok {
		s.coinSellerBills = billSource
	}
}

// SupportsGooglePaidSync 供 handler 判定该源能否响应谷歌实付刷新。
func (s *MongoRechargeBillSource) SupportsGooglePaidSync() bool {
	return s != nil && s.paidStore != nil && s.googleOrders != nil
}

// ListPendingGooglePaidTransactionIDs scans the read-only legacy Mongo source and subtracts both
// successful cache rows and failures still inside persisted backoff. Sorting newest-first keeps the
// 15-second loop current while successful removals naturally drain the complete historical backlog.
func (s *MongoRechargeBillSource) ListPendingGooglePaidTransactionIDs(ctx context.Context, limit int) ([]string, error) {
	if !s.SupportsGooglePaidSync() {
		return nil, errLegacyGooglePaidUnsupported
	}
	if limit <= 0 {
		return []string{}, nil
	}
	if limit > 1000 {
		limit = 1000
	}
	filter, _, matchable, err := s.billFilter(ctx, legacyRechargeBillQuery{RechargeType: "google_play_recharge"})
	if err != nil || !matchable {
		return nil, err
	}
	syncedIDs, err := s.paidStore.LegacyGooglePaidTransactionIDs(s.config.AppCode)
	if err != nil {
		return nil, fmt.Errorf("load synced legacy google transactions for %s: %w", s.config.AppCode, err)
	}
	blockedIDs, err := s.paidStore.LegacyGooglePaidRetryBlockedTransactionIDs(s.config.AppCode, time.Now().UnixMilli())
	if err != nil {
		return nil, fmt.Errorf("load blocked legacy google transactions for %s: %w", s.config.AppCode, err)
	}
	excluded := append(syncedIDs, blockedIDs...)
	if values := legacyTransactionIDFilterValues(excluded); len(values) > 0 {
		filter["_id"] = bson.M{"$nin": values}
	}
	cursor, err := s.collection.Find(ctx, filter, options.Find().
		SetProjection(bson.M{"_id": 1}).
		SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
		SetLimit(int64(limit)))
	if err != nil {
		return nil, fmt.Errorf("list pending legacy google bills for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)
	ids := make([]string, 0, limit)
	for cursor.Next(ctx) {
		var row struct {
			ID any `bson:"_id"`
		}
		if err := cursor.Decode(&row); err != nil {
			return nil, fmt.Errorf("decode pending legacy google bill for %s: %w", s.config.AppCode, err)
		}
		if transactionID := legacyDocumentID(row.ID); transactionID != "" {
			ids = append(ids, transactionID)
		}
	}
	if err := cursor.Err(); err != nil {
		return nil, fmt.Errorf("iterate pending legacy google bills for %s: %w", s.config.AppCode, err)
	}
	return ids, nil
}

// legacyTransactionIDFilterValues covers both string IDs and ObjectID-backed collections.
// The paid cache stores a stable string representation, so both BSON shapes must be excluded.
func legacyTransactionIDFilterValues(transactionIDs []string) bson.A {
	values := make(bson.A, 0, len(transactionIDs)*2)
	seen := make(map[string]struct{}, len(transactionIDs))
	for _, transactionID := range transactionIDs {
		transactionID = strings.TrimSpace(transactionID)
		if transactionID == "" {
			continue
		}
		if _, ok := seen[transactionID]; ok {
			continue
		}
		seen[transactionID] = struct{}{}
		values = append(values, transactionID)
		if objectID, err := bson.ObjectIDFromHex(transactionID); err == nil {
			values = append(values, objectID)
		}
	}
	return values
}

func (s *MongoRechargeBillSource) AppCode() string {
	return s.config.AppCode
}

func (s *MongoRechargeBillSource) AppName() string {
	if s.config.AppName != "" {
		return s.config.AppName
	}
	return s.config.AppCode
}

// LogoURL 与 legacy 账单源同配置生命周期；这类 App 不是 HyApp gateway 租户，不为了展示 Logo 向 apps 注册表塞入虚假 active 记录。
func (s *MongoRechargeBillSource) LogoURL() string {
	return strings.TrimSpace(s.config.LogoURL)
}

type legacyCoinSellerRechargeStats struct {
	TotalUSDMinor   int64
	DailyUSDMinor   map[string]int64
	CountryUSDMinor map[string]int64
}

type legacyCoinSellerRechargeStatsSource interface {
	CoinSellerRechargeStats(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (legacyCoinSellerRechargeStats, error)
}

type dashboardCoinSellerRechargeStatsSource struct {
	source dashboard.ExternalDashboardSource
}

func (s dashboardCoinSellerRechargeStatsSource) CoinSellerRechargeStats(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (legacyCoinSellerRechargeStats, error) {
	stats := legacyCoinSellerRechargeStats{
		DailyUSDMinor:   map[string]int64{},
		CountryUSDMinor: map[string]int64{},
	}
	if s.source == nil || !legacyCoinSellerAggregateFilterSupported(query) {
		return stats, nil
	}
	statTimezone, startMS, endMS, err := legacyDashboardDayRange(query, tzOffsetMinutes)
	if err != nil {
		return stats, err
	}
	overview, err := s.source.StatisticsOverview(ctx, dashboard.StatisticsQuery{
		AppCode:  s.source.AppCode(),
		StatTZ:   statTimezone,
		StartMS:  startMS,
		EndMS:    endMS,
		RegionID: query.RegionID,
	})
	if err != nil {
		return stats, err
	}
	total, err := legacyDashboardRequiredInt64(overview, "coin_seller_recharge_usd_minor")
	if err != nil {
		return stats, err
	}
	stats.TotalUSDMinor = total
	for _, row := range legacyMapRows(overview["daily_series"]) {
		statDay := legacyMapString(row, "stat_day")
		if statDay == "" {
			statDay = legacyMapString(row, "label")
		}
		if statDay == "" {
			continue
		}
		stats.DailyUSDMinor[statDay] += legacyMapInt64(row["coin_seller_recharge_usd_minor"])
	}
	for _, row := range legacyMapRows(overview["country_breakdown"]) {
		countryCode := strings.ToUpper(strings.TrimSpace(legacyMapString(row, "country_code")))
		if countryCode == "" {
			continue
		}
		stats.CountryUSDMinor[countryCode] += legacyMapInt64(row["coin_seller_recharge_usd_minor"])
	}
	return stats, nil
}

// legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档（只取展示需要的字段）。
type legacyPurchaseDocument struct {
	ID                 any                     `bson:"_id"`
	OrderID            string                  `bson:"orderId"`
	AcceptUserID       int64                   `bson:"acceptUserId"`
	CountryCode        string                  `bson:"countryCode"`
	Currency           string                  `bson:"currency"`
	Amount             any                     `bson:"amount"`
	AmountUSD          any                     `bson:"amountUsd"`
	Status             string                  `bson:"status"`
	TrackCommodityType string                  `bson:"trackCommodityType"`
	Factory            legacyPurchaseFactory   `bson:"factory"`
	Products           []legacyPurchaseProduct `bson:"products"`
	CreateTime         time.Time               `bson:"createTime"`
	UpdateTime         time.Time               `bson:"updateTime"`
	PurchaseDateMS     time.Time               `bson:"purchaseDateMs"`
	// LegacyBillCountry 由聚合阶段（legacyCountryResolveStages）解析：账单国家码优先，回退付款用户资料。
	LegacyBillCountry string `bson:"legacyBillCountry"`
}

type legacyPurchaseFactory struct {
	Platform    string `bson:"platform"`
	FactoryCode string `bson:"factoryCode"`
}

type legacyPurchaseProduct struct {
	Code    string `bson:"code"`
	Name    string `bson:"name"`
	Content string `bson:"content"`
}

func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
	if s == nil {
		return nil, 0, fmt.Errorf("legacy bill source is not configured")
	}
	if legacyQueryRechargeType(query) == "coin_seller" {
		// 财务工作台币商充值已切到后台币商订单 + H5 币商身份订单，legacy dashboard 进货聚合不再作为账单来源。
		return []rechargeBillDTO{}, 0, nil
	}
	if s.collection == nil {
		return nil, 0, fmt.Errorf("legacy bill source is not configured")
	}
	filter, regionCodes, matchable, err := s.billFilter(ctx, query)
	if err != nil {
		return nil, 0, err
	}
	if !matchable {
		return []rechargeBillDTO{}, 0, nil
	}
	page := query.Page
	if page < 1 {
		page = 1
	}
	pageSize := query.PageSize
	if pageSize < 1 {
		pageSize = 10
	}
	if len(regionCodes) == 0 {
		return s.listBillsByFilter(ctx, filter, page, pageSize)
	}
	return s.listBillsByRegionPipeline(ctx, filter, regionCodes, page, pageSize)
}

func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
	if s == nil || s.coinSellerBills == nil || strings.TrimSpace(query.PaidState) != "" {
		return []rechargeBillDTO{}, 0, nil
	}
	rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{
		Keyword:  query.Keyword,
		RegionID: query.RegionID,
		StatTZ:   legacyDashboardStatTimezone(legacySummaryTZOffset(query)),
		StartMS:  query.StartAtMS,
		EndMS:    query.EndAtMS,
		Page:     query.Page,
		PageSize: query.PageSize,
	})
	if err != nil {
		return nil, 0, fmt.Errorf("query legacy coin seller recharge bills for %s: %w", s.config.AppCode, err)
	}
	countryToRegion := s.legacyCountryRegionIndex(ctx)
	items := make([]rechargeBillDTO, 0, len(rows))
	for _, row := range rows {
		item := rechargeBillDTO{
			AppCode:              s.config.AppCode,
			TransactionID:        row.TransactionID,
			RechargeType:         legacyCoinSellerBillRechargeType(row.Source),
			Status:               "succeeded",
			ExternalRef:          row.Source,
			SellerUserID:         row.UserID,
			CurrencyCode:         "USD",
			USDMinorAmount:       row.USDMinorAmount,
			CreatedAtMS:          row.CreatedAtMS,
			ProviderCode:         "coin_seller",
			UserPaidCurrencyCode: "USD",
			UserPaidAmountMicro:  row.USDMinorAmount * 10_000,
			PaidSyncedAtMS:       row.CreatedAtMS,
			Seller:               rechargeBillUserOrFallback(rechargeBillUserDTO{}, row.UserID),
		}
		if region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.CountryCode))]; ok {
			item.SellerRegionID = region.RegionID
			item.TargetRegionID = region.RegionID
		}
		items = append(items, item)
	}
	return items, total, nil
}

func legacyCoinSellerBillRechargeType(source string) string {
	switch strings.ToLower(strings.TrimSpace(source)) {
	case "freight_deduction", "coin_seller_stock_deduction":
		return "coin_seller_stock_deduction"
	default:
		return "coin_seller_stock_purchase"
	}
}

func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) {
	total, err := s.collection.CountDocuments(ctx, filter)
	if err != nil {
		return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	// 国家解析放在分页之后：每页最多 pageSize 次 $lookup，用于把账单落到区域列展示。
	pipeline := append(mongo.Pipeline{
		bson.D{{Key: "$match", Value: filter}},
		bson.D{{Key: "$sort", Value: bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}}},
		bson.D{{Key: "$skip", Value: int64(page-1) * int64(pageSize)}},
		bson.D{{Key: "$limit", Value: int64(pageSize)}},
	}, legacyCountryResolveStages()...)
	cursor, err := s.collection.Aggregate(ctx, pipeline)
	if err != nil {
		return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)
	items, err := s.decodeBillCursor(ctx, cursor, pageSize)
	if err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// listBillsByRegionPipeline 处理区域筛选：likei 账单的 countryCode 长期为空，区域归属只能按付款用户
// user_run_profile.countryCode 反查，因此列表和计数都要走 $lookup 聚合。
func (s *MongoRechargeBillSource) listBillsByRegionPipeline(ctx context.Context, filter bson.M, regionCodes []string, page int, pageSize int) ([]rechargeBillDTO, int64, error) {
	base := append(mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}, legacyRegionLookupStages(regionCodes)...)

	countCursor, err := s.collection.Aggregate(ctx, append(clonePipeline(base), bson.D{{Key: "$count", Value: "n"}}))
	if err != nil {
		return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	defer countCursor.Close(ctx)
	var total int64
	if countCursor.Next(ctx) {
		var row struct {
			N int64 `bson:"n"`
		}
		if err := countCursor.Decode(&row); err != nil {
			return nil, 0, fmt.Errorf("decode legacy recharge bill count for %s: %w", s.config.AppCode, err)
		}
		total = row.N
	}
	if err := countCursor.Err(); err != nil {
		return nil, 0, fmt.Errorf("iterate legacy recharge bill count for %s: %w", s.config.AppCode, err)
	}
	if total == 0 {
		return []rechargeBillDTO{}, 0, nil
	}

	pagePipeline := append(clonePipeline(base),
		bson.D{{Key: "$sort", Value: bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}}},
		bson.D{{Key: "$skip", Value: int64(page-1) * int64(pageSize)}},
		bson.D{{Key: "$limit", Value: int64(pageSize)}},
	)
	cursor, err := s.collection.Aggregate(ctx, pagePipeline)
	if err != nil {
		return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)
	items, err := s.decodeBillCursor(ctx, cursor, pageSize)
	if err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

func (s *MongoRechargeBillSource) decodeBillCursor(ctx context.Context, cursor *mongo.Cursor, pageSize int) ([]rechargeBillDTO, error) {
	countryToRegion := s.legacyCountryRegionIndex(ctx)
	items := make([]rechargeBillDTO, 0, pageSize)
	for cursor.Next(ctx) {
		var document legacyPurchaseDocument
		if err := cursor.Decode(&document); err != nil {
			return nil, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
		}
		item := legacyRechargeBillDTO(s.config, document)
		// likei 账单没有区域 ID；用解析出的国家码映射区域目录，供列表区域列与导出展示。
		if country := strings.ToUpper(strings.TrimSpace(document.LegacyBillCountry)); country != "" {
			if region, ok := countryToRegion[country]; ok {
				item.TargetRegionID = region.RegionID
			}
		}
		items = append(items, item)
	}
	if err := cursor.Err(); err != nil {
		return nil, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	if err := s.fillGooglePaidDetails(items); err != nil {
		return nil, err
	}
	return items, nil
}

// fillGooglePaidDetails 把已同步的 Orders API 实付明细回填到当页谷歌账单：实付币种/总额/税/净收入，
// 手续费与 Lalu 同口径反推（实付 − 净收入 − 税）。未同步的谷歌账单保持 paidSyncedAtMs=0，前端提示可查询。
func (s *MongoRechargeBillSource) fillGooglePaidDetails(items []rechargeBillDTO) error {
	if s.paidStore == nil {
		return nil
	}
	googleTxIDs := make([]string, 0, len(items))
	for _, item := range items {
		if item.RechargeType == "google_play_recharge" {
			googleTxIDs = append(googleTxIDs, item.TransactionID)
		}
	}
	if len(googleTxIDs) == 0 {
		return nil
	}
	details, err := s.paidStore.LegacyGooglePaidDetails(s.config.AppCode, googleTxIDs)
	if err != nil {
		return fmt.Errorf("load legacy google paid details for %s: %w", s.config.AppCode, err)
	}
	for index := range items {
		detail, ok := details[items[index].TransactionID]
		if !ok || items[index].RechargeType != "google_play_recharge" {
			continue
		}
		items[index].UserPaidCurrencyCode = detail.PaidCurrencyCode
		items[index].UserPaidAmountMicro = detail.PaidAmountMicro
		items[index].ProviderTaxMicro = detail.PaidTaxMicro
		items[index].ProviderNetMicro = detail.PaidNetMicro
		if detail.PaidAmountMicro > 0 && detail.PaidNetMicro > 0 {
			if fee := detail.PaidAmountMicro - detail.PaidNetMicro - detail.PaidTaxMicro; fee > 0 {
				items[index].ProviderFeeMicro = fee
			}
		}
		items[index].PaidSyncedAtMS = detail.SyncedAtMS
	}
	return nil
}

const maxLegacyGooglePaidRefreshBatch = 100

// RefreshGooglePaidDetails 用该 App 的 Play Console 服务账号逐单拉取实付明细并缓存；单笔失败不阻断整批。
func (s *MongoRechargeBillSource) RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error) {
	if !s.SupportsGooglePaidSync() {
		return nil, errLegacyGooglePaidUnsupported
	}
	cleaned := make([]string, 0, len(transactionIDs))
	for _, transactionID := range transactionIDs {
		if transactionID = strings.TrimSpace(transactionID); transactionID != "" {
			cleaned = append(cleaned, transactionID)
		}
	}
	if len(cleaned) == 0 {
		return []googleRechargePaidDTO{}, nil
	}
	if len(cleaned) > maxLegacyGooglePaidRefreshBatch {
		return nil, fmt.Errorf("too many transaction_ids")
	}
	idValues := make(bson.A, 0, len(cleaned))
	for _, transactionID := range cleaned {
		idValues = append(idValues, transactionID)
	}
	cursor, err := s.collection.Find(ctx, bson.M{
		"_id":       bson.M{"$in": idValues},
		"sysOrigin": s.config.SysOrigin,
	})
	if err != nil {
		return nil, fmt.Errorf("load legacy bills for google paid refresh: %w", err)
	}
	defer cursor.Close(ctx)
	bills := map[string]legacyPurchaseDocument{}
	for cursor.Next(ctx) {
		var document legacyPurchaseDocument
		if err := cursor.Decode(&document); err != nil {
			return nil, fmt.Errorf("decode legacy bill for google paid refresh: %w", err)
		}
		bills[legacyDocumentID(document.ID)] = document
	}
	if err := cursor.Err(); err != nil {
		return nil, fmt.Errorf("iterate legacy bills for google paid refresh: %w", err)
	}

	// Backfills use batchGet whenever every order can be resolved in one Google request. Google rejects
	// an entire batch containing one stale ID, so failures fall back to bounded concurrent single gets.
	prefetched := map[string]googleorders.Order{}
	batchReady := false
	if batchClient, ok := s.googleOrders.(legacyGoogleOrdersBatchClient); ok {
		orderIDs := make([]string, 0, len(bills))
		for _, transactionID := range cleaned {
			if document, exists := bills[transactionID]; exists {
				if orderID := strings.TrimSpace(document.OrderID); orderID != "" {
					orderIDs = append(orderIDs, orderID)
				}
			}
		}
		if len(orderIDs) > 0 {
			if orders, batchErr := batchClient.GetOrders(ctx, orderIDs); batchErr == nil {
				prefetched = orders
				batchReady = true
			}
		}
	}
	results := make([]googleRechargePaidDTO, len(cleaned))
	if batchReady {
		for index, transactionID := range cleaned {
			results[index] = s.refreshGooglePaidDetailWithOrders(ctx, transactionID, bills, prefetched)
		}
		return results, nil
	}
	const fallbackConcurrency = 8
	semaphore := make(chan struct{}, fallbackConcurrency)
	var wait sync.WaitGroup
	for index, transactionID := range cleaned {
		wait.Add(1)
		go func(index int, transactionID string) {
			defer wait.Done()
			select {
			case semaphore <- struct{}{}:
				defer func() { <-semaphore }()
			case <-ctx.Done():
				results[index] = googleRechargePaidDTO{TransactionID: transactionID, Error: ctx.Err().Error()}
				return
			}
			results[index] = s.refreshGooglePaidDetail(ctx, transactionID, bills)
		}(index, transactionID)
	}
	wait.Wait()
	return results, nil
}

func (s *MongoRechargeBillSource) refreshGooglePaidDetail(ctx context.Context, transactionID string, bills map[string]legacyPurchaseDocument) googleRechargePaidDTO {
	return s.refreshGooglePaidDetailWithOrders(ctx, transactionID, bills, nil)
}

func (s *MongoRechargeBillSource) refreshGooglePaidDetailWithOrders(ctx context.Context, transactionID string, bills map[string]legacyPurchaseDocument, prefetched map[string]googleorders.Order) googleRechargePaidDTO {
	result := googleRechargePaidDTO{TransactionID: transactionID}
	document, ok := bills[transactionID]
	if !ok {
		result.Error = "账单不存在"
		return result
	}
	if strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode)) != "GOOGLE" {
		result.Error = "不是谷歌账单"
		return result
	}
	orderID := strings.TrimSpace(document.OrderID)
	if orderID == "" {
		result.Error = "缺少谷歌订单号"
		return result
	}
	order, prefetchedOK := prefetched[orderID]
	if prefetched != nil && !prefetchedOK {
		result.Error = "google order is not found"
		return result
	}
	if !prefetchedOK {
		var err error
		order, err = s.googleOrders.GetOrder(ctx, orderID)
		if err != nil {
			result.Error = err.Error()
			return result
		}
	}
	syncedAt := time.Now().UnixMilli()
	billCreatedAtMS := int64(0)
	if !document.CreateTime.IsZero() {
		billCreatedAtMS = document.CreateTime.UnixMilli()
	} else if !document.PurchaseDateMS.IsZero() {
		billCreatedAtMS = document.PurchaseDateMS.UnixMilli()
	}
	if err := s.paidStore.UpsertLegacyGooglePaidDetail(model.LegacyGooglePaidDetail{
		AppCode:          s.config.AppCode,
		TransactionID:    transactionID,
		ProviderOrderID:  orderID,
		PaidCurrencyCode: strings.ToUpper(strings.TrimSpace(order.CurrencyCode)),
		PaidAmountMicro:  order.TotalAmountMicro,
		PaidTaxMicro:     order.TaxAmountMicro,
		PaidNetMicro:     order.NetAmountMicro,
		BillUSDMinor:     legacyDecimalToMinor(document.AmountUSD),
		BillCreatedAtMS:  billCreatedAtMS,
		SyncedAtMS:       syncedAt,
	}); err != nil {
		result.Error = err.Error()
		return result
	}
	result.CurrencyCode = strings.ToUpper(strings.TrimSpace(order.CurrencyCode))
	result.PaidAmountMicro = order.TotalAmountMicro
	result.TaxMicro = order.TaxAmountMicro
	result.NetMicro = order.NetAmountMicro
	result.SyncedAtMS = syncedAt
	return result
}

func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) {
	if s == nil || s.collection == nil {
		return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
	}
	if legacyQueryRechargeType(query) == "coin_seller" {
		return rechargeBillSummaryDTO{}, nil
	}
	filter, regionCodes, matchable, err := s.billFilter(ctx, query)
	if err != nil {
		return rechargeBillSummaryDTO{}, err
	}
	if !matchable {
		return rechargeBillSummaryDTO{}, nil
	}
	// 金币数只统计 GOLD/CANDY 商品内容，口径对齐 chatapp3 的 firstGoldProduct；content 非数值时按 0 计入。
	coinExpr := bson.M{"$reduce": bson.M{
		"input": bson.M{"$filter": bson.M{
			"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
			"cond":  bson.M{"$in": bson.A{"$$this.name", bson.A{"GOLD", "CANDY"}}},
		}},
		"initialValue": 0,
		"in": bson.M{"$add": bson.A{
			"$$value",
			bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": 0, "onNull": 0}},
		}},
	}}
	pipeline := append(mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}, legacyRegionLookupStages(regionCodes)...)
	pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.M{
		"_id": bson.M{"$cond": bson.A{
			bson.M{"$eq": bson.A{"$factory.factoryCode", "GOOGLE"}},
			"google",
			"third_party",
		}},
		"billCount": bson.M{"$sum": 1},
		"usd":       bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
		"coin":      bson.M{"$sum": coinExpr},
	}}})
	cursor, err := s.collection.Aggregate(ctx, pipeline)
	if err != nil {
		return rechargeBillSummaryDTO{}, fmt.Errorf("summarize legacy recharge bills for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)

	summary := rechargeBillSummaryDTO{}
	for cursor.Next(ctx) {
		var row struct {
			ID        string `bson:"_id"`
			BillCount int64  `bson:"billCount"`
			USD       any    `bson:"usd"`
			Coin      int64  `bson:"coin"`
		}
		if err := cursor.Decode(&row); err != nil {
			return rechargeBillSummaryDTO{}, fmt.Errorf("decode legacy recharge summary for %s: %w", s.config.AppCode, err)
		}
		bucket := rechargeBillSummaryBucketDTO{
			BillCount:      row.BillCount,
			CoinAmount:     row.Coin,
			USDMinorAmount: legacyDecimalToMinor(row.USD),
		}
		if row.ID == "google" {
			summary.GooglePlay = bucket
		} else {
			summary.ThirdParty.BillCount += bucket.BillCount
			summary.ThirdParty.CoinAmount += bucket.CoinAmount
			summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount
		}
	}
	if err := cursor.Err(); err != nil {
		return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err)
	}
	legacyRecomputeSummaryTotal(&summary)
	return summary, nil
}

func (s *MongoRechargeBillSource) mergeCoinSellerSummary(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32, summary *rechargeBillSummaryDTO) error {
	if s == nil || s.coinSellerStats == nil || summary == nil || !legacyCoinSellerAggregateRequested(query) {
		return nil
	}
	stats, err := s.coinSellerStats.CoinSellerRechargeStats(ctx, query, tzOffsetMinutes)
	if err != nil {
		return fmt.Errorf("load legacy coin seller recharge stats for %s: %w", s.config.AppCode, err)
	}
	summary.CoinSeller.USDMinorAmount += stats.TotalUSDMinor
	return nil
}

func legacyRecomputeSummaryTotal(summary *rechargeBillSummaryDTO) {
	if summary == nil {
		return
	}
	summary.Total = rechargeBillSummaryBucketDTO{
		BillCount:      summary.GooglePlay.BillCount + summary.ThirdParty.BillCount + summary.CoinSeller.BillCount,
		CoinAmount:     summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount + summary.CoinSeller.CoinAmount,
		USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount + summary.CoinSeller.USDMinorAmount,
	}
}

// billFilter 构造与 hyapp 账单列表同口径的 Mongo 过滤条件；返回 matchable=false 表示筛选注定无结果。
// regionCodes 非空时调用方必须叠加 legacyRegionLookupStages：likei 账单不带国家码，区域归属按付款用户资料判定。
func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRechargeBillQuery) (bson.M, []string, bool, error) {
	filter := bson.M{
		"sysOrigin": s.config.SysOrigin,
		"status":    "SUCCESS",
		// 订阅免费试用不产生真实付款，与 Aslan 大屏充值口径保持一致。
		"trialPeriod": bson.M{"$ne": true},
	}
	timeRange := bson.M{}
	if query.StartAtMS > 0 {
		timeRange["$gte"] = time.UnixMilli(query.StartAtMS)
	}
	if query.EndAtMS > 0 {
		timeRange["$lt"] = time.UnixMilli(query.EndAtMS)
	}
	if len(timeRange) > 0 {
		filter["createTime"] = timeRange
	}
	if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		// legacy 集合没有 hyapp 交易号；关键词只精确匹配内部订单 ID 和三方订单号，避免大集合正则扫描。
		filter["$or"] = bson.A{
			bson.M{"_id": keyword},
			bson.M{"orderId": keyword},
		}
	}
	switch strings.ToLower(strings.TrimSpace(query.RechargeType)) {
	case "google_play_recharge":
		filter["factory.factoryCode"] = "GOOGLE"
	case "third_party":
		filter["factory.factoryCode"] = bson.M{"$ne": "GOOGLE"}
	case "coin_seller":
		// coin_seller 明细不在 in_app_purchase_details；调用方会先走 listCoinSellerRechargeBills。
		return nil, nil, false, nil
	}
	// Aslan 金币代理“发货”历史上也落过 in_app_purchase_details；dashboard/social 已把它归入币商充值，
	// finance 的普通 Google/三方内购必须排除这些商品，避免总额重复且三方口径偏大。
	excludeLegacyFreightGoldRecharge(filter)
	if strings.ToLower(strings.TrimSpace(query.PaidState)) == "unsynced" {
		filter["factory.factoryCode"] = "GOOGLE"
		if s.paidStore != nil {
			syncedIDs, err := s.paidStore.LegacyGooglePaidTransactionIDs(s.config.AppCode)
			if err != nil {
				return nil, nil, false, fmt.Errorf("load synced legacy google tx ids for %s: %w", s.config.AppCode, err)
			}
			if idValues := legacyTransactionIDFilterValues(syncedIDs); len(idValues) > 0 {
				filter["_id"] = bson.M{"$nin": idValues}
			}
		}
	}
	if query.RegionID > 0 {
		codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
		if err != nil {
			return nil, nil, false, err
		}
		if !handled || len(codes) == 0 {
			return nil, nil, false, nil
		}
		return filter, codes, true, nil
	}
	return filter, nil, true, nil
}

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

func excludeLegacyFreightGoldRecharge(filter bson.M) {
	if filter == nil {
		return
	}
	nor := bson.A{}
	if existing, ok := filter["$nor"].(bson.A); ok {
		nor = append(nor, existing...)
	}
	for _, field := range legacyFreightCommodityFields {
		nor = append(nor, bson.M{field: bson.M{"$in": legacyFreightCommodityTypes}})
	}
	filter["$nor"] = nor
}

const legacyUserProfileCollection = "user_run_profile"

// legacyCountryResolveStages 解析账单的区域归属国家：优先账单自身 countryCode（Aslan 的 MifaPay 单会带），
// 为空时回退付款用户 user_run_profile.countryCode（Yumi/Aslan 的谷歌单都不带国家码）。
func legacyCountryResolveStages() mongo.Pipeline {
	return mongo.Pipeline{
		bson.D{{Key: "$lookup", Value: bson.M{
			"from":         legacyUserProfileCollection,
			"localField":   "acceptUserId",
			"foreignField": "_id",
			"as":           "legacyBillUser",
		}}},
		bson.D{{Key: "$addFields", Value: bson.M{
			"legacyBillCountry": 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": "$legacyBillUser.countryCode"}, ""}},
			}}},
		}}},
	}
}

// legacyRegionLookupStages 在国家解析的基础上按区域国家清单过滤；codes 为空时不追加任何阶段。
func legacyRegionLookupStages(codes []string) mongo.Pipeline {
	if len(codes) == 0 {
		return mongo.Pipeline{}
	}
	return append(legacyCountryResolveStages(),
		bson.D{{Key: "$match", Value: bson.M{"legacyBillCountry": bson.M{"$in": codes}}}},
	)
}

// clonePipeline 复制聚合管道，避免 append 复用底层数组导致 count/分页两条管道互相踩踏。
func clonePipeline(pipeline mongo.Pipeline) mongo.Pipeline {
	cloned := make(mongo.Pipeline, len(pipeline))
	copy(cloned, pipeline)
	return cloned
}

func (s *MongoRechargeBillSource) regionCountryCodes(ctx context.Context, regionID int64) ([]string, bool, error) {
	for _, resolver := range s.regionResolvers {
		codes, handled, err := resolver.CountryCodesForRegion(ctx, s.config.AppCode, regionID)
		if err != nil {
			return nil, false, err
		}
		if handled {
			normalized := make([]string, 0, len(codes))
			for _, code := range codes {
				if code = strings.ToUpper(strings.TrimSpace(code)); code != "" {
					normalized = append(normalized, code)
				}
			}
			return normalized, true, nil
		}
	}
	return nil, false, nil
}

// legacyRechargeBillDTO 把 likei 内购明细收敛成财务充值账单 DTO；金额统一按实付币种微单位、美金最小单位表达。
func legacyRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document legacyPurchaseDocument) rechargeBillDTO {
	createdAtMS := int64(0)
	if !document.CreateTime.IsZero() {
		createdAtMS = document.CreateTime.UnixMilli()
	} else if !document.PurchaseDateMS.IsZero() {
		createdAtMS = document.PurchaseDateMS.UnixMilli()
	}
	factoryCode := strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode))
	dto := rechargeBillDTO{
		AppCode:              sourceConfig.AppCode,
		TransactionID:        legacyDocumentID(document.ID),
		RechargeType:         legacyRechargeType(factoryCode),
		Status:               "succeeded",
		ExternalRef:          strings.TrimSpace(document.OrderID),
		UserID:               document.AcceptUserID,
		CurrencyCode:         strings.ToUpper(strings.TrimSpace(document.Currency)),
		CoinAmount:           legacyGoldCoinAmount(document.Products),
		USDMinorAmount:       legacyDecimalToMinor(document.AmountUSD),
		CreatedAtMS:          createdAtMS,
		ProviderCode:         legacyProviderCode(factoryCode),
		UserPaidCurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
		UserPaidAmountMicro:  legacyDecimalToMicro(document.Amount),
		// 非谷歌渠道（MifaPay 等）没有可反推的三方扣款数据，标记已同步；
		// 谷歌账单保持未同步（0），由 Orders API 实付缓存回填后才算同步完成。
		PaidSyncedAtMS: createdAtMS,
	}
	if factoryCode == "GOOGLE" {
		dto.PaidSyncedAtMS = 0
	}
	return dto
}

func legacyDocumentID(value any) string {
	if value == nil {
		return ""
	}
	if text, ok := value.(string); ok {
		return text
	}
	if objectID, ok := value.(bson.ObjectID); ok {
		return objectID.Hex()
	}
	return strings.TrimSpace(fmt.Sprint(value))
}

func legacyRechargeType(factoryCode string) string {
	switch factoryCode {
	case "GOOGLE":
		return "google_play_recharge"
	case "APPLE":
		return "apple_recharge"
	case "HUAWEI":
		return "huawei_recharge"
	case "TELEGRAM":
		return "telegram_recharge"
	case "MIFA_PAY":
		// Aslan 的 H5 三方走 MifaPay；复用 hyapp 的 mifapay 展示口径。
		return "mifapay"
	default:
		return "web_recharge"
	}
}

func legacyProviderCode(factoryCode string) string {
	if factoryCode == "" {
		return "legacy"
	}
	return strings.ToLower(factoryCode)
}

// legacyGoldCoinAmount 只统计 GOLD/CANDY 商品内容，口径对齐 chatapp3 的 firstGoldProduct。
func legacyGoldCoinAmount(products []legacyPurchaseProduct) int64 {
	var total int64
	for _, product := range products {
		if product.Name != "GOLD" && product.Name != "CANDY" {
			continue
		}
		if amount, err := strconv.ParseInt(strings.TrimSpace(product.Content), 10, 64); err == nil {
			total += amount
		}
	}
	return total
}

// Overview 返回 legacy 账单的按日趋势、区域分布与谷歌实付覆盖度；tzOffsetMinutes 决定日边界。
func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (rechargeBillOverviewDTO, error) {
	overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
	if s == nil || s.collection == nil {
		return overview, fmt.Errorf("legacy bill source is not configured")
	}
	// 用户提现（银行卡余额兑换金币/转货运代理）与充值渠道筛选无关，先于渠道可匹配性判断统计。
	if err := s.overviewWithdrawal(ctx, query, &overview); err != nil {
		return overview, err
	}
	if legacyQueryRechargeType(query) == "coin_seller" {
		return overview, nil
	}
	filter, regionCodes, matchable, err := s.billFilter(ctx, query)
	if err != nil {
		return overview, err
	}
	if !matchable {
		return overview, nil
	}
	base := append(mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}, legacyRegionLookupStages(regionCodes)...)

	if err := s.overviewDaily(ctx, base, tzOffsetMinutes, &overview); err != nil {
		return overview, err
	}
	if err := s.overviewRegions(ctx, base, &overview); err != nil {
		return overview, err
	}
	if err := s.overviewGooglePaid(ctx, filter, query, &overview); err != nil {
		return overview, err
	}
	return overview, nil
}

func (s *MongoRechargeBillSource) mergeCoinSellerOverview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32, overview *rechargeBillOverviewDTO) error {
	if s == nil || s.coinSellerStats == nil || overview == nil || !legacyCoinSellerAggregateRequested(query) {
		return nil
	}
	stats, err := s.coinSellerStats.CoinSellerRechargeStats(ctx, query, tzOffsetMinutes)
	if err != nil {
		return fmt.Errorf("load legacy coin seller overview for %s: %w", s.config.AppCode, err)
	}
	if len(stats.DailyUSDMinor) > 0 {
		byDate := map[string]int{}
		for i := range overview.Daily {
			byDate[overview.Daily[i].Date] = i
		}
		for date, usdMinor := range stats.DailyUSDMinor {
			if date == "" || usdMinor == 0 {
				continue
			}
			if index, ok := byDate[date]; ok {
				overview.Daily[index].CoinSellerUsdMinor += usdMinor
				continue
			}
			overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date, CoinSellerUsdMinor: usdMinor})
			byDate[date] = len(overview.Daily) - 1
		}
		sort.Slice(overview.Daily, func(i, j int) bool {
			return overview.Daily[i].Date < overview.Daily[j].Date
		})
	}
	if len(stats.CountryUSDMinor) == 0 {
		return nil
	}
	countryToRegion := s.legacyCountryRegionIndex(ctx)
	byRegion := map[int64]int{}
	for i := range overview.Regions {
		byRegion[overview.Regions[i].RegionID] = i
	}
	for countryCode, usdMinor := range stats.CountryUSDMinor {
		if usdMinor == 0 {
			continue
		}
		region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(countryCode))]
		if !ok {
			continue
		}
		index, exists := byRegion[region.RegionID]
		if !exists {
			overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{RegionID: region.RegionID, Name: region.Name})
			index = len(overview.Regions) - 1
			byRegion[region.RegionID] = index
		}
		overview.Regions[index].UsdMinorAmount += usdMinor
	}
	sort.Slice(overview.Regions, func(i, j int) bool {
		return overview.Regions[i].UsdMinorAmount > overview.Regions[j].UsdMinorAmount
	})
	return nil
}

func (s *MongoRechargeBillSource) overviewDaily(ctx context.Context, base mongo.Pipeline, tzOffsetMinutes int32, overview *rechargeBillOverviewDTO) error {
	coinExpr := bson.M{"$reduce": bson.M{
		"input": bson.M{"$filter": bson.M{
			"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
			"cond":  bson.M{"$in": bson.A{"$$this.name", bson.A{"GOLD", "CANDY"}}},
		}},
		"initialValue": 0,
		"in": bson.M{"$add": bson.A{
			"$$value",
			bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": 0, "onNull": 0}},
		}},
	}}
	isGoogle := bson.M{"$eq": bson.A{"$factory.factoryCode", "GOOGLE"}}
	pipeline := append(clonePipeline(base),
		bson.D{{Key: "$group", Value: bson.M{
			"_id":        bson.M{"$dateToString": bson.M{"format": "%Y-%m-%d", "date": "$createTime", "timezone": legacyTimezoneOffset(tzOffsetMinutes)}},
			"googleUsd":  bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, bson.M{"$ifNull": bson.A{"$amountUsd", 0}}, 0}}},
			"thirdUsd":   bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, 0, bson.M{"$ifNull": bson.A{"$amountUsd", 0}}}}},
			"googleCoin": bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, coinExpr, 0}}},
			"thirdCoin":  bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, 0, coinExpr}}},
		}}},
		bson.D{{Key: "$sort", Value: bson.M{"_id": 1}}},
	)
	cursor, err := s.collection.Aggregate(ctx, pipeline)
	if err != nil {
		return fmt.Errorf("aggregate legacy daily overview for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)
	for cursor.Next(ctx) {
		var row struct {
			Date       string `bson:"_id"`
			GoogleUSD  any    `bson:"googleUsd"`
			ThirdUSD   any    `bson:"thirdUsd"`
			GoogleCoin int64  `bson:"googleCoin"`
			ThirdCoin  int64  `bson:"thirdCoin"`
		}
		if err := cursor.Decode(&row); err != nil {
			return fmt.Errorf("decode legacy daily overview for %s: %w", s.config.AppCode, err)
		}
		overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
			Date:                 row.Date,
			GoogleUsdMinor:       legacyDecimalToMinor(row.GoogleUSD),
			ThirdPartyUsdMinor:   legacyDecimalToMinor(row.ThirdUSD),
			GoogleCoinAmount:     row.GoogleCoin,
			ThirdPartyCoinAmount: row.ThirdCoin,
		})
	}
	return cursor.Err()
}

func (s *MongoRechargeBillSource) overviewRegions(ctx context.Context, base mongo.Pipeline, overview *rechargeBillOverviewDTO) error {
	pipeline := clonePipeline(base)
	// 区域分布需要国家解析；若区域筛选未触发 lookup，这里补上（重复 addFields 无害但避免重复 lookup）。
	if len(pipeline) == 1 {
		pipeline = append(pipeline, legacyCountryResolveStages()...)
	}
	pipeline = append(pipeline,
		bson.D{{Key: "$group", Value: bson.M{
			"_id":       "$legacyBillCountry",
			"billCount": bson.M{"$sum": 1},
			"usd":       bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
		}}},
	)
	cursor, err := s.collection.Aggregate(ctx, pipeline)
	if err != nil {
		return fmt.Errorf("aggregate legacy region overview for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)

	countryToRegion := s.legacyCountryRegionIndex(ctx)
	buckets := map[int64]*rechargeBillRegionBucketDTO{}
	for cursor.Next(ctx) {
		var row struct {
			Country   string `bson:"_id"`
			BillCount int64  `bson:"billCount"`
			USD       any    `bson:"usd"`
		}
		if err := cursor.Decode(&row); err != nil {
			return fmt.Errorf("decode legacy region overview for %s: %w", s.config.AppCode, err)
		}
		region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.Country))]
		if !ok {
			continue
		}
		bucket, exists := buckets[region.RegionID]
		if !exists {
			bucket = &rechargeBillRegionBucketDTO{RegionID: region.RegionID, Name: region.Name}
			buckets[region.RegionID] = bucket
		}
		bucket.BillCount += row.BillCount
		bucket.UsdMinorAmount += legacyDecimalToMinor(row.USD)
	}
	if err := cursor.Err(); err != nil {
		return err
	}
	for _, bucket := range buckets {
		overview.Regions = append(overview.Regions, *bucket)
	}
	sort.Slice(overview.Regions, func(i, j int) bool {
		return overview.Regions[i].UsdMinorAmount > overview.Regions[j].UsdMinorAmount
	})
	return nil
}

type legacyRegionRef struct {
	RegionID int64
	Name     string
}

// legacyCountryRegionIndex 从区域目录构建国家→区域映射；目录读取失败时区域分布降级为空，不阻断概览其他数据。
func (s *MongoRechargeBillSource) legacyCountryRegionIndex(ctx context.Context) map[string]legacyRegionRef {
	index := map[string]legacyRegionRef{}
	for _, regionSource := range s.regionSources {
		_, regions, _, err := regionSource.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
		if err != nil {
			continue
		}
		for _, region := range regions {
			if appctx.Normalize(region.AppCode) != s.config.AppCode {
				continue
			}
			for _, country := range region.Countries {
				country = strings.ToUpper(strings.TrimSpace(country))
				if country == "" {
					continue
				}
				if _, exists := index[country]; !exists {
					index[country] = legacyRegionRef{RegionID: region.RegionID, Name: region.Name}
				}
			}
		}
	}
	return index
}

// legacyGoldWithdrawCollection 是 likei 的“银行卡余额兑换金币”申请集合；
// 该平台的用户工资转币商即此流程（币商在 likei 叫货运代理 FREIGHT），amount 为提交的美元余额。
const legacyGoldWithdrawCollection = "user_bank_withdraw_gold_apply"

func (s *MongoRechargeBillSource) overviewWithdrawal(ctx context.Context, query legacyRechargeBillQuery, overview *rechargeBillOverviewDTO) error {
	if s.database == nil {
		return nil
	}
	match := bson.M{"sysOrigin": s.config.SysOrigin}
	timeRange := bson.M{}
	if query.StartAtMS > 0 {
		timeRange["$gte"] = time.UnixMilli(query.StartAtMS)
	}
	if query.EndAtMS > 0 {
		timeRange["$lt"] = time.UnixMilli(query.EndAtMS)
	}
	if len(timeRange) > 0 {
		match["createTime"] = timeRange
	}
	pipeline := mongo.Pipeline{bson.D{{Key: "$match", Value: match}}}
	if query.RegionID > 0 {
		codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
		if err != nil {
			return err
		}
		if !handled || len(codes) == 0 {
			return nil
		}
		// 兑换申请没有国家码，按提交用户的资料国家落区域。
		pipeline = append(pipeline,
			bson.D{{Key: "$lookup", Value: bson.M{
				"from":         legacyUserProfileCollection,
				"localField":   "submitUserId",
				"foreignField": "_id",
				"as":           "legacyBillUser",
			}}},
			bson.D{{Key: "$addFields", Value: bson.M{
				"legacyBillCountry": bson.M{"$toUpper": bson.M{"$ifNull": bson.A{
					bson.M{"$first": "$legacyBillUser.countryCode"},
					"",
				}}},
			}}},
			bson.D{{Key: "$match", Value: bson.M{"legacyBillCountry": bson.M{"$in": codes}}}},
		)
	}
	pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.M{
		"_id":    nil,
		"n":      bson.M{"$sum": 1},
		"amount": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amount", 0}}},
	}}})
	cursor, err := s.database.Collection(legacyGoldWithdrawCollection).Aggregate(ctx, pipeline)
	if err != nil {
		return fmt.Errorf("aggregate legacy gold withdraw for %s: %w", s.config.AppCode, err)
	}
	defer cursor.Close(ctx)
	if cursor.Next(ctx) {
		var row struct {
			N      int64 `bson:"n"`
			Amount any   `bson:"amount"`
		}
		if err := cursor.Decode(&row); err != nil {
			return fmt.Errorf("decode legacy gold withdraw for %s: %w", s.config.AppCode, err)
		}
		overview.Withdrawal.TransferCount = row.N
		overview.Withdrawal.TransferUsdMinor = legacyDecimalToMinor(row.Amount)
	}
	return cursor.Err()
}

func (s *MongoRechargeBillSource) overviewGooglePaid(ctx context.Context, filter bson.M, query legacyRechargeBillQuery, overview *rechargeBillOverviewDTO) error {
	rechargeType := strings.ToLower(strings.TrimSpace(query.RechargeType))
	if rechargeType != "" && rechargeType != "google_play_recharge" {
		return nil
	}
	googleFilter := bson.M{}
	for key, value := range filter {
		googleFilter[key] = value
	}
	googleFilter["factory.factoryCode"] = "GOOGLE"
	googleCount, err := s.collection.CountDocuments(ctx, googleFilter)
	if err != nil {
		return fmt.Errorf("count legacy google bills for %s: %w", s.config.AppCode, err)
	}
	overview.GooglePaid.GoogleBillCount = googleCount
	if s.paidStore == nil {
		overview.GooglePaid.UnsyncedCount = googleCount
		return nil
	}
	stats, err := s.paidStore.LegacyGooglePaidStatsByBillTime(s.config.AppCode, query.StartAtMS, query.EndAtMS)
	if err != nil {
		return fmt.Errorf("load legacy google paid stats for %s: %w", s.config.AppCode, err)
	}
	overview.GooglePaid.SyncedCount = stats.SyncedCount
	if unsynced := googleCount - stats.SyncedCount; unsynced > 0 {
		overview.GooglePaid.UnsyncedCount = unsynced
	}
	overview.GooglePaid.CoveredUsdMinor = stats.CoveredUSDMinor
	overview.GooglePaid.EstFeeUsdMinor = stats.EstFeeUSDMinor
	overview.GooglePaid.EstTaxUsdMinor = stats.EstTaxUSDMinor
	overview.GooglePaid.EstNetUsdMinor = stats.EstNetUSDMinor
	return nil
}

func legacyQueryRechargeType(query legacyRechargeBillQuery) string {
	return strings.ToLower(strings.TrimSpace(query.RechargeType))
}

func legacySummaryTZOffset(query legacyRechargeBillQuery) int32 {
	if query.TzOffsetMinutes != 0 {
		return query.TzOffsetMinutes
	}
	return defaultLegacyFinanceTZOffsetMinutes
}

func legacyCoinSellerAggregateRequested(query legacyRechargeBillQuery) bool {
	rechargeType := legacyQueryRechargeType(query)
	return rechargeType == "" || rechargeType == "coin_seller"
}

func legacyCoinSellerAggregateFilterSupported(query legacyRechargeBillQuery) bool {
	// dashboard 外部源只有按 App/区域/时间的预聚合事实；关键词和谷歌实付同步状态是账单明细筛选，不能反推到币商聚合。
	return strings.TrimSpace(query.Keyword) == "" && strings.TrimSpace(query.PaidState) == ""
}

func legacyDashboardStatTimezone(tzOffsetMinutes int32) string {
	switch tzOffsetMinutes {
	case 480:
		return "Asia/Shanghai"
	case 180:
		return "Asia/Riyadh"
	default:
		return ""
	}
}

func legacyDashboardDayRange(query legacyRechargeBillQuery, tzOffsetMinutes int32) (string, int64, int64, error) {
	statTimezone := legacyDashboardStatTimezone(tzOffsetMinutes)
	if statTimezone == "" {
		return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats do not support tz offset %d", tzOffsetMinutes)
	}
	if query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
		return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats require explicit day range")
	}
	location, err := time.LoadLocation(statTimezone)
	if err != nil {
		return "", 0, 0, fmt.Errorf("load legacy coin seller timezone %s: %w", statTimezone, err)
	}
	start := time.UnixMilli(query.StartAtMS).In(location)
	end := time.UnixMilli(query.EndAtMS).In(location)
	if !start.Equal(legacyLocalMidnight(start, location)) || !end.Equal(legacyLocalMidnight(end, location)) {
		return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats require whole-day range in %s", statTimezone)
	}
	return statTimezone, query.StartAtMS, query.EndAtMS, nil
}

func legacyLocalMidnight(value time.Time, location *time.Location) time.Time {
	local := value.In(location)
	return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}

func legacyDashboardRequiredInt64(row map[string]any, key string) (int64, error) {
	if row == nil {
		return 0, fmt.Errorf("legacy dashboard response is empty")
	}
	value, ok := row[key]
	if !ok || value == nil {
		return 0, fmt.Errorf("legacy dashboard response missing required field %s", key)
	}
	return legacyMapInt64(value), nil
}

func legacyMapRows(value any) []map[string]any {
	switch typed := value.(type) {
	case []map[string]any:
		return typed
	case []any:
		rows := make([]map[string]any, 0, len(typed))
		for _, item := range typed {
			if row, ok := item.(map[string]any); ok {
				rows = append(rows, row)
			}
		}
		return rows
	default:
		return nil
	}
}

func legacyMapString(row map[string]any, key string) string {
	if row == nil {
		return ""
	}
	switch typed := row[key].(type) {
	case string:
		return strings.TrimSpace(typed)
	case fmt.Stringer:
		return strings.TrimSpace(typed.String())
	case nil:
		return ""
	default:
		return strings.TrimSpace(fmt.Sprint(typed))
	}
}

func legacyMapInt64(value any) int64 {
	switch typed := value.(type) {
	case nil:
		return 0
	case int64:
		return typed
	case int:
		return int64(typed)
	case int32:
		return int64(typed)
	case float64:
		return int64(math.Round(typed))
	case float32:
		return int64(math.Round(float64(typed)))
	case json.Number:
		parsed, _ := typed.Int64()
		return parsed
	case string:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
		return parsed
	default:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
		return parsed
	}
}

// legacyTimezoneOffset 把分钟偏移转成 Mongo $dateToString 的时区串，如 480 → "+08:00"。
func legacyTimezoneOffset(tzOffsetMinutes int32) string {
	sign := "+"
	minutes := tzOffsetMinutes
	if minutes < 0 {
		sign = "-"
		minutes = -minutes
	}
	return fmt.Sprintf("%s%02d:%02d", sign, minutes/60, minutes%60)
}

func legacyDecimalToMicro(value any) int64 {
	return legacyDecimalScaled(value, 1_000_000)
}

func legacyDecimalToMinor(value any) int64 {
	return legacyDecimalScaled(value, 100)
}

// legacyDecimalScaled 把 Mongo 里 Decimal128/数值/字符串形式的十进制金额换算成目标最小单位；非法值按 0 处理。
func legacyDecimalScaled(value any, scale float64) int64 {
	var text string
	switch typed := value.(type) {
	case nil:
		return 0
	case bson.Decimal128:
		text = typed.String()
	case string:
		text = typed
	case int32:
		return int64(typed) * int64(scale)
	case int64:
		return typed * int64(scale)
	case int:
		return int64(typed) * int64(scale)
	case float64:
		text = strconv.FormatFloat(typed, 'f', -1, 64)
	case float32:
		text = strconv.FormatFloat(float64(typed), 'f', -1, 32)
	default:
		text = fmt.Sprint(value)
	}
	parsed, err := strconv.ParseFloat(strings.TrimSpace(text), 64)
	if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
		return 0
	}
	return int64(math.Round(parsed * scale))
}
