package dashboard

import (
	"context"
	"database/sql/driver"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// 复合 $group 的 _id 子文档经 driver 解码进 bson.M 后，嵌套值默认是 bson.D 而不是 bson.M；
// 这个测试锁死我们对两种形态的兼容，防止聚合行被静默丢弃（表现为"成功但全零"）。
func TestAslanMongoDayCountryHandlesDecodedShapes(t *testing.T) {
	raw, err := bson.Marshal(bson.M{"_id": bson.M{"day": "2026-07-02", "country": "sa"}, "amount": int64(5)})
	if err != nil {
		t.Fatalf("marshal fixture: %v", err)
	}
	row := bson.M{}
	if err := bson.Unmarshal(raw, &row); err != nil {
		t.Fatalf("unmarshal fixture: %v", err)
	}
	if _, isM := row["_id"].(bson.M); isM {
		t.Log("driver decoded nested document as bson.M")
	} else if _, isD := row["_id"].(bson.D); isD {
		t.Log("driver decoded nested document as bson.D (must be supported)")
	} else {
		t.Fatalf("unexpected nested _id type %T", row["_id"])
	}

	day, country := aslanMongoDayCountry(row["_id"])
	if day != "2026-07-02" || country != "SA" {
		t.Fatalf("expected day/country from decoded _id, got %q/%q", day, country)
	}

	// 直接构造 bson.D 与 bson.M 两种形态都必须可读。
	for _, value := range []any{
		bson.D{{Key: "day", Value: "2026-07-01"}, {Key: "country", Value: "eg"}},
		bson.M{"day": "2026-07-01", "country": "eg"},
	} {
		day, country := aslanMongoDayCountry(value)
		if day != "2026-07-01" || country != "EG" {
			t.Fatalf("expected 2026-07-01/EG for %T, got %q/%q", value, day, country)
		}
	}
}

func TestAslanMongoDocumentConversion(t *testing.T) {
	if doc := aslanMongoDocument(bson.D{{Key: "day", Value: "2026-07-02"}, {Key: "type", Value: int32(1)}}); doc == nil || doc["day"] != "2026-07-02" {
		t.Fatalf("bson.D conversion failed: %v", doc)
	}
	if doc := aslanMongoDocument(bson.M{"user": int64(7)}); doc == nil || doc["user"] != int64(7) {
		t.Fatalf("bson.M passthrough failed: %v", doc)
	}
	if doc := aslanMongoDocument(nil); doc != nil {
		t.Fatalf("nil should convert to nil, got %v", doc)
	}
	if doc := aslanMongoDocument("scalar"); doc != nil {
		t.Fatalf("scalar should convert to nil, got %v", doc)
	}
}

func TestAslanMongoLegacyCoinSellerRechargeFeedsRechargeChannel(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	source := &AslanMongoDashboardSource{
		legacyDB:       db,
		sysOrigin:      "ATYOU",
		requestTimeout: time.Second,
	}
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
	end := start.AddDate(0, 0, 1)
	grid := newLegacyMongoGrid(start, end)
	cohorts := map[string]map[string][]int64{
		"2026-07-05": {
			"SA": {101},
		},
	}
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
	perUser := map[string]map[int64]int64{}

	mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2[\\s\\S]*ugcr\\.create_time >= \\?[\\s\\S]*ugcr\\.create_time < \\?").
		WithArgs("ATYOU", start, end).
		WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
			AddRow("2026-07-05", "sa", int64(101), "42.00").
			AddRow("2026-07-05", "AE", int64(202), "10.25"))

	if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
		t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
	}
	applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)

	saMetric := grid.metrics["2026-07-05"]["SA"]
	if saMetric == nil {
		t.Fatalf("missing SA metric: %#v", grid.metrics)
	}
	saRow := saMetric.toExternal(nil).toMap()
	assertInt64(t, saRow["coin_seller_recharge_usd_minor"], 4200)
	assertInt64(t, saRow["recharge_usd_minor"], 4200)
	assertInt64(t, saRow["total_recharge_usd_minor"], 4200)
	assertInt64(t, saRow["user_recharge_usd_minor"], 0)
	assertInt64(t, saRow["mifapay_recharge_usd_minor"], 0)
	assertInt64(t, saRow["paid_users"], 1)
	assertInt64(t, saRow["new_user_recharge_usd_minor"], 4200)

	aeMetric := grid.metrics["2026-07-05"]["AE"]
	if aeMetric == nil {
		t.Fatalf("missing AE metric: %#v", grid.metrics)
	}
	aeRow := aeMetric.toExternal(nil).toMap()
	assertInt64(t, aeRow["coin_seller_recharge_usd_minor"], 1025)
	assertInt64(t, aeRow["recharge_usd_minor"], 1025)
	assertInt64(t, aeRow["new_user_recharge_usd_minor"], 0)

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	source := &AslanMongoDashboardSource{
		legacyDB:       db,
		sysOrigin:      "ATYOU",
		requestTimeout: time.Second,
	}
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
	end := start.AddDate(0, 0, 1)
	grid := newLegacyMongoGrid(start, end)
	cohorts := map[string]map[string][]int64{
		"2026-07-05": {
			"SA": {101, 202, 303},
		},
	}
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
	perUser := map[string]map[int64]int64{}

	mock.ExpectQuery("FROM order_other_recharge oor[\\s\\S]*INNER JOIN user_base_info ubi ON ubi\\.id = oor\\.user_id[\\s\\S]*oor\\.payment_date_number >= \\?[\\s\\S]*oor\\.payment_date_number < \\?").
		WithArgs("ATYOU", "20260705", "20260706").
		WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
			AddRow("2026-07-05", "SA", int64(101), "12.34").
			AddRow("2026-07-05", "SA", int64(202), "0.00").
			AddRow("2026-07-05", "SA", int64(303), "-3.00"))

	if err := source.loadLegacyOtherRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
		t.Fatalf("loadLegacyOtherRechargeStats: %v", err)
	}
	applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)

	row := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap()
	assertInt64(t, row["recharge_usd_minor"], 1234)
	assertInt64(t, row["mifapay_recharge_usd_minor"], 1234)
	assertInt64(t, row["coin_seller_recharge_usd_minor"], 0)
	assertInt64(t, row["user_recharge_usd_minor"], 1234)
	assertInt64(t, row["paid_users"], 1)
	assertInt64(t, row["new_user_recharge_usd_minor"], 1234)

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	source := &AslanMongoDashboardSource{
		legacyDB:             db,
		legacyWalletDatabase: "atyou_wallet",
		sysOrigin:            "ATYOU",
		requestTimeout:       time.Second,
	}
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
	end := start.AddDate(0, 0, 1)
	grid := newLegacyMongoGrid(start, end)
	cohorts := map[string]map[string][]int64{
		"2026-07-05": {
			"PH": {2069828597070528514},
		},
	}
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
	perUser := map[string]map[int64]int64{}

	mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2").
		WithArgs("ATYOU", start, end).
		WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}))
	mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.origin = 'DEDUCTION'").
		WithArgs("ATYOU", start, end).
		WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
			AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00").
			AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47").
			AddRow("2026-07-05", "SA", int64(2071589123112779778), "-1000.00"))

	if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
		t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
	}
	applyAslanRechargeUserMetrics(cohorts, grid, distinctUsers, perUser)

	phRow := grid.metrics["2026-07-05"]["PH"].toExternal(nil).toMap()
	assertInt64(t, phRow["coin_seller_recharge_usd_minor"], 200000)
	assertInt64(t, phRow["recharge_usd_minor"], 200000)
	assertInt64(t, phRow["user_recharge_usd_minor"], 0)
	assertInt64(t, phRow["paid_users"], 1)
	assertInt64(t, phRow["new_user_recharge_usd_minor"], 200000)

	pkRow := grid.metrics["2026-07-05"]["PK"].toExternal(nil).toMap()
	assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547)
	assertInt64(t, pkRow["recharge_usd_minor"], 2547)

	saRow := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap()
	assertInt64(t, saRow["coin_seller_recharge_usd_minor"], -100000)
	assertInt64(t, saRow["recharge_usd_minor"], -100000)
	assertInt64(t, saRow["user_recharge_usd_minor"], 0)
	assertInt64(t, saRow["paid_users"], 0)

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	source := &AslanMongoDashboardSource{
		legacyDB:             db,
		legacyWalletDatabase: "atyou_wallet",
		sysOrigin:            "ATYOU",
		requestTimeout:       time.Second,
	}
	start := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
	end := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
	startArg := time.UnixMilli(start.UnixMilli())
	endArg := time.UnixMilli(end.UnixMilli())
	createdAt := time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC)
	queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw"
	mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern).
		WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3)))
	mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern).
		WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0).
		WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}).
			AddRow("aslan-freight-deduction-100", "freight_deduction", int64(2069828597070528516), "ae", "-1000.00", createdAt.Add(time.Minute)).
			AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt).
			AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour)))

	items, total, err := source.ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{
		StartMS:  start.UnixMilli(),
		EndMS:    end.UnixMilli(),
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListCoinSellerRechargeBills: %v", err)
	}
	if total != 3 || len(items) != 3 {
		t.Fatalf("unexpected result total=%d items=%+v", total, items)
	}
	if items[0].TransactionID != "aslan-freight-deduction-100" || items[0].Source != "freight_deduction" || items[0].CountryCode != "AE" {
		t.Fatalf("unexpected first item identity: %+v", items[0])
	}
	if items[0].USDMinorAmount != -100000 || items[0].CreatedAtMS != createdAt.Add(time.Minute).UnixMilli() {
		t.Fatalf("unexpected first item amount/time: %+v", items[0])
	}
	if items[1].TransactionID != "aslan-freight-purchase-99" || items[1].USDMinorAmount != 132790 {
		t.Fatalf("unexpected second item: %+v", items[1])
	}
	if items[2].TransactionID != "aslan-gold-coin-7" || items[2].USDMinorAmount != 4200 {
		t.Fatalf("unexpected third item: %+v", items[2])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAslanMongoLegacyGameFlowsUseSelectedDayNumberForRiyadhSnapshots(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	// Social BI 仍按上海时区选择日期；wallet 游戏快照虽按 Riyadh 切日，但 group_num 是自然日号，
	// 查询必须直接使用所选 yyyyMMdd，不能把请求时间点换区后再偏移日期。
	location, err := time.LoadLocation("Asia/Shanghai")
	if err != nil {
		t.Fatalf("load Shanghai location: %v", err)
	}
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, location)
	end := start.AddDate(0, 0, 1)
	grid := newLegacyMongoGrid(start, end)
	// 国家行存在时也不能继承全 App 游戏值；它只用于验证 assembleRange 的维度隔离。
	grid.at("2026-07-05", "SA").NewUsers = 1
	source := &AslanMongoDashboardSource{
		legacyDB:             db,
		legacyWalletDatabase: "atyou_wallet",
		sysOrigin:            "ATYOU",
		requestTimeout:       time.Second,
	}

	arguments := []driver.Value{"ATYOU", "20260705", "20260706"}
	for _, eventType := range aslanLegacyGameEventTypes {
		arguments = append(arguments, eventType)
	}
	mock.ExpectQuery("FROM `atyou_wallet`\\.wallet_gold_count[\\s\\S]*sys_origin = \\?[\\s\\S]*event_type IN").
		WithArgs(arguments...).
		WillReturnRows(sqlmock.NewRows([]string{"group_num", "type", "amount"}).
			AddRow(20260705, 1, int64(12_345)).
			AddRow(20260705, 0, int64(2_345)))

	if err := source.loadLegacyGameFlows(context.Background(), start, end, grid); err != nil {
		t.Fatalf("loadLegacyGameFlows: %v", err)
	}
	flow := grid.gameFlows["2026-07-05"]
	if flow == nil || flow.Turnover != 123 || flow.Payout != 23 {
		t.Fatalf("legacy game flow mismatch: %+v", flow)
	}

	overview := source.assembleRange(grid, externalDashboardCountryFilter{}, false, true, true)
	if overview.Total.GameTurnover != 123 || overview.Total.GamePayout != 23 || overview.Total.GameProfit != 100 {
		t.Fatalf("legacy game overview mismatch: %+v", overview.Total)
	}
	assertInt64(t, overview.DailySeries[0]["game_turnover"], 123)
	assertInt64(t, overview.DailySeries[0]["game_payout"], 23)
	assertInt64(t, overview.DailySeries[0]["game_profit"], 100)
	if overview.DailySeries[0]["game_players"] != nil {
		t.Fatalf("game players must stay unavailable: %#v", overview.DailySeries[0])
	}
	if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0]["game_turnover"] != nil {
		t.Fatalf("country rows must not contain global game totals: %#v", overview.CountryBreakdown)
	}

	// 即使 grid 中已有全局快照，区域筛选路径传入 unavailable 后也必须继续输出 nil，防止全局值泄漏。
	regionOverview := source.assembleRange(grid, externalDashboardCountryFilter{Applied: true, RegionID: 9}, false, true, false)
	if regionOverview.DailySeries[0]["game_turnover"] != nil || regionOverview.Total.GameTurnover != 0 {
		t.Fatalf("region overview must not expose global game totals: daily=%#v total=%+v", regionOverview.DailySeries, regionOverview.Total)
	}

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAslanMongoLegacyGameWhitelistExcludesRewardOrigins(t *testing.T) {
	seen := make(map[string]struct{}, len(aslanLegacyGameEventTypes))
	for _, eventType := range aslanLegacyGameEventTypes {
		if _, duplicated := seen[eventType]; duplicated {
			t.Fatalf("duplicate game event type %s", eventType)
		}
		seen[eventType] = struct{}{}
	}
	for _, required := range []string{"HOT_GAME_201", "HKYS_GAME_101", "BAISHUN_GAME_1182", "YOMI_GAME_282"} {
		if _, ok := seen[required]; !ok {
			t.Fatalf("confirmed game event type %s missing from whitelist", required)
		}
	}
	for _, reward := range []string{"HOT_GAME_200", "BAISHUN_GAME_1085"} {
		if _, ok := seen[reward]; ok {
			t.Fatalf("reward origin %s must not be counted as game flow", reward)
		}
	}
}

func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
	match := bson.M{}
	aslanMongoExcludeFreightGoldRecharge(match)

	for _, field := range []string{"trackCommodityType", "products.code", "products.name", "products.content"} {
		condition, ok := match[field].(bson.M)
		if !ok {
			t.Fatalf("%s condition = %#v", field, match[field])
		}
		values, ok := condition["$nin"].(bson.A)
		if !ok {
			t.Fatalf("%s $nin = %#v", field, condition["$nin"])
		}
		got := map[any]bool{}
		for _, value := range values {
			got[value] = true
		}
		if !got["FREIGHT_GOLD"] || !got["FREIGHT_GOLD_SUPER"] {
			t.Fatalf("%s should exclude freight commodity types, got %#v", field, values)
		}
	}
}

func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) {
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
	grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
	perUser := map[string]map[int64]int64{}

	addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0)
	addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, 0)
	if row := grid.metrics["2026-07-05"]["SA"]; row != nil {
		t.Fatalf("zero legacy recharge should not create metric row: %#v", row)
	}

	addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100)
	row := grid.metrics["2026-07-05"]["SA"]
	if row == nil {
		t.Fatalf("negative coin seller deduction should create metric row")
	}
	if row.RechargeUSDMinor != -100 || row.CoinSellerRechargeUSDMinor != -100 {
		t.Fatalf("deduction should reduce recharge metrics: %#v", row)
	}
	if len(distinctUsers) != 0 {
		t.Fatalf("deduction should not create paid users: %#v", distinctUsers)
	}
	if len(perUser) != 0 {
		t.Fatalf("deduction should not create per-user recharge: %#v", perUser)
	}
}

func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) {
	available := aslanMongoDashboardMetricSources(false, true, true, false)
	assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor")
	assertMetricSourceAvailable(t, available, "game_turnover")

	unavailable := aslanMongoDashboardMetricSources(false, false, false, false)
	assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor")
	assertMetricSourceUnavailable(t, unavailable, "game_turnover")

	regionFiltered := aslanMongoDashboardMetricSources(false, true, false, true)
	assertMetricSourceUnavailable(t, regionFiltered, "game_turnover")
}

func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) {
	start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
	grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
	grid.at("2026-07-05", "SA").RechargeUSDMinor = 4200
	grid.at("2026-07-05", "AE").RechargeUSDMinor = 1000
	distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{
		{day: "2026-07-05", country: "SA"}: {"101": {}},
		{day: "2026-07-05", country: "AE"}: {"101": {}},
	}
	applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil)

	overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true, false)
	if overview.Total.PaidUsers != 1 {
		t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers)
	}
	if len(overview.DailySeries) != 1 {
		t.Fatalf("daily series mismatch: %#v", overview.DailySeries)
	}
	assertInt64(t, overview.DailySeries[0]["paid_users"], 1)

	countries := map[string]int64{}
	for _, row := range overview.CountryBreakdown {
		country, _ := row["country_code"].(string)
		paid, _ := row["paid_users"].(int64)
		countries[country] = paid
	}
	if countries["SA"] != 1 || countries["AE"] != 1 {
		t.Fatalf("country paid users should preserve country buckets, got %#v", countries)
	}
}

func assertMetricSourceAvailable(t *testing.T, sources []map[string]any, field string) {
	t.Helper()
	for _, source := range sources {
		if source["field"] != field {
			continue
		}
		if source["available"] != true {
			t.Fatalf("metric source %s should be available, got %#v", field, source)
		}
		return
	}
	t.Fatalf("metric source %s not found in %#v", field, sources)
}

// applyRetention 的观察日回看归属：区间内每天 D 的 DN 计数 = D-N 注册 cohort 在 D 的活跃，
// 归属观察日 D；主 cohorts 与补载的 earlier cohorts 同日并存时必须合并；无 cohort 的天基数保持 0。
func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) {
	location, err := time.LoadLocation("Asia/Shanghai")
	if err != nil {
		t.Fatalf("load location: %v", err)
	}
	startDate := time.Date(2026, 6, 10, 0, 0, 0, 0, location)
	endDate := time.Date(2026, 6, 12, 0, 0, 0, 0, location)
	grid := newLegacyMongoGrid(startDate, endDate)

	// 主 cohorts 覆盖 [start, end)；"2026-06-10" 同时出现在 earlier 里，验证合并。
	cohorts := map[string]map[string][]int64{
		"2026-06-10": {"SA": {1, 2}},
		"2026-06-11": {"EG": {3}},
	}
	earlier := map[string]map[string][]int64{
		"2026-06-09": {"SA": {10, 11}},     // 观察日 06-10 的 D1 cohort
		"2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort
		"2026-05-11": {"EG": {30}},         // 观察日 06-10 的 D30 cohort
		"2026-06-04": {"EG": {40}},         // 观察日 06-11 的 D7 cohort
		"2026-06-10": {"SA": {50}},         // 与主 cohorts 同日：合并成 {1,2,50}
	}
	activeByDay := map[string]map[int64]struct{}{
		"2026-06-10": {10: {}, 20: {}, 30: {}},
		"2026-06-11": {1: {}, 50: {}},
	}

	var cohortRangeFrom, cohortRangeTo string
	requestedUnions := map[string]int{}
	source := &AslanMongoDashboardSource{
		statTimezone: "Asia/Shanghai",
		retentionCohortsLoader: func(_ context.Context, from time.Time, to time.Time, _ externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
			cohortRangeFrom, cohortRangeTo = dashboardSQLDate(from), dashboardSQLDate(to)
			return earlier, nil
		},
		activeUserSetLoader: func(_ context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
			requestedUnions[activeDate] = len(userIDs)
			return activeByDay[activeDate], nil
		},
	}
	if err := source.applyRetention(context.Background(), startDate, externalDashboardCountryFilter{}, cohorts, grid); err != nil {
		t.Fatalf("apply retention: %v", err)
	}

	if cohortRangeFrom != "2026-05-11" || cohortRangeTo != "2026-06-10" {
		t.Fatalf("expected earlier cohort range [start-30, start), got [%s, %s)", cohortRangeFrom, cohortRangeTo)
	}
	// 每个观察日只做一次活跃点查，且是三个 offset cohort 的并集。
	// 06-10: D1{10,11}∪D7{20,21,22}∪D30{30}=6；06-11: D1 合并 cohort{1,2,50}∪D7{40}=4。
	if requestedUnions["2026-06-10"] != 6 || requestedUnions["2026-06-11"] != 4 {
		t.Fatalf("expected union sizes 6/4, got %#v", requestedUnions)
	}

	day1SA := grid.at("2026-06-10", "SA")
	if day1SA.D1Users != 1 || day1SA.D1Base != 2 || day1SA.D7Users != 1 || day1SA.D7Base != 3 {
		t.Fatalf("obs 06-10 SA retention mismatch: %+v", day1SA)
	}
	day1EG := grid.at("2026-06-10", "EG")
	if day1EG.D30Users != 1 || day1EG.D30Base != 1 {
		t.Fatalf("obs 06-10 EG d30 mismatch: %+v", day1EG)
	}
	day2SA := grid.at("2026-06-11", "SA")
	if day2SA.D1Users != 2 || day2SA.D1Base != 3 {
		t.Fatalf("obs 06-11 SA d1 (merged cohort) mismatch: %+v", day2SA)
	}
	day2EG := grid.at("2026-06-11", "EG")
	if day2EG.D7Users != 0 || day2EG.D7Base != 1 {
		t.Fatalf("obs 06-11 EG d7 real-zero mismatch: %+v", day2EG)
	}
	if day2EG.D30Base != 0 || day2SA.D30Base != 0 {
		t.Fatalf("obs 06-11 d30 should have no cohort: SA=%+v EG=%+v", day2SA, day2EG)
	}
}
