package mysql

import (
	"context"
	"encoding/json"
	"sort"
	"strconv"
	"strings"
	"time"

	"hyapp/pkg/appcode"
)

const (
	SocialSectionNewUsers  = "new_users"
	SocialSectionRevenue   = "revenue"
	SocialSectionRetention = "retention"
	SocialSectionActive    = "active"
	SocialSectionGift      = "gift"
	SocialSectionGame      = "game"
)

type SocialRequirementsQuery struct {
	AppCode     string
	StatTZ      string
	StartMS     int64
	EndMS       int64
	CountryID   int64
	RegionID    int64
	RegionIDs   []int64
	Section     string
	UserRole    string
	PayerType   string
	NewUserType string
}

type SocialRequirements struct {
	AppCode   string                     `json:"app_code"`
	StatTZ    string                     `json:"stat_tz"`
	StartMS   int64                      `json:"start_ms"`
	EndMS     int64                      `json:"end_ms"`
	CountryID int64                      `json:"country_id"`
	RegionID  int64                      `json:"region_id"`
	RegionIDs []int64                    `json:"region_ids,omitempty"`
	Sections  []SocialRequirementSection `json:"sections"`
}

type SocialRequirementSection struct {
	Key               string                    `json:"key"`
	Label             string                    `json:"label"`
	Filters           SocialRequirementFilter   `json:"filters"`
	Columns           []SocialRequirementColumn `json:"columns"`
	MetricDefinitions []MetricDefinition        `json:"metric_definitions"`
	Total             SocialRequirementRow      `json:"total"`
	Average           SocialRequirementRow      `json:"average"`
	DailySeries       []SocialRequirementRow    `json:"daily_series"`
	// P5 游戏 section 附带的按游戏明细：来自 stat_self_game_user_day / stat_game_day_players 投影，
	// 不受用户身份/付费身份/是否新用户筛选影响（厂商游戏无按用户金额，无法一致地过滤）。
	GameColumns []SocialRequirementColumn `json:"game_columns,omitempty"`
	Games       []SocialRequirementGame   `json:"games,omitempty"`
}

type SocialRequirementGame struct {
	GameKey     string                 `json:"game_key"`
	GameSource  string                 `json:"game_source"`
	GameName    string                 `json:"game_name"`
	Total       SocialRequirementRow   `json:"total"`
	DailySeries []SocialRequirementRow `json:"daily_series"`
}

type SocialRequirementColumn struct {
	Key     string `json:"key"`
	Label   string `json:"label"`
	Type    string `json:"type"`
	Tooltip string `json:"tooltip,omitempty"`
}

type SocialRequirementFilter struct {
	UserRole    string `json:"user_role"`
	PayerType   string `json:"payer_type"`
	NewUserType string `json:"new_user_type"`
}

type SocialRequirementRow struct {
	StatDay string         `json:"stat_day"`
	Label   string         `json:"label"`
	Metrics map[string]any `json:"metrics"`
}

func (row SocialRequirementRow) MarshalJSON() ([]byte, error) {
	out := make(map[string]any, len(row.Metrics)+2)
	out["stat_day"] = row.StatDay
	out["label"] = row.Label
	for key, value := range row.Metrics {
		out[key] = value
	}
	return json.Marshal(out)
}

type socialRequirementUserDay struct {
	StatDay                    string
	Subject                    string
	UserID                     int64
	DirectUser                 bool
	DeviceID                   string
	CountryID                  int64
	RegionID                   int64
	UserRole                   string
	AppFirstOpen               int64
	ProfileSetupView           int64
	Registered                 int64
	Active                     int64
	RoomJoinOK                 int64
	RoomJoinNG                 int64
	MicUp                      int64
	Gift                       int64
	NormalGift                 int64
	LuckyGift                  int64
	SuperGift                  int64
	Recharge                   int64
	FirstRecharge              int64
	RepeatRecharge             int64
	GameBet                    int64
	SelfGameBet                int64
	ProbabilityBet             int64
	Chat                       int64
	FollowUser                 int64
	FollowRoom                 int64
	GiftPanel                  int64
	ProbabilityPanel           int64
	AppStayMS                  int64
	RoomStayMS                 int64
	RoomOnlineMS               int64
	MicStayMS                  int64
	RechargeCount              int64
	RechargeUSDMinor           int64
	GoogleRechargeUSDMinor     int64
	ThirdPartyRechargeUSDMinor int64
	CoinSellerRechargeUSDMinor int64
	FirstRechargeUSDMinor      int64
	RepeatRechargeUSDMinor     int64
	NormalGiftCoin             int64
	LuckyGiftCoin              int64
	SuperGiftCoin              int64
	LuckyGiftPayoutCoin        int64
	SuperGiftPayoutCoin        int64
	GameBetCoin                int64
	GameBetCount               int64
	SelfGameBetCoin            int64
	SelfGameBetCount           int64
	ProbabilityBetCoin         int64
	ProbabilityBetCount        int64
	OtherConsumeCoin           int64
	OutputCoin                 int64
}

type socialRequirementAggregate struct {
	AppFirstOpen               int64
	ProfileSetupView           int64
	Registered                 int64
	NewHostUsers               int64
	NewNormalUsers             int64
	Active                     int64
	RoomJoinOK                 int64
	RoomJoinNG                 int64
	RegisteredRoomJoinOK       int64
	RegisteredRoomJoinNG       int64
	RegisteredMicUp            int64
	RegisteredRoomGift         int64
	RegisteredRecharge         int64
	RegisteredGame             int64
	RegisteredFollowUser       int64
	RegisteredFollowRoom       int64
	RegisteredChat             int64
	RegisteredAppStayMS        int64
	RegisteredRoomStayMS       int64
	RegisteredMicStayMS        int64
	RegisteredRechargeUSDMinor int64
	ConsumeUsers               int64
	MicUp                      int64
	Gift                       int64
	NormalGift                 int64
	LuckyGift                  int64
	SuperGift                  int64
	Recharge                   int64
	FirstRecharge              int64
	RepeatRecharge             int64
	GameBet                    int64
	SelfGameBet                int64
	ProbabilityBet             int64
	Chat                       int64
	FollowUser                 int64
	FollowRoom                 int64
	GiftPanel                  int64
	ProbabilityPanel           int64
	AppStayMS                  int64
	RoomStayMS                 int64
	MicStayMS                  int64
	RechargeCount              int64
	RechargeUSDMinor           int64
	GoogleRechargeUSDMinor     int64
	ThirdPartyRechargeUSDMinor int64
	CoinSellerRechargeUSDMinor int64
	FirstRechargeUSDMinor      int64
	RepeatRechargeUSDMinor     int64
	NormalGiftCoin             int64
	LuckyGiftCoin              int64
	SuperGiftCoin              int64
	LuckyGiftPayoutCoin        int64
	SuperGiftPayoutCoin        int64
	GameBetCoin                int64
	GameBetCount               int64
	SelfGameBetCoin            int64
	SelfGameBetCount           int64
	ProbabilityBetCoin         int64
	ProbabilityBetCount        int64
	OtherConsumeCoin           int64
	OutputCoin                 int64
}

type retentionMetric struct {
	Base  int64
	Users int64
}

// retention-only 请求只需要这些身份、筛选和留存行为列。保持投影集中定义，
// 避免后续无意把金额、时长等约 50 列重新带回最长 61 天的留存扫描。
const socialRetentionUserDayProjection = `DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id,
	       country_id, region_id, user_role, registered_success, active_user,
	       room_join_success, mic_up_success, gift_sent, recharge_user, game_bet_user`

func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRequirementsQuery) (SocialRequirements, error) {
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = "lalu"
	}
	statTZ := normalizeStatTZ(query.StatTZ)
	startMS, endMS := query.StartMS, query.EndMS
	if startMS <= 0 {
		now := time.Now().UTC()
		startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
	}
	if endMS <= startMS {
		endMS = startMS + 24*time.Hour.Milliseconds()
	}
	startDay := statDayIn(startMS, statTZ)
	endDay := statDayIn(endMS-1, statTZ)
	observableDay := statDayIn(time.Now().UnixMilli(), statTZ)
	days := socialDayRange(startDay, endDay)
	sections := socialRequestedSections(query.Section)
	lookaheadDays := socialRetentionLookaheadDays(sections)
	// 只有 P0 次留或 P2 留存需要读取查询结束日之后的观察行；单独查看活跃、
	// 营收、送礼、游戏时不再固定多扫 30 天的 50 列用户日宽表。
	queryEndDay := addStatDays(endDay, lookaheadDays)
	var allRows []socialRequirementUserDay
	var err error
	if len(sections) == 1 && sections[0] == SocialSectionRetention {
		allRows, err = r.querySocialRetentionUserDays(ctx, app, statTZ, startDay, queryEndDay)
	} else {
		allRows, err = r.querySocialUserDays(ctx, app, statTZ, startDay, queryEndDay, 0, 0, nil)
	}
	if err != nil {
		return SocialRequirements{}, err
	}
	rows := filterSocialRequirementUserDays(allRows, query.CountryID, query.RegionID, query.RegionIDs)
	byDay, _ := indexSocialRows(rows)
	// 区域只筛 cohort 基准日；D+N 观察日保留完整 canonical 活跃索引。
	// 用户次日跨区仍算回来，不能因为目标日维度不同被误判为流失。
	_, bySubjectDay := indexSocialRows(allRows)
	var gameBreakdown []SocialRequirementGame
	for _, section := range sections {
		if section == SocialSectionGame {
			gameBreakdown, err = r.querySocialGameBreakdown(ctx, app, statTZ, days, query.CountryID, query.RegionID, query.RegionIDs)
			if err != nil {
				return SocialRequirements{}, err
			}
			break
		}
	}
	out := SocialRequirements{
		AppCode:   app,
		StatTZ:    statTZ,
		StartMS:   startMS,
		EndMS:     endMS,
		CountryID: query.CountryID,
		RegionID:  query.RegionID,
		RegionIDs: normalizePositiveIDs(query.RegionIDs),
		Sections:  make([]SocialRequirementSection, 0, len(sections)),
	}
	for _, section := range sections {
		sectionRows := make([]SocialRequirementRow, 0, len(days))
		averageRows := make([]SocialRequirementRow, 0, len(days))
		totalAgg := socialRequirementAggregate{}
		totalRetention := map[string]retentionMetric{}
		for _, day := range days {
			agg := aggregateSocialRows(byDay[day], section, query.UserRole, query.PayerType, query.NewUserType)
			totalAgg.add(agg)
			retention := map[string]retentionMetric{}
			if socialSectionNeedsRetention(section) {
				retention = socialRetentionMetrics(day, observableDay, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType, query.NewUserType)
			}
			mergeRetentionMetrics(totalRetention, retention)
			metrics := socialSectionMetrics(section, agg, retention)
			averageRows = append(averageRows, SocialRequirementRow{StatDay: day, Label: day, Metrics: metrics})
			// 日行始终展示基准日 canonical DAU，方便当前日判断 cohort 规模；
			// 汇总和平均仍使用注入前的 metrics，只合并已经到达观察日的 cohort。
			if section == SocialSectionRetention {
				metrics = socialRetentionDailyMetrics(metrics, byDay[day], query.UserRole, query.PayerType, query.NewUserType)
			}
			sectionRows = append(sectionRows, SocialRequirementRow{
				StatDay: day,
				Label:   day,
				Metrics: metrics,
			})
		}
		sectionOut := SocialRequirementSection{
			Key:   section,
			Label: socialSectionLabel(section),
			Filters: SocialRequirementFilter{
				UserRole:    normalizeSocialRequirementRole(query.UserRole),
				PayerType:   normalizeSocialPayerType(query.PayerType),
				NewUserType: normalizeSocialNewUserType(query.NewUserType),
			},
			Columns:           socialRequirementColumns(section),
			MetricDefinitions: socialRequirementMetricDefinitions(section),
			Total:             SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)},
			Average:           socialAverageRow(averageRows),
			DailySeries:       sectionRows,
		}
		if section == SocialSectionGame {
			sectionOut.GameColumns = socialGameBreakdownColumns()
			sectionOut.Games = gameBreakdown
		}
		out.Sections = append(out.Sections, sectionOut)
	}
	return out, nil
}

// socialAverageRow 输出按天算术平均的"平均值"行：每个指标对有值的天求均值。
// 计数/金额类是"日均量"，比例类是"日均率"（与汇总行的整段合并口径互补）。
func socialAverageRow(rows []SocialRequirementRow) SocialRequirementRow {
	sums := map[string]float64{}
	counts := map[string]int{}
	for _, row := range rows {
		for key, value := range row.Metrics {
			switch v := value.(type) {
			case int64:
				sums[key] += float64(v)
				counts[key]++
			case float64:
				sums[key] += v
				counts[key]++
			}
		}
	}
	metrics := make(map[string]any, len(sums))
	for key, sum := range sums {
		if counts[key] > 0 {
			metrics[key] = sum / float64(counts[key])
		}
	}
	return SocialRequirementRow{StatDay: "average", Label: "平均值", Metrics: metrics}
}

func socialRetentionLookaheadDays(sections []string) int {
	lookahead := 0
	for _, section := range sections {
		switch section {
		case SocialSectionRetention:
			return 30
		case SocialSectionNewUsers:
			lookahead = 1
		}
	}
	return lookahead
}

func socialSectionNeedsRetention(section string) bool {
	return section == SocialSectionRetention || section == SocialSectionNewUsers
}

func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, regionIDs []int64) ([]socialRequirementUserDay, error) {
	args := []any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay}
	conditions := []string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?"}
	// 匿名行通常没有国家/区域，而登录后的 u: 行才带用户维度。必须先在完整
	// app+day 小宽表范围内完成 canonical 合并，再应用维度过滤；提前在 SQL 里
	// 过滤会丢掉同一用户登录前的活跃，并让区域 DAU 再次出现 d/u 口径偏差。
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id, country_id, region_id, user_role,
		       app_first_open, profile_setup_view, registered_success, active_user, room_join_success, room_join_fail,
		       mic_up_success, gift_sent, normal_gift_sent, lucky_gift_sent, super_lucky_gift_sent,
		       recharge_user, first_recharge_user, repeat_recharge_user, game_bet_user, self_game_bet_user,
		       probability_game_bet_user, chat_sent, follow_user, follow_room, gift_panel_open, probability_game_panel_open,
		       app_stay_ms, room_stay_ms, room_online_ms, mic_stay_ms, recharge_count, recharge_usd_minor,
		       google_recharge_usd_minor, third_party_recharge_usd_minor, coin_seller_recharge_usd_minor,
		       first_recharge_usd_minor, repeat_recharge_usd_minor, normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin,
		       lucky_gift_payout_coin, super_lucky_gift_payout_coin, game_bet_coin, game_bet_count,
		       self_game_bet_coin, self_game_bet_count, probability_game_bet_coin, probability_game_bet_count,
		       other_consume_coin, output_coin
		FROM stat_social_user_day
		WHERE `+strings.Join(conditions, " AND ")+`
		ORDER BY stat_day, subject_key
	`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []socialRequirementUserDay{}
	for rows.Next() {
		var item socialRequirementUserDay
		if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.DeviceID, &item.CountryID, &item.RegionID, &item.UserRole,
			&item.AppFirstOpen, &item.ProfileSetupView, &item.Registered, &item.Active, &item.RoomJoinOK, &item.RoomJoinNG,
			&item.MicUp, &item.Gift, &item.NormalGift, &item.LuckyGift, &item.SuperGift,
			&item.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet,
			&item.ProbabilityBet, &item.Chat, &item.FollowUser, &item.FollowRoom, &item.GiftPanel, &item.ProbabilityPanel,
			&item.AppStayMS, &item.RoomStayMS, &item.RoomOnlineMS, &item.MicStayMS, &item.RechargeCount, &item.RechargeUSDMinor,
			&item.GoogleRechargeUSDMinor, &item.ThirdPartyRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor,
			&item.FirstRechargeUSDMinor, &item.RepeatRechargeUSDMinor, &item.NormalGiftCoin, &item.LuckyGiftCoin, &item.SuperGiftCoin,
			&item.LuckyGiftPayoutCoin, &item.SuperGiftPayoutCoin, &item.GameBetCoin, &item.GameBetCount,
			&item.SelfGameBetCoin, &item.SelfGameBetCount, &item.ProbabilityBetCoin, &item.ProbabilityBetCount,
			&item.OtherConsumeCoin, &item.OutputCoin); err != nil {
			return nil, err
		}
		item.DirectUser = item.UserID > 0
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	identityByDeviceDay, err := r.querySocialIdentityDays(ctx, app, statTZ, startDay, endDay)
	if err != nil {
		return nil, err
	}
	return canonicalizeSocialUserDays(out, identityByDeviceDay, countryID, regionID, regionIDs), nil
}

// querySocialRetentionUserDays keeps the full identity population until after
// d:/u: canonicalization. Country/region and role/payer/new-user filters cannot
// be pushed into SQL because the authoritative dimensions may live only on the
// logged-in u: row while the anonymous d: row carries part of the activity.
func (r *Repository) querySocialRetentionUserDays(ctx context.Context, app, statTZ, startDay, endDay string) ([]socialRequirementUserDay, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT `+socialRetentionUserDayProjection+`
		FROM stat_social_user_day
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?
		ORDER BY stat_day, subject_key
	`, appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := []socialRequirementUserDay{}
	for rows.Next() {
		var item socialRequirementUserDay
		if err := rows.Scan(
			&item.StatDay, &item.Subject, &item.UserID, &item.DeviceID,
			&item.CountryID, &item.RegionID, &item.UserRole, &item.Registered, &item.Active,
			&item.RoomJoinOK, &item.MicUp, &item.Gift, &item.Recharge, &item.GameBet,
		); err != nil {
			return nil, err
		}
		item.DirectUser = item.UserID > 0
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	identityByDeviceDay, err := r.querySocialIdentityDays(ctx, app, statTZ, startDay, endDay)
	if err != nil {
		return nil, err
	}
	return canonicalizeSocialUserDays(out, identityByDeviceDay, 0, 0, nil), nil
}

type socialIdentityDeviceDayKey struct {
	StatDay  string
	DeviceID string
}

// canonicalOverviewActiveDimension is the compact DAU projection consumed by
// the operation-wide overview. ActiveUsers is a sum of canonical user-days;
// RegionID follows the existing country-breakdown contract where one country
// row carries the greatest observed region ID after the requested region filter.
type canonicalOverviewActiveDimension struct {
	ActiveUsers int64
	RegionID    int64
}

type canonicalOverviewActiveCube struct {
	Total        int64
	ByDay        map[string]int64
	ByCountry    map[int64]canonicalOverviewActiveDimension
	ByDayCountry map[string]canonicalOverviewActiveDimension
}

func newCanonicalOverviewActiveCube() canonicalOverviewActiveCube {
	return canonicalOverviewActiveCube{
		ByDay:        map[string]int64{},
		ByCountry:    map[int64]canonicalOverviewActiveDimension{},
		ByDayCountry: map[string]canonicalOverviewActiveDimension{},
	}
}

// queryCanonicalOverviewActiveCube deliberately reads only the identity and
// dimension columns needed by DAU. The PK prefixes bound both scans by
// app+timezone+day; canonicalization stays in memory because a direct SQL SUM
// would count the same session once as d:<device> and again as u:<account>.
func (r *Repository) queryCanonicalOverviewActiveCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID, regionID int64) (canonicalOverviewActiveCube, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id,
		       country_id, region_id, user_role, active_user
		FROM stat_social_user_day
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?
		  AND (active_user > 0 OR user_id > 0)
		ORDER BY stat_day, subject_key
	`, appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay)
	if err != nil {
		return canonicalOverviewActiveCube{}, err
	}
	defer rows.Close()

	userDays := []socialRequirementUserDay{}
	for rows.Next() {
		var item socialRequirementUserDay
		if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.DeviceID,
			&item.CountryID, &item.RegionID, &item.UserRole, &item.Active); err != nil {
			return canonicalOverviewActiveCube{}, err
		}
		item.DirectUser = item.UserID > 0
		userDays = append(userDays, item)
	}
	if err := rows.Err(); err != nil {
		return canonicalOverviewActiveCube{}, err
	}

	identityByDeviceDay, err := r.querySocialIdentityDays(ctx, app, statTZ, startDay, endDay)
	if err != nil {
		return canonicalOverviewActiveCube{}, err
	}
	canonicalRows := canonicalizeSocialUserDays(userDays, identityByDeviceDay, countryID, regionID, nil)
	return buildCanonicalOverviewActiveCube(canonicalRows), nil
}

func buildCanonicalOverviewActiveCube(canonicalRows []socialRequirementUserDay) canonicalOverviewActiveCube {
	cube := newCanonicalOverviewActiveCube()
	for _, row := range canonicalRows {
		if row.Active <= 0 {
			continue
		}
		cube.Total++
		cube.ByDay[row.StatDay]++
		// 匿名设备在无法安全归并到账号时仍属于真实 DAU，因此保留在总计和按日总计；country_id=0
		// 只是“尚无国家维度”，不是一个国家。国家/按日国家明细只能承载有效国家，不能把 0 暴露成伪国家。
		if row.CountryID <= 0 {
			continue
		}

		country := cube.ByCountry[row.CountryID]
		country.ActiveUsers++
		if row.RegionID > country.RegionID {
			country.RegionID = row.RegionID
		}
		cube.ByCountry[row.CountryID] = country

		dayCountryKey := dailyCountryKey(row.StatDay, row.CountryID)
		dayCountry := cube.ByDayCountry[dayCountryKey]
		dayCountry.ActiveUsers++
		if row.RegionID > dayCountry.RegionID {
			dayCountry.RegionID = row.RegionID
		}
		cube.ByDayCountry[dayCountryKey] = dayCountry
	}
	return cube
}

func (r *Repository) querySocialIdentityDays(ctx context.Context, app, statTZ, startDay, endDay string) (map[socialIdentityDeviceDayKey]int64, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), device_id, resolved_user_id
		FROM stat_social_identity_day
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?
		  AND resolution_state = 'resolved' AND resolved_user_id > 0
	`, appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	resolved := map[socialIdentityDeviceDayKey]int64{}
	for rows.Next() {
		var day, deviceID string
		var userID int64
		if err := rows.Scan(&day, &deviceID, &userID); err != nil {
			return nil, err
		}
		if deviceID = strings.TrimSpace(deviceID); deviceID != "" && userID > 0 {
			resolved[socialIdentityDeviceDayKey{StatDay: day, DeviceID: deviceID}] = userID
		}
	}
	return resolved, rows.Err()
}

// canonicalizeSocialUserDays keeps stat_social_user_day as the immutable source
// of financial/duration facts and only rewrites identity in memory. This avoids
// destructive primary-key moves and keeps a replay idempotent even when late
// events or an account switch changes the device-day resolution.
func canonicalizeSocialUserDays(rows []socialRequirementUserDay, resolved map[socialIdentityDeviceDayKey]int64, countryID, regionID int64, regionIDs []int64) []socialRequirementUserDay {
	merged := map[string]socialRequirementUserDay{}
	firstOpenDevices := map[string]map[string]struct{}{}
	for _, row := range rows {
		if row.UserID <= 0 {
			if userID := resolved[socialIdentityDeviceDayKey{StatDay: row.StatDay, DeviceID: strings.TrimSpace(row.DeviceID)}]; userID > 0 {
				row.UserID = userID
				row.Subject = "u:" + strconv.FormatInt(userID, 10)
			}
		}
		key := row.StatDay + "\x00" + row.Subject
		if row.AppFirstOpen > 0 {
			deviceID := strings.TrimSpace(row.DeviceID)
			if deviceID == "" {
				// Old u: rows may not retain a device. Use the physical subject as a
				// stable one-count fallback; known d/u rows still dedupe by device ID.
				deviceID = "subject:" + row.Subject
			}
			if firstOpenDevices[key] == nil {
				firstOpenDevices[key] = map[string]struct{}{}
			}
			firstOpenDevices[key][deviceID] = struct{}{}
		}
		if current, ok := merged[key]; ok {
			merged[key] = mergeSocialRequirementUserDay(current, row)
		} else {
			merged[key] = row
		}
	}
	out := make([]socialRequirementUserDay, 0, len(merged))
	for key, row := range merged {
		row.AppFirstOpen = int64(len(firstOpenDevices[key]))
		// Client room_stay and authoritative server room_online are parallel
		// measurements, not additive copies. Clamp only after d:/u: fields have
		// been summed so the same interval is not counted twice.
		if row.RoomStayMS < row.RoomOnlineMS {
			row.RoomStayMS = row.RoomOnlineMS
		}
		if row.RoomStayMS < row.MicStayMS {
			row.RoomStayMS = row.MicStayMS
		}
		out = append(out, row)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].StatDay != out[j].StatDay {
			return out[i].StatDay < out[j].StatDay
		}
		return out[i].Subject < out[j].Subject
	})
	return filterSocialRequirementUserDays(out, countryID, regionID, regionIDs)
}

func mergeSocialRequirementUserDay(current, next socialRequirementUserDay) socialRequirementUserDay {
	if current.UserID == 0 {
		current.UserID = next.UserID
	}
	if current.DeviceID == "" {
		current.DeviceID = next.DeviceID
	}
	// Country and region are one dimension tuple. A direct same-day u: row is
	// authoritative only when it carries a real country; a legacy row with just a
	// region cannot erase the anonymous row's known country because region alone
	// has no valid country ownership semantics. No cross-day dimension is borrowed.
	if next.DirectUser && !current.DirectUser {
		if next.CountryID > 0 {
			current.CountryID, current.RegionID = next.CountryID, next.RegionID
		}
		current.UserRole = next.UserRole
		current.DirectUser = true
	} else if next.DirectUser == current.DirectUser {
		if current.CountryID == 0 && current.RegionID == 0 && (next.CountryID > 0 || next.RegionID > 0) {
			current.CountryID, current.RegionID = next.CountryID, next.RegionID
		}
		if normalizeSocialUserRole(next.UserRole) == "host" {
			current.UserRole = next.UserRole
		}
	}
	// canonicalizeSocialUserDays replaces this flag with the distinct known
	// device count after all rows merge; MAX is only the empty-device fallback.
	current.AppFirstOpen = maxSocialFlag(current.AppFirstOpen, next.AppFirstOpen)
	current.ProfileSetupView = maxSocialFlag(current.ProfileSetupView, next.ProfileSetupView)
	current.Registered = maxSocialFlag(current.Registered, next.Registered)
	current.Active = maxSocialFlag(current.Active, next.Active)
	current.RoomJoinOK = maxSocialFlag(current.RoomJoinOK, next.RoomJoinOK)
	current.RoomJoinNG = maxSocialFlag(current.RoomJoinNG, next.RoomJoinNG)
	current.MicUp = maxSocialFlag(current.MicUp, next.MicUp)
	current.Gift = maxSocialFlag(current.Gift, next.Gift)
	current.NormalGift = maxSocialFlag(current.NormalGift, next.NormalGift)
	current.LuckyGift = maxSocialFlag(current.LuckyGift, next.LuckyGift)
	current.SuperGift = maxSocialFlag(current.SuperGift, next.SuperGift)
	current.Recharge = maxSocialFlag(current.Recharge, next.Recharge)
	current.FirstRecharge = maxSocialFlag(current.FirstRecharge, next.FirstRecharge)
	current.RepeatRecharge = maxSocialFlag(current.RepeatRecharge, next.RepeatRecharge)
	current.GameBet = maxSocialFlag(current.GameBet, next.GameBet)
	current.SelfGameBet = maxSocialFlag(current.SelfGameBet, next.SelfGameBet)
	current.ProbabilityBet = maxSocialFlag(current.ProbabilityBet, next.ProbabilityBet)
	current.Chat = maxSocialFlag(current.Chat, next.Chat)
	current.FollowUser = maxSocialFlag(current.FollowUser, next.FollowUser)
	current.FollowRoom = maxSocialFlag(current.FollowRoom, next.FollowRoom)
	current.GiftPanel = maxSocialFlag(current.GiftPanel, next.GiftPanel)
	current.ProbabilityPanel = maxSocialFlag(current.ProbabilityPanel, next.ProbabilityPanel)
	current.AppStayMS += next.AppStayMS
	current.RoomStayMS += next.RoomStayMS
	current.RoomOnlineMS += next.RoomOnlineMS
	current.MicStayMS += next.MicStayMS
	current.RechargeCount += next.RechargeCount
	current.RechargeUSDMinor += next.RechargeUSDMinor
	current.GoogleRechargeUSDMinor += next.GoogleRechargeUSDMinor
	current.ThirdPartyRechargeUSDMinor += next.ThirdPartyRechargeUSDMinor
	current.CoinSellerRechargeUSDMinor += next.CoinSellerRechargeUSDMinor
	current.FirstRechargeUSDMinor += next.FirstRechargeUSDMinor
	current.RepeatRechargeUSDMinor += next.RepeatRechargeUSDMinor
	current.NormalGiftCoin += next.NormalGiftCoin
	current.LuckyGiftCoin += next.LuckyGiftCoin
	current.SuperGiftCoin += next.SuperGiftCoin
	current.LuckyGiftPayoutCoin += next.LuckyGiftPayoutCoin
	current.SuperGiftPayoutCoin += next.SuperGiftPayoutCoin
	current.GameBetCoin += next.GameBetCoin
	current.GameBetCount += next.GameBetCount
	current.SelfGameBetCoin += next.SelfGameBetCoin
	current.SelfGameBetCount += next.SelfGameBetCount
	current.ProbabilityBetCoin += next.ProbabilityBetCoin
	current.ProbabilityBetCount += next.ProbabilityBetCount
	current.OtherConsumeCoin += next.OtherConsumeCoin
	current.OutputCoin += next.OutputCoin
	return current
}

func maxSocialFlag(left, right int64) int64 {
	if right > left {
		return right
	}
	return left
}

func filterSocialRequirementUserDays(rows []socialRequirementUserDay, countryID, regionID int64, regionIDs []int64) []socialRequirementUserDay {
	allowedRegions := map[int64]struct{}{}
	if regionID <= 0 {
		for _, id := range normalizePositiveIDs(regionIDs) {
			allowedRegions[id] = struct{}{}
		}
	}
	if countryID <= 0 && regionID <= 0 && len(allowedRegions) == 0 {
		return rows
	}
	out := make([]socialRequirementUserDay, 0, len(rows))
	for _, row := range rows {
		if countryID > 0 && row.CountryID != countryID {
			continue
		}
		if regionID > 0 && row.RegionID != regionID {
			continue
		}
		if len(allowedRegions) > 0 {
			if _, ok := allowedRegions[row.RegionID]; !ok {
				continue
			}
		}
		out = append(out, row)
	}
	return out
}

func indexSocialRows(rows []socialRequirementUserDay) (map[string][]socialRequirementUserDay, map[string]map[string]socialRequirementUserDay) {
	byDay := map[string][]socialRequirementUserDay{}
	bySubjectDay := map[string]map[string]socialRequirementUserDay{}
	for _, row := range rows {
		byDay[row.StatDay] = append(byDay[row.StatDay], row)
		if bySubjectDay[row.Subject] == nil {
			bySubjectDay[row.Subject] = map[string]socialRequirementUserDay{}
		}
		bySubjectDay[row.Subject][row.StatDay] = row
	}
	return byDay, bySubjectDay
}

func aggregateSocialRows(rows []socialRequirementUserDay, section string, role string, payerType string, newUserType string) socialRequirementAggregate {
	var agg socialRequirementAggregate
	for _, row := range rows {
		if !socialRowMatchesSection(row, section, role, payerType, newUserType) {
			continue
		}
		// 下载/首次打开是设备口径；身份归并只消除用户行为的 d/u 重复，
		// 不能把同一账号在两台设备上的两次安装压成 1。
		agg.AppFirstOpen += normalizeID(row.AppFirstOpen)
		// 资料页曝光按 canonical 主体日去重：登录用户按 user_id，注册前匿名流程按 device_id；
		// 同一客户端会话完成登录后，查询阶段会把 d:/u: 两行合并，避免一次曝光重复计数。
		agg.ProfileSetupView += flagValue(row.ProfileSetupView)
		agg.Registered += flagValue(row.Registered)
		if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
			agg.NewHostUsers++
		} else if flagValue(row.Registered) > 0 {
			agg.NewNormalUsers++
		}
		agg.Active += flagValue(row.Active)
		agg.RoomJoinOK += flagValue(row.RoomJoinOK)
		agg.RoomJoinNG += flagValue(row.RoomJoinNG)
		if row.Registered > 0 && row.RoomJoinOK > 0 {
			agg.RegisteredRoomJoinOK++
		}
		// 失败人数表示“尝试过但当天始终没有拿到房间信息”的新用户；同一用户先失败后成功时，
		// 成功事实优先，不能让一个人同时进入成功、失败两个转化分子。
		if row.Registered > 0 && row.RoomJoinNG > 0 && row.RoomJoinOK == 0 {
			agg.RegisteredRoomJoinNG++
		}
		if row.Registered > 0 && row.RoomJoinOK > 0 && row.MicUp > 0 {
			agg.RegisteredMicUp++
		}
		if row.Registered > 0 && row.RoomJoinOK > 0 && row.Gift > 0 {
			agg.RegisteredRoomGift++
		}
		// 新用户充值转化覆盖普通充值事实和 finance verified 币商充值。后者不进入全局
		// paid/ARPPU 分母，但已审核到账金额足以证明该新用户当天完成过一次充值。
		if row.Registered > 0 && (row.Recharge > 0 || row.CoinSellerRechargeUSDMinor > 0) {
			agg.RegisteredRecharge++
		}
		if row.Registered > 0 && row.GameBet > 0 {
			agg.RegisteredGame++
		}
		// P0 的时长/关注/聊天/充值金额按文档限定「当天注册的新用户」，不能混入全体用户。
		if row.Registered > 0 {
			if row.FollowUser > 0 {
				agg.RegisteredFollowUser++
			}
			if row.FollowRoom > 0 {
				agg.RegisteredFollowRoom++
			}
			if row.Chat > 0 {
				agg.RegisteredChat++
			}
			agg.RegisteredAppStayMS += row.AppStayMS
			agg.RegisteredRoomStayMS += row.RoomStayMS
			agg.RegisteredMicStayMS += row.MicStayMS
			agg.RegisteredRechargeUSDMinor += row.RechargeUSDMinor
		}
		// 消费渗透率的分子是「有任一金币消耗行为」的去重用户（送礼/游戏/其他消费），不能把各行为人数直接相加。
		if row.Gift > 0 || row.GameBet > 0 || row.OtherConsumeCoin > 0 {
			agg.ConsumeUsers++
		}
		agg.MicUp += flagValue(row.MicUp)
		agg.Gift += flagValue(row.Gift)
		agg.NormalGift += flagValue(row.NormalGift)
		agg.LuckyGift += flagValue(row.LuckyGift)
		agg.SuperGift += flagValue(row.SuperGift)
		agg.Recharge += flagValue(row.Recharge)
		agg.FirstRecharge += flagValue(row.FirstRecharge)
		agg.RepeatRecharge += flagValue(row.RepeatRecharge)
		agg.GameBet += flagValue(row.GameBet)
		agg.SelfGameBet += flagValue(row.SelfGameBet)
		agg.ProbabilityBet += flagValue(row.ProbabilityBet)
		agg.Chat += flagValue(row.Chat)
		agg.FollowUser += flagValue(row.FollowUser)
		agg.FollowRoom += flagValue(row.FollowRoom)
		agg.GiftPanel += flagValue(row.GiftPanel)
		agg.ProbabilityPanel += flagValue(row.ProbabilityPanel)
		agg.AppStayMS += row.AppStayMS
		agg.RoomStayMS += row.RoomStayMS
		agg.MicStayMS += row.MicStayMS
		agg.RechargeCount += row.RechargeCount
		agg.RechargeUSDMinor += row.RechargeUSDMinor
		agg.GoogleRechargeUSDMinor += row.GoogleRechargeUSDMinor
		agg.ThirdPartyRechargeUSDMinor += row.ThirdPartyRechargeUSDMinor
		agg.CoinSellerRechargeUSDMinor += row.CoinSellerRechargeUSDMinor
		agg.FirstRechargeUSDMinor += row.FirstRechargeUSDMinor
		agg.RepeatRechargeUSDMinor += row.RepeatRechargeUSDMinor
		agg.NormalGiftCoin += row.NormalGiftCoin
		agg.LuckyGiftCoin += row.LuckyGiftCoin
		agg.SuperGiftCoin += row.SuperGiftCoin
		agg.LuckyGiftPayoutCoin += row.LuckyGiftPayoutCoin
		agg.SuperGiftPayoutCoin += row.SuperGiftPayoutCoin
		agg.GameBetCoin += row.GameBetCoin
		agg.GameBetCount += row.GameBetCount
		agg.SelfGameBetCoin += row.SelfGameBetCoin
		agg.SelfGameBetCount += row.SelfGameBetCount
		agg.ProbabilityBetCoin += row.ProbabilityBetCoin
		agg.ProbabilityBetCount += row.ProbabilityBetCount
		agg.OtherConsumeCoin += row.OtherConsumeCoin
		agg.OutputCoin += row.OutputCoin
	}
	return agg
}

func (a *socialRequirementAggregate) add(other socialRequirementAggregate) {
	a.AppFirstOpen += other.AppFirstOpen
	a.Registered += other.Registered
	a.NewHostUsers += other.NewHostUsers
	a.NewNormalUsers += other.NewNormalUsers
	a.Active += other.Active
	a.RoomJoinOK += other.RoomJoinOK
	a.RoomJoinNG += other.RoomJoinNG
	a.RegisteredRoomJoinOK += other.RegisteredRoomJoinOK
	a.RegisteredRoomJoinNG += other.RegisteredRoomJoinNG
	a.RegisteredMicUp += other.RegisteredMicUp
	a.RegisteredRoomGift += other.RegisteredRoomGift
	a.RegisteredRecharge += other.RegisteredRecharge
	a.RegisteredGame += other.RegisteredGame
	a.RegisteredFollowUser += other.RegisteredFollowUser
	a.RegisteredFollowRoom += other.RegisteredFollowRoom
	a.RegisteredChat += other.RegisteredChat
	a.RegisteredAppStayMS += other.RegisteredAppStayMS
	a.RegisteredRoomStayMS += other.RegisteredRoomStayMS
	a.RegisteredMicStayMS += other.RegisteredMicStayMS
	a.RegisteredRechargeUSDMinor += other.RegisteredRechargeUSDMinor
	a.ConsumeUsers += other.ConsumeUsers
	a.MicUp += other.MicUp
	a.Gift += other.Gift
	a.NormalGift += other.NormalGift
	a.LuckyGift += other.LuckyGift
	a.SuperGift += other.SuperGift
	a.Recharge += other.Recharge
	a.FirstRecharge += other.FirstRecharge
	a.RepeatRecharge += other.RepeatRecharge
	a.GameBet += other.GameBet
	a.SelfGameBet += other.SelfGameBet
	a.ProbabilityBet += other.ProbabilityBet
	a.Chat += other.Chat
	a.FollowUser += other.FollowUser
	a.FollowRoom += other.FollowRoom
	a.GiftPanel += other.GiftPanel
	a.ProbabilityPanel += other.ProbabilityPanel
	a.AppStayMS += other.AppStayMS
	a.RoomStayMS += other.RoomStayMS
	a.MicStayMS += other.MicStayMS
	a.RechargeCount += other.RechargeCount
	a.RechargeUSDMinor += other.RechargeUSDMinor
	a.GoogleRechargeUSDMinor += other.GoogleRechargeUSDMinor
	a.ThirdPartyRechargeUSDMinor += other.ThirdPartyRechargeUSDMinor
	a.CoinSellerRechargeUSDMinor += other.CoinSellerRechargeUSDMinor
	a.FirstRechargeUSDMinor += other.FirstRechargeUSDMinor
	a.RepeatRechargeUSDMinor += other.RepeatRechargeUSDMinor
	a.NormalGiftCoin += other.NormalGiftCoin
	a.LuckyGiftCoin += other.LuckyGiftCoin
	a.SuperGiftCoin += other.SuperGiftCoin
	a.LuckyGiftPayoutCoin += other.LuckyGiftPayoutCoin
	a.SuperGiftPayoutCoin += other.SuperGiftPayoutCoin
	a.GameBetCoin += other.GameBetCoin
	a.GameBetCount += other.GameBetCount
	a.SelfGameBetCoin += other.SelfGameBetCoin
	a.SelfGameBetCount += other.SelfGameBetCount
	a.ProbabilityBetCoin += other.ProbabilityBetCoin
	a.ProbabilityBetCount += other.ProbabilityBetCount
	a.OtherConsumeCoin += other.OtherConsumeCoin
	a.OutputCoin += other.OutputCoin
}

func socialRetentionMetrics(day string, observableDay string, rows []socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, section string, role string, payerType string, newUserType string) map[string]retentionMetric {
	out := map[string]retentionMetric{}
	d1Observable := addStatDays(day, 1) <= observableDay
	for _, row := range rows {
		if !socialRowMatchesSection(row, section, role, payerType, newUserType) {
			continue
		}
		// 文档口径：用户次留/3/7/15/30日留以「当日全体活跃用户」为基数，次日继续活跃算留存；
		// 新用户（注册 cohort）留存只保留在 P0 的分身份次留里。
		for _, offset := range []int{1, 3, 7, 15, 30} {
			// D+N 尚未到达时，缺少目标日事实表示“不可观察”，不能当成 0 人回访。
			// 不创建 metric key 可让日行显示空值，并让汇总/平均自然排除未成熟 cohort。
			if row.Active > 0 && addStatDays(day, offset) <= observableDay {
				addSocialRetention(out, "active_d"+intLabel(offset)+"_active", row, bySubjectDay, offset, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			}
		}
		if d1Observable && row.RoomJoinOK > 0 {
			addSocialRetention(out, "after_room_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			addSocialRetention(out, "after_room_d1_room", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.RoomJoinOK > 0 })
		}
		if d1Observable && row.MicUp > 0 {
			addSocialRetention(out, "after_mic_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			addSocialRetention(out, "after_mic_d1_mic", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.MicUp > 0 })
		}
		if d1Observable && row.Gift > 0 {
			addSocialRetention(out, "after_gift_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			addSocialRetention(out, "after_gift_d1_gift", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Gift > 0 })
		}
		if d1Observable && row.GameBet > 0 {
			addSocialRetention(out, "after_game_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			addSocialRetention(out, "after_game_d1_game", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.GameBet > 0 })
		}
		if d1Observable && row.Recharge > 0 {
			addSocialRetention(out, "after_recharge_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
			addSocialRetention(out, "after_recharge_d1_recharge", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Recharge > 0 })
		}
		if d1Observable && row.Registered > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
			addSocialRetention(out, "new_host_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
		}
		if d1Observable && row.Registered > 0 && normalizeSocialUserRole(row.UserRole) != "host" {
			addSocialRetention(out, "new_user_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
		}
	}
	return out
}

func addSocialRetention(out map[string]retentionMetric, key string, base socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, offset int, retained func(socialRequirementUserDay) bool) {
	item := out[key]
	item.Base++
	if target, ok := bySubjectDay[base.Subject][addStatDays(base.StatDay, offset)]; ok && retained(target) {
		item.Users++
	}
	out[key] = item
}

func mergeRetentionMetrics(total map[string]retentionMetric, next map[string]retentionMetric) {
	for key, item := range next {
		current := total[key]
		current.Base += item.Base
		current.Users += item.Users
		total[key] = current
	}
}

// socialRetentionDailyMetrics adds the cohort size independently from the
// observation result. This lets an immature day expose its canonical DAU base
// while users/rate remain absent; aggregate rows keep using only mature metrics.
func socialRetentionDailyMetrics(mature map[string]any, rows []socialRequirementUserDay, role, payerType, newUserType string) map[string]any {
	out := make(map[string]any, len(mature)+5)
	for key, value := range mature {
		out[key] = value
	}
	var base int64
	for _, row := range rows {
		if row.Active > 0 && socialRowMatchesSection(row, SocialSectionRetention, role, payerType, newUserType) {
			base++
		}
	}
	for _, offset := range []int{1, 3, 7, 15, 30} {
		out["active_d"+intLabel(offset)+"_active_base_users"] = base
	}
	return out
}

func socialSectionMetrics(section string, agg socialRequirementAggregate, retention map[string]retentionMetric) map[string]any {
	switch section {
	case SocialSectionRevenue:
		totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin
		ordinaryRechargeUSDMinor := agg.FirstRechargeUSDMinor + agg.RepeatRechargeUSDMinor
		return map[string]any{
			"recharge_usd_minor":             agg.RechargeUSDMinor,
			"google_recharge_usd_minor":      agg.GoogleRechargeUSDMinor,
			"third_party_recharge_usd_minor": agg.ThirdPartyRechargeUSDMinor,
			"coin_seller_recharge_usd_minor": agg.CoinSellerRechargeUSDMinor,
			"first_recharge_usd_minor":       agg.FirstRechargeUSDMinor,
			"repeat_recharge_usd_minor":      agg.RepeatRechargeUSDMinor,
			"recharge_users":                 agg.Recharge,
			"recharge_count":                 agg.RechargeCount,
			"first_recharge_users":           agg.FirstRecharge,
			"repeat_recharge_users":          agg.RepeatRecharge,
			"repurchase_rate":                ratio(agg.RepeatRecharge, agg.Recharge),
			"paid_rate":                      ratio(agg.Recharge, agg.Active),
			"arpu_usd_minor":                 div(agg.RechargeUSDMinor, agg.Active),
			"arppu_usd_minor":                div(ordinaryRechargeUSDMinor, agg.Recharge),
			"consumption_penetration_rate":   ratio(agg.ConsumeUsers, agg.Active),
			"game_consumption_ratio":         ratio(agg.GameBetCoin, totalConsume),
			"lucky_gift_consumption_ratio":   ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume),
			"normal_gift_consumption_ratio":  ratio(agg.NormalGiftCoin, totalConsume),
			"other_consumption_ratio":        ratio(agg.OtherConsumeCoin, totalConsume),
		}
	case SocialSectionRetention:
		out := map[string]any{}
		for _, key := range sortedRetentionKeys(retention) {
			item := retention[key]
			out[key+"_base_users"] = item.Base
			out[key+"_users"] = item.Users
			out[key+"_rate"] = ratio(item.Users, item.Base)
		}
		return out
	case SocialSectionActive:
		return map[string]any{
			"active_users":            agg.Active,
			"room_join_success_users": agg.RoomJoinOK,
			"room_join_fail_users":    agg.RoomJoinNG,
			// 文档口径：进房成功率/失败率的分母都是 DAU，不是进房尝试数。
			"room_join_success_rate": ratio(agg.RoomJoinOK, agg.Active),
			"room_join_fail_rate":    ratio(agg.RoomJoinNG, agg.Active),
			"mic_up_users":           agg.MicUp,
			"mic_up_rate":            ratio(agg.MicUp, agg.RoomJoinOK),
			"gift_users":             agg.Gift,
			"gift_rate":              ratio(agg.Gift, agg.RoomJoinOK),
			"game_users":             agg.GameBet,
			"game_conversion_rate":   ratio(agg.GameBet, agg.RoomJoinOK),
			"chat_users":             agg.Chat,
			"chat_conversion_rate":   ratio(agg.Chat, agg.Active),
			"avg_app_stay_min":       minutesPerUser(agg.AppStayMS, agg.Active),
		}
	case SocialSectionGift:
		return map[string]any{
			"gift_panel_open_users":        agg.GiftPanel,
			"gift_panel_conversion_rate":   ratio(agg.GiftPanel, agg.RoomJoinOK),
			"room_gift_users":              agg.Gift,
			"room_gift_conversion_rate":    ratio(agg.Gift, agg.RoomJoinOK),
			"normal_gift_users":            agg.NormalGift,
			"normal_gift_coin":             agg.NormalGiftCoin,
			"normal_gift_arppu_coin":       div(agg.NormalGiftCoin, agg.NormalGift),
			"lucky_gift_users":             agg.LuckyGift,
			"lucky_gift_coin":              agg.LuckyGiftCoin,
			"lucky_gift_payout_coin":       agg.LuckyGiftPayoutCoin,
			"lucky_gift_rtp":               ratio(agg.LuckyGiftPayoutCoin, agg.LuckyGiftCoin),
			"lucky_gift_arppu_coin":        div(agg.LuckyGiftCoin, agg.LuckyGift),
			"super_lucky_gift_users":       agg.SuperGift,
			"super_lucky_gift_coin":        agg.SuperGiftCoin,
			"super_lucky_gift_payout_coin": agg.SuperGiftPayoutCoin,
			"super_lucky_gift_rtp":         ratio(agg.SuperGiftPayoutCoin, agg.SuperGiftCoin),
			"super_lucky_gift_arppu_coin":  div(agg.SuperGiftCoin, agg.SuperGift),
		}
	case SocialSectionGame:
		return map[string]any{
			"active_users":                           agg.Active,
			"room_join_success_users":                agg.RoomJoinOK,
			"self_game_conversion_rate":              ratio(agg.SelfGameBet, agg.Active),
			"self_game_bet_users":                    agg.SelfGameBet,
			"self_game_bet_count":                    agg.SelfGameBetCount,
			"self_game_bet_count_per_user":           ratio(agg.SelfGameBetCount, agg.SelfGameBet),
			"self_game_bet_coin":                     agg.SelfGameBetCoin,
			"self_game_bet_arppu_coin":               div(agg.SelfGameBetCoin, agg.SelfGameBet),
			"probability_game_panel_open_users":      agg.ProbabilityPanel,
			"probability_game_panel_conversion_rate": ratio(agg.ProbabilityPanel, agg.RoomJoinOK),
			"probability_game_bet_users":             agg.ProbabilityBet,
			"probability_game_conversion_rate":       ratio(agg.ProbabilityBet, agg.RoomJoinOK),
			"probability_game_bet_count":             agg.ProbabilityBetCount,
			"probability_game_bet_count_per_user":    ratio(agg.ProbabilityBetCount, agg.ProbabilityBet),
			"probability_game_bet_coin":              agg.ProbabilityBetCoin,
			"probability_game_bet_arppu_coin":        div(agg.ProbabilityBetCoin, agg.ProbabilityBet),
		}
	default:
		out := map[string]any{
			"app_download_users":        agg.AppFirstOpen,
			"profile_setup_view_users":  agg.ProfileSetupView,
			"registered_users":          agg.Registered,
			"new_host_users":            agg.NewHostUsers,
			"new_normal_users":          agg.NewNormalUsers,
			"registration_success_rate": ratio(agg.Registered, agg.ProfileSetupView),
			"room_join_success_users":   agg.RegisteredRoomJoinOK,
			"room_join_fail_users":      agg.RegisteredRoomJoinNG,
			"room_join_success_rate":    ratio(agg.RegisteredRoomJoinOK, agg.Registered),
			"room_join_fail_rate":       ratio(agg.RegisteredRoomJoinNG, agg.Registered),
			"mic_up_users":              agg.RegisteredMicUp,
			"mic_up_rate":               ratio(agg.RegisteredMicUp, agg.RegisteredRoomJoinOK),
			"gift_users":                agg.RegisteredRoomGift,
			"gift_rate":                 ratio(agg.RegisteredRoomGift, agg.RegisteredRoomJoinOK),
			"recharge_users":            agg.RegisteredRecharge,
			"recharge_rate":             ratio(agg.RegisteredRecharge, agg.Registered),
			"game_users":                agg.RegisteredGame,
			"game_conversion_rate":      ratio(agg.RegisteredGame, agg.Registered),
			// P0 全部限定当天注册的新用户：时长按新用户累计、除以对应行为的新用户数。
			"avg_app_stay_min":            minutesPerUser(agg.RegisteredAppStayMS, agg.Registered),
			"avg_room_stay_min":           minutesPerUser(agg.RegisteredRoomStayMS, agg.RegisteredRoomJoinOK),
			"avg_mic_stay_min":            minutesPerUser(agg.RegisteredMicStayMS, agg.RegisteredMicUp),
			"new_user_recharge_usd_minor": agg.RegisteredRechargeUSDMinor,
			"follow_user_users":           agg.RegisteredFollowUser,
			"follow_room_users":           agg.RegisteredFollowRoom,
			"chat_users":                  agg.RegisteredChat,
		}
		// 未成熟的 D1 cohort 不会产生 retention key；此时省略字段，让前端显示“--”，
		// 同时避免总计和日均把尚未发生的观察日按 0% 计算。
		if _, ok := retention["new_host_d1_active"]; ok {
			out["new_host_d1_retention_rate"] = retentionRate(retention, "new_host_d1_active")
		}
		if _, ok := retention["new_user_d1_active"]; ok {
			out["new_user_d1_retention_rate"] = retentionRate(retention, "new_user_d1_active")
		}
		return out
	}
}

func socialRowMatchesSection(row socialRequirementUserDay, section string, role string, payerType string, newUserType string) bool {
	switch section {
	case SocialSectionNewUsers:
		return true
	case SocialSectionGift, SocialSectionGame:
		return socialRoleMatches(row, role) && socialNewUserMatches(row, newUserType)
	default:
		return socialRoleMatches(row, role) && socialPayerMatches(row, payerType) && socialNewUserMatches(row, newUserType)
	}
}

// 是否新用户筛选：新用户 = 当天注册成功的用户（registered_success 标志按行的 stat_day 判定）。
func socialNewUserMatches(row socialRequirementUserDay, newUserType string) bool {
	switch normalizeSocialNewUserType(newUserType) {
	case "new":
		return row.Registered > 0
	case "old":
		return row.Registered == 0
	default:
		return true
	}
}

func normalizeSocialNewUserType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "new":
		return "new"
	case "old", "existing", "non_new":
		return "old"
	default:
		return "all"
	}
}

func socialRoleMatches(row socialRequirementUserDay, role string) bool {
	switch normalizeSocialRequirementRole(role) {
	case "host":
		return normalizeSocialUserRole(row.UserRole) == "host"
	case "user":
		return normalizeSocialUserRole(row.UserRole) != "host"
	default:
		return true
	}
}

func socialPayerMatches(row socialRequirementUserDay, payerType string) bool {
	switch normalizeSocialPayerType(payerType) {
	case "paid":
		return row.Recharge > 0
	case "unpaid":
		return row.Recharge == 0
	default:
		return true
	}
}

func normalizeSocialRequirementRole(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "host":
		return "host"
	case "user":
		return "user"
	default:
		return "all"
	}
}

func normalizeSocialPayerType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "paid":
		return "paid"
	case "unpaid":
		return "unpaid"
	default:
		return "all"
	}
}

func socialRequestedSections(value string) []string {
	allowed := map[string]bool{
		SocialSectionNewUsers:  true,
		SocialSectionRevenue:   true,
		SocialSectionRetention: true,
		SocialSectionActive:    true,
		SocialSectionGift:      true,
		SocialSectionGame:      true,
	}
	parts := strings.Split(value, ",")
	out := []string{}
	for _, part := range parts {
		part = strings.ToLower(strings.TrimSpace(part))
		if allowed[part] {
			out = append(out, part)
		}
	}
	if len(out) > 0 {
		return out
	}
	return []string{SocialSectionNewUsers, SocialSectionRevenue, SocialSectionRetention, SocialSectionActive, SocialSectionGift, SocialSectionGame}
}

func socialSectionLabel(section string) string {
	switch section {
	case SocialSectionRevenue:
		return "P1 营收"
	case SocialSectionRetention:
		return "P2 留存"
	case SocialSectionActive:
		return "P2 活跃"
	case SocialSectionGift:
		return "P4 送礼"
	case SocialSectionGame:
		return "P5 游戏"
	default:
		return "P0 新用户"
	}
}

func socialRequirementMetricDefinitions(section string) []MetricDefinition {
	switch section {
	case SocialSectionRevenue:
		return []MetricDefinition{
			{Metric: "recharge_usd_minor", Definition: "普通 WalletRechargeRecorded 用户充值和 finance verified 币商充值订单汇总，单位 USD minor；总充值 = Google + 第三方 + 币商。"},
			{Metric: "third_party_recharge_usd_minor", Definition: "MiFaPay/V5Pay/USDT 等非 Google、非币商支付渠道的用户充值金额。"},
			{Metric: "coin_seller_recharge_usd_minor", Definition: "finance 工作台币商充值订单 verify_status=verified 后按 created_at_ms 计入。"},
			{Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"},
			{Metric: "paid_rate", Definition: "充值用户 / DAU，支持用户身份和当日付费身份筛选。"},
			{Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"},
			{Metric: "arppu_usd_minor", Definition: "普通充值金额 / 普通充值用户；finance 币商订单只进入收入金额和订单次数，不污染用户付费均值。"},
		}
	case SocialSectionRetention:
		return []MetricDefinition{
			{Metric: "active_d1_active_rate", Definition: "当日全体活跃用户（进入 app）在次日继续活跃的比例；D3/D7/D15/D30 同理。不是新用户留存。"},
			{Metric: "after_room_d1_room_rate", Definition: "进房 cohort 在次日再次进房的留存。"},
			{Metric: "after_recharge_d1_recharge_rate", Definition: "充值 cohort 在次日再次充值的留存。"},
		}
	case SocialSectionActive:
		return []MetricDefinition{
			{Metric: "active_users", Definition: "服务端活跃事实和关键客户端行为汇总后的当日活跃用户。"},
			{Metric: "room_join_success_rate", Definition: "进房成功用户 / 进房尝试用户。"},
			{Metric: "avg_app_stay_min", Definition: "客户端 app_session 时长 / DAU，单位分钟。"},
		}
	case SocialSectionGift:
		return []MetricDefinition{
			{Metric: "gift_panel_conversion_rate", Definition: "打开礼物面板用户 / 进房成功用户。"},
			{Metric: "room_gift_conversion_rate", Definition: "送礼用户 / 进房成功用户。"},
			{Metric: "lucky_gift_rtp", Definition: "幸运礼物返奖 / 幸运礼物流水。"},
		}
	case SocialSectionGame:
		return []MetricDefinition{
			{Metric: "self_game_conversion_rate", Definition: "自研游戏押注用户 / DAU。"},
			{Metric: "probability_game_panel_conversion_rate", Definition: "概率游戏面板打开用户 / 进房成功用户。"},
			{Metric: "probability_game_bet_arppu_coin", Definition: "概率游戏押注金币 / 概率游戏押注用户。"},
		}
	default:
		return []MetricDefinition{
			{Metric: "profile_setup_view_users", Definition: "进入填写资料页的新用户人数：客户端 profile_start 事件按 canonical 用户或设备主体、统计自然日去重。"},
			// registered_users 保留历史字段兼容，业务展示统一为“新增用户”；资格和幂等边界由 UserRegistered 生产、消费链路保证。
			{Metric: "registered_users", Definition: "注册成功的新用户人数：仅统计资料完成后由 user-service 发布的 UserRegistered 服务端事实，按用户去重；未完成资料、quick_account、game_robot 不计，MQ 重投不重复。"},
			{Metric: "registration_success_rate", Definition: "新增用户 / 填写资料页曝光人数；填写资料页曝光人数为 0 时返回 0。"},
			{Metric: "room_join_success_users", Definition: "新用户进房人数：当天新增用户中，客户端 JoinRoom 成功返回完整房间信息或服务端 RoomUserJoined 确认成功的去重用户数。"},
			{Metric: "room_join_fail_users", Definition: "新用户进房失败人数：当天新增用户中发起进房后因网络、加载或业务错误失败，且当天没有任何成功进房事实的去重用户数。"},
			{Metric: "room_join_success_rate", Definition: "新用户进房人数 / 新增用户；新增用户为 0 时返回 0。"},
			{Metric: "room_join_fail_rate", Definition: "新用户进房失败人数 / 新增用户；新增用户为 0 时返回 0。"},
			{Metric: "mic_up_users", Definition: "新用户上麦人数：当天已成功进房的新用户中，客户端 ConfirmMicPublishing 确认麦位为 publishing，或服务端产生正麦上时长事实的去重人数。"},
			{Metric: "mic_up_rate", Definition: "新用户上麦人数 / 新用户进房人数；新用户进房人数为 0 时返回 0。"},
			{Metric: "gift_users", Definition: "新用户送礼人数：当天已成功进房的新用户中，产生 RoomGiftSent 成功事实的去重人数；普通、幸运及 Super Lucky 礼物均计入。"},
			{Metric: "gift_rate", Definition: "新用户送礼人数 / 新用户进房人数；新用户进房人数为 0 时返回 0。"},
			{Metric: "recharge_users", Definition: "新用户充值人数：当天新增用户中，成功完成 Google、第三方或币商充值的去重人数；充值失败、待审核币商订单不计。"},
			{Metric: "recharge_rate", Definition: "新用户充值人数 / 新增用户；新增用户为 0 时返回 0。"},
			{Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"},
		}
	}
}

func socialRequirementColumns(section string) []SocialRequirementColumn {
	switch section {
	case SocialSectionRevenue:
		return []SocialRequirementColumn{
			{Key: "recharge_usd_minor", Label: "充值金额", Type: "money_minor"},
			{Key: "google_recharge_usd_minor", Label: "Google充值", Type: "money_minor"},
			{Key: "third_party_recharge_usd_minor", Label: "三方充值", Type: "money_minor"},
			{Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Type: "money_minor"},
			{Key: "first_recharge_usd_minor", Label: "首充金额", Type: "money_minor"},
			{Key: "repeat_recharge_usd_minor", Label: "复购金额", Type: "money_minor"},
			{Key: "recharge_users", Label: "充值用户", Type: "count"},
			{Key: "recharge_count", Label: "充值次数", Type: "count"},
			{Key: "first_recharge_users", Label: "首充用户", Type: "count"},
			{Key: "repeat_recharge_users", Label: "复购用户", Type: "count"},
			{Key: "repurchase_rate", Label: "复购率", Type: "ratio"},
			{Key: "paid_rate", Label: "付费率", Type: "ratio"},
			{Key: "arpu_usd_minor", Label: "ARPU", Type: "money_minor"},
			{Key: "arppu_usd_minor", Label: "ARPPU", Type: "money_minor"},
			{Key: "consumption_penetration_rate", Label: "消费渗透率", Type: "ratio"},
			{Key: "game_consumption_ratio", Label: "游戏消费占比", Type: "ratio"},
			{Key: "lucky_gift_consumption_ratio", Label: "幸运礼物消费占比", Type: "ratio"},
			{Key: "normal_gift_consumption_ratio", Label: "普通礼物消费占比", Type: "ratio"},
			{Key: "other_consumption_ratio", Label: "其他消费占比", Type: "ratio"},
		}
	case SocialSectionRetention:
		return []SocialRequirementColumn{
			{Key: "active_d1_active_base_users", Label: "活跃用户基数", Type: "count", Tooltip: "当日进入 app 的全体活跃用户数（不限新用户）"},
			{Key: "active_d1_active_users", Label: "次留人数", Type: "count", Tooltip: "当日活跃用户中，次日继续进入 app 的人数"},
			{Key: "active_d1_active_rate", Label: "用户次留", Type: "ratio", Tooltip: "当日活跃用户中，次日继续进入 app 的比例"},
			{Key: "active_d3_active_rate", Label: "用户3日留", Type: "ratio"},
			{Key: "active_d7_active_rate", Label: "用户7日留", Type: "ratio"},
			{Key: "active_d15_active_rate", Label: "用户15日留", Type: "ratio"},
			{Key: "active_d30_active_rate", Label: "用户30日留", Type: "ratio"},
			{Key: "after_room_d1_active_rate", Label: "进房后次留(活跃)", Type: "ratio", Tooltip: "当天进房后，次日继续登录"},
			{Key: "after_room_d1_room_rate", Label: "进房后次留(进房)", Type: "ratio", Tooltip: "当天进房后，次日继续进房"},
			{Key: "after_mic_d1_active_rate", Label: "上麦后次留(活跃)", Type: "ratio", Tooltip: "当天上麦后，次日继续登录"},
			{Key: "after_mic_d1_mic_rate", Label: "上麦后次留(上麦)", Type: "ratio", Tooltip: "当天上麦后，次日继续上麦"},
			{Key: "after_gift_d1_active_rate", Label: "送礼后次留(活跃)", Type: "ratio", Tooltip: "当天送礼后，次日继续登录"},
			{Key: "after_gift_d1_gift_rate", Label: "送礼后次留(送礼)", Type: "ratio", Tooltip: "当天送礼后，次日继续送礼"},
			{Key: "after_game_d1_active_rate", Label: "游戏次留(活跃)", Type: "ratio", Tooltip: "当天押注后，次日继续登录"},
			{Key: "after_game_d1_game_rate", Label: "游戏次留(游戏)", Type: "ratio", Tooltip: "当天押注后，次日继续押注"},
			{Key: "after_recharge_d1_active_rate", Label: "充值后次留(活跃)", Type: "ratio", Tooltip: "当天充值后，次日继续活跃"},
			{Key: "after_recharge_d1_recharge_rate", Label: "充值后次留(充值)", Type: "ratio", Tooltip: "当天充值后，次日继续充值"},
		}
	case SocialSectionActive:
		return []SocialRequirementColumn{
			{Key: "active_users", Label: "DAU", Type: "count"},
			{Key: "room_join_success_users", Label: "进房成功用户", Type: "count"},
			{Key: "room_join_fail_users", Label: "进房失败用户", Type: "count"},
			{Key: "room_join_success_rate", Label: "进房成功率", Type: "ratio"},
			{Key: "room_join_fail_rate", Label: "进房失败率", Type: "ratio"},
			{Key: "mic_up_users", Label: "上麦用户", Type: "count"},
			{Key: "mic_up_rate", Label: "上麦率", Type: "ratio"},
			{Key: "gift_users", Label: "送礼用户", Type: "count"},
			{Key: "gift_rate", Label: "送礼率", Type: "ratio"},
			{Key: "game_users", Label: "游戏用户", Type: "count"},
			{Key: "game_conversion_rate", Label: "游戏转化率", Type: "ratio"},
			{Key: "chat_users", Label: "发言用户", Type: "count"},
			{Key: "chat_conversion_rate", Label: "发言转化率", Type: "ratio"},
			{Key: "avg_app_stay_min", Label: "人均App停留(分钟)", Type: "number"},
		}
	case SocialSectionGift:
		return []SocialRequirementColumn{
			{Key: "gift_panel_open_users", Label: "礼物面板用户", Type: "count"},
			{Key: "gift_panel_conversion_rate", Label: "礼物面板转化率", Type: "ratio"},
			{Key: "room_gift_users", Label: "送礼用户", Type: "count"},
			{Key: "room_gift_conversion_rate", Label: "房间送礼转化率", Type: "ratio"},
			{Key: "normal_gift_users", Label: "普通礼物用户", Type: "count"},
			{Key: "normal_gift_coin", Label: "普通礼物流水", Type: "coin"},
			{Key: "normal_gift_arppu_coin", Label: "普通礼物ARPPU", Type: "coin"},
			{Key: "lucky_gift_users", Label: "幸运礼物用户", Type: "count"},
			{Key: "lucky_gift_coin", Label: "幸运礼物流水", Type: "coin"},
			{Key: "lucky_gift_payout_coin", Label: "幸运礼物返奖", Type: "coin"},
			{Key: "lucky_gift_rtp", Label: "幸运礼物RTP", Type: "ratio"},
			{Key: "lucky_gift_arppu_coin", Label: "幸运礼物ARPPU", Type: "coin"},
			{Key: "super_lucky_gift_users", Label: "超级幸运用户", Type: "count"},
			{Key: "super_lucky_gift_coin", Label: "超级幸运流水", Type: "coin"},
			{Key: "super_lucky_gift_payout_coin", Label: "超级幸运返奖", Type: "coin"},
			{Key: "super_lucky_gift_rtp", Label: "超级幸运RTP", Type: "ratio"},
			{Key: "super_lucky_gift_arppu_coin", Label: "超级幸运ARPPU", Type: "coin"},
		}
	case SocialSectionGame:
		return []SocialRequirementColumn{
			{Key: "active_users", Label: "DAU", Type: "count"},
			{Key: "room_join_success_users", Label: "进房成功用户", Type: "count"},
			{Key: "self_game_conversion_rate", Label: "自研游戏转化率", Type: "ratio"},
			{Key: "self_game_bet_users", Label: "自研押注用户", Type: "count"},
			{Key: "self_game_bet_count", Label: "自研押注次数", Type: "count"},
			{Key: "self_game_bet_count_per_user", Label: "自研人均押注次数", Type: "number"},
			{Key: "self_game_bet_coin", Label: "自研押注金币", Type: "coin"},
			{Key: "self_game_bet_arppu_coin", Label: "自研押注ARPPU", Type: "coin"},
			{Key: "probability_game_panel_open_users", Label: "概率游戏面板用户", Type: "count"},
			{Key: "probability_game_panel_conversion_rate", Label: "概率游戏面板转化率", Type: "ratio"},
			{Key: "probability_game_bet_users", Label: "概率游戏押注用户", Type: "count"},
			{Key: "probability_game_conversion_rate", Label: "概率游戏转化率", Type: "ratio"},
			{Key: "probability_game_bet_count", Label: "概率游戏押注次数", Type: "count"},
			{Key: "probability_game_bet_count_per_user", Label: "概率游戏人均押注次数", Type: "number"},
			{Key: "probability_game_bet_coin", Label: "概率游戏押注金币", Type: "coin"},
			{Key: "probability_game_bet_arppu_coin", Label: "概率游戏押注ARPPU", Type: "coin"},
		}
	default:
		return []SocialRequirementColumn{
			{Key: "profile_setup_view_users", Label: "填写资料页曝光人数", Type: "count", Tooltip: "进入填写资料页的新用户人数；同一用户或设备同一天重复进入只计一次"},
			{Key: "registered_users", Label: "新增用户", Type: "count", Tooltip: "注册成功的新用户人数：资料完成后产生 UserRegistered 服务端事实并按用户去重；未完成资料、quick_account、game_robot 不计，MQ 重投不重复"},
			{Key: "new_host_users", Label: "新主播用户", Type: "count"},
			{Key: "new_normal_users", Label: "新普通用户", Type: "count"},
			{Key: "registration_success_rate", Label: "注册成功率", Type: "ratio", Tooltip: "新增用户 / 填写资料页曝光人数"},
			{Key: "room_join_success_users", Label: "新用户进房人数", Type: "count", Tooltip: "当天新增用户中，成功拿到房间信息的去重人数"},
			{Key: "room_join_fail_users", Label: "新用户进房失败人数", Type: "count", Tooltip: "当天新增用户中，进房报错且当天始终未成功拿到房间信息的去重人数"},
			{Key: "room_join_success_rate", Label: "新用户进房成功率", Type: "ratio", Tooltip: "新用户进房人数 / 新增用户"},
			{Key: "room_join_fail_rate", Label: "新用户进房失败率", Type: "ratio", Tooltip: "新用户进房失败人数 / 新增用户"},
			{Key: "mic_up_users", Label: "新用户上麦人数", Type: "count", Tooltip: "已成功进房且实际处于麦上 publishing 状态的新用户去重人数"},
			{Key: "mic_up_rate", Label: "新用户上麦率", Type: "ratio", Tooltip: "新用户上麦人数 / 新用户进房人数"},
			{Key: "gift_users", Label: "新用户送礼人数", Type: "count", Tooltip: "已成功进房且成功送出普通、幸运或 Super Lucky 礼物的新用户去重人数"},
			{Key: "gift_rate", Label: "新用户送礼率", Type: "ratio", Tooltip: "新用户送礼人数 / 新用户进房人数"},
			{Key: "recharge_users", Label: "新用户充值人数", Type: "count", Tooltip: "成功完成 Google、第三方或币商充值的新用户去重人数"},
			{Key: "recharge_rate", Label: "新用户充值率", Type: "ratio", Tooltip: "新用户充值人数 / 新增用户"},
			{Key: "game_users", Label: "新用户游戏", Type: "count"},
			{Key: "game_conversion_rate", Label: "新用户游戏转化率", Type: "ratio"},
			{Key: "avg_app_stay_min", Label: "新用户平均停留(分钟)", Type: "number", Tooltip: "当天注册的新用户 App 停留总时长 / 新用户注册成功人数"},
			{Key: "avg_room_stay_min", Label: "新用户平均进房时长(分钟)", Type: "number", Tooltip: "当天注册的新用户房间停留总时长 / 新用户进房成功人数"},
			{Key: "avg_mic_stay_min", Label: "新用户平均上麦时长(分钟)", Type: "number", Tooltip: "当天注册的新用户麦上总时长 / 新用户上麦人数"},
			{Key: "new_user_recharge_usd_minor", Label: "新用户充值金额", Type: "money_minor", Tooltip: "当天注册的新用户全渠道充值金额"},
			{Key: "follow_user_users", Label: "新用户关注行为人数", Type: "count", Tooltip: "当天注册的新用户中发生过关注用户行为的人数"},
			{Key: "follow_room_users", Label: "新用户关注房间人数", Type: "count", Tooltip: "当天注册的新用户中发生过关注房间行为的人数"},
			{Key: "chat_users", Label: "新用户聊天人数", Type: "count", Tooltip: "当天注册的新用户中发送过 IM 消息（房间或私聊）的人数"},
			{Key: "new_host_d1_retention_rate", Label: "新主播D1留存", Type: "ratio"},
			{Key: "new_user_d1_retention_rate", Label: "新普通用户D1留存", Type: "ratio"},
		}
	}
}

func retentionRate(values map[string]retentionMetric, key string) float64 {
	item := values[key]
	return ratio(item.Users, item.Base)
}

func minutesPerUser(durationMS int64, users int64) float64 {
	if users <= 0 {
		return 0
	}
	return float64(durationMS) / float64(users) / 60000
}

func sortedRetentionKeys(values map[string]retentionMetric) []string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func intLabel(value int) string {
	return strconv.Itoa(value)
}

func socialDayRange(startDay string, endDay string) []string {
	start, err := time.Parse("2006-01-02", startDay)
	if err != nil {
		return []string{startDay}
	}
	end, err := time.Parse("2006-01-02", endDay)
	if err != nil || end.Before(start) {
		return []string{startDay}
	}
	days := []string{}
	for current := start; !current.After(end); current = current.AddDate(0, 0, 1) {
		days = append(days, current.Format("2006-01-02"))
	}
	return days
}

type socialGameKey struct {
	Source   string
	Platform string
	GameID   string
}

type socialGameDayData struct {
	Users map[string]map[int64]bool // stat_day -> 押注用户集合
	Coin  map[string]int64          // stat_day -> 押注金币
}

// querySocialGameBreakdown 输出 P5 的按游戏明细：押注用户、同游戏次留、ARPPU、押注金币。
// 自研游戏读 stat_self_game_user_day（用户级、含金额）；厂商/概率游戏用户集读 stat_game_day_players、
// 金额读 stat_game_day_country（该表无用户级金额）。次留需要展示末日的次日数据，成员集多读 1 天。
func (r *Repository) querySocialGameBreakdown(ctx context.Context, app string, statTZ string, days []string, countryID int64, regionID int64, regionIDs []int64) ([]SocialRequirementGame, error) {
	if len(days) == 0 {
		return nil, nil
	}
	startDay := days[0]
	memberEndDay := addStatDays(days[len(days)-1], 1)
	games := map[socialGameKey]*socialGameDayData{}
	ensure := func(key socialGameKey) *socialGameDayData {
		if games[key] == nil {
			games[key] = &socialGameDayData{Users: map[string]map[int64]bool{}, Coin: map[string]int64{}}
		}
		return games[key]
	}
	scopeConditions := func(base []string, args []any) ([]string, []any) {
		if countryID > 0 {
			base = append(base, "country_id = ?")
			args = append(args, countryID)
		}
		if regionID > 0 {
			base = append(base, "region_id = ?")
			args = append(args, regionID)
		} else if ids := normalizePositiveIDs(regionIDs); len(ids) > 0 {
			placeholders := make([]string, 0, len(ids))
			for _, id := range ids {
				placeholders = append(placeholders, "?")
				args = append(args, id)
			}
			base = append(base, "region_id IN ("+strings.Join(placeholders, ",")+")")
		}
		return base, args
	}

	selfConditions, selfArgs := scopeConditions(
		[]string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?", "match_count > 0"},
		[]any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, memberEndDay},
	)
	selfRows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), game_id, user_id, stake_coin
		FROM stat_self_game_user_day
		WHERE `+strings.Join(selfConditions, " AND "), selfArgs...)
	if err != nil {
		return nil, err
	}
	defer selfRows.Close()
	for selfRows.Next() {
		var day, gameID string
		var userID, stakeCoin int64
		if err := selfRows.Scan(&day, &gameID, &userID, &stakeCoin); err != nil {
			return nil, err
		}
		data := ensure(socialGameKey{Source: "self", GameID: gameID})
		if data.Users[day] == nil {
			data.Users[day] = map[int64]bool{}
		}
		data.Users[day][userID] = true
		data.Coin[day] += stakeCoin
	}
	if err := selfRows.Err(); err != nil {
		return nil, err
	}

	vendorConditions, vendorArgs := scopeConditions(
		[]string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?"},
		[]any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, memberEndDay},
	)
	vendorRows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), platform_code, game_id, user_id
		FROM stat_game_day_players
		WHERE `+strings.Join(vendorConditions, " AND "), vendorArgs...)
	if err != nil {
		return nil, err
	}
	defer vendorRows.Close()
	for vendorRows.Next() {
		var day, platform, gameID string
		var userID int64
		if err := vendorRows.Scan(&day, &platform, &gameID, &userID); err != nil {
			return nil, err
		}
		data := ensure(socialGameKey{Source: "vendor", Platform: platform, GameID: gameID})
		if data.Users[day] == nil {
			data.Users[day] = map[int64]bool{}
		}
		data.Users[day][userID] = true
	}
	if err := vendorRows.Err(); err != nil {
		return nil, err
	}

	turnoverRows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), platform_code, game_id, SUM(turnover_coin)
		FROM stat_game_day_country
		WHERE `+strings.Join(vendorConditions, " AND ")+`
		GROUP BY stat_day, platform_code, game_id`, vendorArgs...)
	if err != nil {
		return nil, err
	}
	defer turnoverRows.Close()
	for turnoverRows.Next() {
		var day, platform, gameID string
		var turnover int64
		if err := turnoverRows.Scan(&day, &platform, &gameID, &turnover); err != nil {
			return nil, err
		}
		key := socialGameKey{Source: "vendor", Platform: platform, GameID: gameID}
		if games[key] == nil {
			// 只有金额没有用户投影的历史脏数据不单独成行，避免出现"有流水无用户"的幽灵游戏。
			continue
		}
		games[key].Coin[day] += turnover
	}
	if err := turnoverRows.Err(); err != nil {
		return nil, err
	}

	out := make([]SocialRequirementGame, 0, len(games))
	for key, data := range games {
		daily := make([]SocialRequirementRow, 0, len(days))
		var totalUsers, totalCoin int64
		totalRetention := retentionMetric{}
		for _, day := range days {
			users := int64(len(data.Users[day]))
			coin := data.Coin[day]
			retention := retentionMetric{Base: users}
			nextDay := data.Users[addStatDays(day, 1)]
			for userID := range data.Users[day] {
				if nextDay[userID] {
					retention.Users++
				}
			}
			totalUsers += users
			totalCoin += coin
			totalRetention.Base += retention.Base
			totalRetention.Users += retention.Users
			daily = append(daily, SocialRequirementRow{
				StatDay: day,
				Label:   day,
				Metrics: socialGameRowMetrics(users, coin, retention),
			})
		}
		if totalUsers == 0 && totalCoin == 0 {
			continue
		}
		keyParts := []string{key.Source}
		if strings.TrimSpace(key.Platform) != "" {
			keyParts = append(keyParts, strings.TrimSpace(key.Platform))
		}
		keyParts = append(keyParts, strings.TrimSpace(key.GameID))
		out = append(out, SocialRequirementGame{
			GameKey:     strings.Join(keyParts, ":"),
			GameSource:  key.Source,
			GameName:    socialGameDisplayName(key),
			Total:       SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialGameRowMetrics(totalUsers, totalCoin, totalRetention)},
			DailySeries: daily,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		left := out[i].Total.Metrics["game_bet_coin"].(int64)
		right := out[j].Total.Metrics["game_bet_coin"].(int64)
		if left != right {
			return left > right
		}
		return out[i].GameName < out[j].GameName
	})
	return out, nil
}

func socialGameRowMetrics(users int64, coin int64, retention retentionMetric) map[string]any {
	return map[string]any{
		"game_bet_users":               users,
		"game_bet_coin":                coin,
		"game_arppu_coin":              div(coin, users),
		"game_d1_retention_base_users": retention.Base,
		"game_d1_retention_users":      retention.Users,
		"game_d1_retention_rate":       ratio(retention.Users, retention.Base),
	}
}

func socialGameDisplayName(key socialGameKey) string {
	name := strings.TrimSpace(key.GameID)
	if name == "" {
		name = "unknown"
	}
	if key.Source == "vendor" && strings.TrimSpace(key.Platform) != "" {
		return name + " (" + strings.TrimSpace(key.Platform) + ")"
	}
	return name
}

func socialGameBreakdownColumns() []SocialRequirementColumn {
	return []SocialRequirementColumn{
		{Key: "game_bet_users", Label: "押注用户数", Type: "count", Tooltip: "当日押注过该游戏的去重用户数"},
		{Key: "game_d1_retention_rate", Label: "游戏次留", Type: "ratio", Tooltip: "当日押注该游戏的用户中，次日继续押注同一游戏的比例"},
		{Key: "game_arppu_coin", Label: "ARPPU(金币)", Type: "coin", Tooltip: "该游戏押注金币 / 押注用户数"},
		{Key: "game_bet_coin", Label: "押注金币", Type: "coin"},
	}
}

func normalizePositiveIDs(values []int64) []int64 {
	seen := map[int64]bool{}
	out := []int64{}
	for _, value := range values {
		if value <= 0 || seen[value] {
			continue
		}
		seen[value] = true
		out = append(out, value)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}
