package main

import (
	"context"
	"database/sql"
	"flag"
	"fmt"
	"log"
	"os"
	"sort"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
)

type countryRegion struct {
	countryID int64
	regionID  int64
}

type appDayKey struct {
	day       string
	countryID int64
	regionID  int64
}

type appDayAgg struct {
	newUsers                   int64
	activeUsers                int64
	paidUsers                  int64
	rechargeUSDMinor           int64
	newUserRechargeUSDMinor    int64
	coinSellerRechargeUSDMinor int64
	coinSellerStockCoin        int64
	mifaPayRechargeUSDMinor    int64
	googleRechargeUSDMinor     int64
	coinSellerTransferCoin     int64
	coinTotal                  int64
	consumedCoin               int64
	outputCoin                 int64
	platformGrantCoin          int64
	manualGrantCoin            int64
	salaryUSDMinor             int64
	salaryTransferCoin         int64
	micOnlineMS                int64
	micOnlineUsers             int64
}

type userRegistrationRow struct {
	userID         int64
	registeredDay  string
	dim            countryRegion
	registeredAtMS int64
}

type payerRow struct {
	day     string
	userID  int64
	dim     countryRegion
	firstMS int64
}

type activeRow struct {
	day     string
	userID  int64
	dim     countryRegion
	firstMS int64
}

type micRow struct {
	day         string
	userID      int64
	dim         countryRegion
	micOnlineMS int64
}

type rebuildState struct {
	appDays             map[appDayKey]*appDayAgg
	registrations       []userRegistrationRow
	registeredByUserDay map[string]struct{}
	payersByKey         map[string]payerRow
	activeByKey         map[string]activeRow
	micByKey            map[string]micRow
	userIDs             map[int64]struct{}
	userDims            map[int64]countryRegion
	knownUsers          map[int64]struct{}
}

func main() {
	ctx := context.Background()
	var (
		appFlag       = flag.String("app", envDefault("APP_CODE", "lalu"), "app_code to backfill")
		tzFlag        = flag.String("timezone", envDefault("BACKFILL_TIMEZONE", "UTC"), "day boundary timezone, e.g. UTC or Asia/Shanghai")
		startFlag     = flag.String("start-day", os.Getenv("START_DAY"), "inclusive yyyy-mm-dd in timezone; defaults to last 7 days")
		endFlag       = flag.String("end-day", os.Getenv("END_DAY"), "inclusive yyyy-mm-dd in timezone; defaults to today")
		statsDSNFlag  = flag.String("statistics-dsn", firstNonEmpty(os.Getenv("STATISTICS_MYSQL_DSN"), os.Getenv("MYSQL_DSN")), "statistics mysql dsn")
		userDSNFlag   = flag.String("user-dsn", os.Getenv("USER_MYSQL_DSN"), "user-service mysql dsn")
		walletDSNFlag = flag.String("wallet-dsn", os.Getenv("WALLET_MYSQL_DSN"), "wallet-service mysql dsn")
		applyFlag     = flag.Bool("apply", envBool("APPLY"), "apply replacement; default dry-run")
	)
	flag.Parse()

	if strings.TrimSpace(*statsDSNFlag) == "" || strings.TrimSpace(*userDSNFlag) == "" || strings.TrimSpace(*walletDSNFlag) == "" {
		log.Fatal("statistics-dsn, user-dsn and wallet-dsn are required")
	}
	loc, err := time.LoadLocation(strings.TrimSpace(*tzFlag))
	if err != nil {
		log.Fatalf("load timezone: %v", err)
	}
	statTZ, err := normalizeBackfillStatTZ(loc.String())
	if err != nil {
		log.Fatal(err)
	}
	startDay, endDay, windowStartMS, windowEndMS, err := resolveWindow(*startFlag, *endFlag, loc)
	if err != nil {
		log.Fatal(err)
	}
	app := appcode.Normalize(*appFlag)

	statsDB := mustOpen(ctx, *statsDSNFlag)
	defer statsDB.Close()
	userDB := mustOpen(ctx, *userDSNFlag)
	defer userDB.Close()
	walletDB := mustOpen(ctx, *walletDSNFlag)
	defer walletDB.Close()

	state := &rebuildState{
		appDays:             map[appDayKey]*appDayAgg{},
		registeredByUserDay: map[string]struct{}{},
		payersByKey:         map[string]payerRow{},
		activeByKey:         map[string]activeRow{},
		micByKey:            map[string]micRow{},
		userIDs:             map[int64]struct{}{},
		userDims:            map[int64]countryRegion{},
		knownUsers:          map[int64]struct{}{},
	}
	// 第一阶段只收集窗口内会参与重算的 user_id，后续统一批量加载用户国家和区域，避免按充值/活跃记录逐条访问 user-service 数据库。
	if err := collectRegistrationUsers(ctx, userDB, state, app, windowStartMS, windowEndMS); err != nil {
		log.Fatalf("collect registrations: %v", err)
	}
	if err := collectActiveUserIDs(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil {
		log.Fatalf("collect active ids: %v", err)
	}
	if err := collectRechargeUserIDs(ctx, walletDB, state, app, windowStartMS, windowEndMS); err != nil {
		log.Fatalf("collect recharge ids: %v", err)
	}
	if err := collectReportMetricUserIDs(ctx, walletDB, userDB, state, app, startDay, endDay, windowEndMS); err != nil {
		log.Fatalf("collect report metric ids: %v", err)
	}
	if err := loadUserDims(ctx, userDB, state, app); err != nil {
		log.Fatalf("load user dims: %v", err)
	}
	if err := rebuildRegistrations(ctx, userDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
		log.Fatalf("rebuild registrations: %v", err)
	}
	if err := rebuildActive(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil {
		log.Fatalf("rebuild active: %v", err)
	}
	if err := rebuildWalletRecharge(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
		log.Fatalf("rebuild wallet recharge: %v", err)
	}
	if err := rebuildCoinSellerStock(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
		log.Fatalf("rebuild coin seller stock: %v", err)
	}
	if err := rebuildWalletReportMetrics(ctx, walletDB, state, app, startDay, endDay, windowStartMS, windowEndMS, loc); err != nil {
		log.Fatalf("rebuild wallet report metrics: %v", err)
	}
	if err := rebuildAppDayCoinMetricsFromStatistics(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil {
		log.Fatalf("rebuild app day coin metrics from statistics: %v", err)
	}
	if err := rebuildSalaryTotals(ctx, walletDB, state, app, startDay, endDay, windowStartMS, windowEndMS, loc); err != nil {
		log.Fatalf("rebuild salary totals: %v", err)
	}
	if err := rebuildMicStats(ctx, userDB, state, app, startDay, endDay); err != nil {
		log.Fatalf("rebuild mic stats: %v", err)
	}

	if err := stageAndMaybeApply(ctx, statsDB, state, app, statTZ, startDay, endDay, *applyFlag); err != nil {
		log.Fatalf("stage/apply: %v", err)
	}
	mode := "dry-run"
	if *applyFlag {
		mode = "applied"
	}
	fmt.Printf("%s app=%s timezone=%s window=%s..%s app_day_rows=%d registrations=%d active=%d payers=%d\n",
		mode, app, statTZ, startDay, endDay, len(state.appDays), len(state.registrations), len(state.activeByKey), len(state.payersByKey))
}

func normalizeBackfillStatTZ(value string) (string, error) {
	switch strings.TrimSpace(value) {
	case "UTC":
		return "UTC", nil
	case "Asia/Shanghai":
		return "Asia/Shanghai", nil
	default:
		return "", fmt.Errorf("unsupported stat_tz %q; only UTC and Asia/Shanghai are queryable", value)
	}
}

func envDefault(key string, fallback string) string {
	if value := strings.TrimSpace(os.Getenv(key)); value != "" {
		return value
	}
	return fallback
}

func envBool(key string) bool {
	switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
	case "1", "true", "yes", "y":
		return true
	default:
		return false
	}
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

func mustOpen(ctx context.Context, dsn string) *sql.DB {
	db, err := sql.Open("mysql", strings.TrimSpace(dsn))
	if err != nil {
		log.Fatal(err)
	}
	if err := db.PingContext(ctx); err != nil {
		log.Fatal(err)
	}
	return db
}

func resolveWindow(rawStart, rawEnd string, loc *time.Location) (string, string, int64, int64, error) {
	now := time.Now().In(loc)
	endDayTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
	startDayTime := endDayTime.AddDate(0, 0, -6)
	var err error
	if strings.TrimSpace(rawEnd) != "" {
		endDayTime, err = parseDay(rawEnd, loc)
		if err != nil {
			return "", "", 0, 0, err
		}
	}
	if strings.TrimSpace(rawStart) != "" {
		startDayTime, err = parseDay(rawStart, loc)
		if err != nil {
			return "", "", 0, 0, err
		}
	}
	if startDayTime.After(endDayTime) {
		return "", "", 0, 0, fmt.Errorf("start-day must not be after end-day")
	}
	windowEnd := endDayTime.AddDate(0, 0, 1)
	return formatDay(startDayTime), formatDay(endDayTime), startDayTime.UTC().UnixMilli(), windowEnd.UTC().UnixMilli(), nil
}

func parseDay(value string, loc *time.Location) (time.Time, error) {
	parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), loc)
	if err != nil {
		return time.Time{}, err
	}
	return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc), nil
}

func dayFromMS(ms int64, loc *time.Location) string {
	return formatDay(time.UnixMilli(ms).In(loc))
}

func formatDay(value time.Time) string {
	return value.Format("2006-01-02")
}

func collectRegistrationUsers(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64) error {
	// 全站机器人和后台快捷账号使用 users 资料参与运营、联调或游戏展示，但不是 App 自然注册用户；补数入口必须和实时 UserRegistered 口径一致排除。
	rows, err := db.QueryContext(ctx, `
		SELECT u.user_id
		FROM users u
		WHERE u.app_code = ?
		  AND u.profile_completed = 1
		  AND COALESCE(NULLIF(u.profile_completed_at_ms, 0), u.created_at_ms) >= ?
		  AND COALESCE(NULLIF(u.profile_completed_at_ms, 0), u.created_at_ms) < ?
		  AND LOWER(COALESCE(u.source, '')) NOT IN ('game_robot', 'quick_account')`,
		app, startMS, endMS)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		if err := rows.Scan(&userID); err != nil {
			return err
		}
		state.userIDs[userID] = struct{}{}
	}
	return rows.Err()
}

func collectActiveUserIDs(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string) error {
	rows, err := db.QueryContext(ctx, `
		SELECT DISTINCT user_id
		FROM stat_user_day_activity
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
		app, statTZ, startDay, endDay)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		if err := rows.Scan(&userID); err != nil {
			return err
		}
		state.userIDs[userID] = struct{}{}
	}
	return rows.Err()
}

func collectRechargeUserIDs(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64) error {
	for _, query := range []string{
		`SELECT DISTINCT user_id FROM wallet_recharge_records WHERE app_code = ? AND created_at_ms >= ? AND created_at_ms < ?`,
		`SELECT DISTINCT seller_user_id FROM coin_seller_stock_records WHERE app_code = ? AND counts_as_seller_recharge = 1 AND created_at_ms >= ? AND created_at_ms < ?`,
	} {
		rows, err := db.QueryContext(ctx, query, app, startMS, endMS)
		if err != nil {
			return err
		}
		for rows.Next() {
			var userID int64
			if err := rows.Scan(&userID); err != nil {
				rows.Close()
				return err
			}
			state.userIDs[userID] = struct{}{}
		}
		if err := rows.Close(); err != nil {
			return err
		}
	}
	return nil
}

func collectReportMetricUserIDs(ctx context.Context, walletDB *sql.DB, userDB *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64) error {
	queries := []struct {
		db    *sql.DB
		query string
		args  []any
	}{
		{
			db: walletDB,
			// 历史持有人集合从 wallet_accounts 取：账户行在首笔流水时创建且从不删除，
			// 与「wallet_entries 中出现过该资产」等价（提前开户的账户是无害超集，只影响维度预取）。
			// 千万级 wallet_entries 上按资产全量 DISTINCT 扫描曾是线上 MySQL 每 10 分钟的 CPU 尖峰主因。
			query: `
				SELECT user_id
				FROM wallet_accounts
				WHERE app_code = ? AND asset_type IN ('COIN', 'COIN_SELLER_COIN', 'HOST_SALARY_USD', 'AGENCY_SALARY_USD') AND created_at_ms < ?
				UNION
				SELECT e.counterparty_user_id
				FROM wallet_entries e
				JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
				WHERE e.app_code = ? AND wt.biz_type = 'salary_transfer_to_coin_seller'
				  AND e.asset_type = 'COIN_SELLER_COIN' AND e.counterparty_user_id > 0 AND e.created_at_ms < ?`,
			args: []any{app, windowEndMS, app, windowEndMS},
		},
		{
			db: walletDB,
			query: `
				SELECT DISTINCT user_id
				FROM wallet_accounts
				WHERE app_code = ? AND asset_type IN ('COIN', 'HOST_SALARY_USD', 'AGENCY_SALARY_USD') AND available_amount + frozen_amount <> 0`,
			args: []any{app},
		},
		{
			db: userDB,
			query: `
				SELECT DISTINCT user_id
				FROM user_mic_daily_stats
				WHERE app_code = ? AND stat_date BETWEEN ? AND ?`,
			args: []any{app, startDay, endDay},
		},
	}
	for _, item := range queries {
		rows, err := item.db.QueryContext(ctx, item.query, item.args...)
		if err != nil {
			return err
		}
		for rows.Next() {
			var userID int64
			if err := rows.Scan(&userID); err != nil {
				rows.Close()
				return err
			}
			if userID > 0 {
				state.userIDs[userID] = struct{}{}
			}
		}
		if err := rows.Close(); err != nil {
			return err
		}
	}
	return nil
}

func loadUserDims(ctx context.Context, db *sql.DB, state *rebuildState, app string) error {
	userIDs := make([]int64, 0, len(state.userIDs))
	for userID := range state.userIDs {
		userIDs = append(userIDs, userID)
	}
	sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
	for start := 0; start < len(userIDs); start += 500 {
		end := start + 500
		if end > len(userIDs) {
			end = len(userIDs)
		}
		placeholders := strings.TrimRight(strings.Repeat("?,", end-start), ",")
		args := []any{app}
		for _, userID := range userIDs[start:end] {
			args = append(args, userID)
		}
		rows, err := db.QueryContext(ctx, `
			SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0)
			FROM users u
			LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
			WHERE u.app_code = ? AND u.user_id IN (`+placeholders+`)`, args...)
		if err != nil {
			return err
		}
		for rows.Next() {
			var userID int64
			var dim countryRegion
			if err := rows.Scan(&userID, &dim.countryID, &dim.regionID); err != nil {
				rows.Close()
				return err
			}
			state.userDims[userID] = dim
			state.knownUsers[userID] = struct{}{}
		}
		if err := rows.Close(); err != nil {
			return err
		}
	}
	return nil
}

func rebuildRegistrations(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
	// 注册国家以资料完成后的 users.country 映射为准；region_id 只作为独立维度写入，不能再兜底成国家。
	// source=game_robot 是后台全站机器人池账号，source=quick_account 是后台快捷建号；两者都不是 App 自然注册，不能进入新增用户、注册 cohort 或留存口径。
	rows, err := db.QueryContext(ctx, `
		SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0),
		       COALESCE(NULLIF(u.profile_completed_at_ms, 0), u.created_at_ms) AS registered_at_ms
		FROM users u
		LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
		WHERE u.app_code = ?
		  AND u.profile_completed = 1
		  AND COALESCE(NULLIF(u.profile_completed_at_ms, 0), u.created_at_ms) >= ?
		  AND COALESCE(NULLIF(u.profile_completed_at_ms, 0), u.created_at_ms) < ?
		  AND LOWER(COALESCE(u.source, '')) NOT IN ('game_robot', 'quick_account')`,
		app, startMS, endMS)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var row userRegistrationRow
		if err := rows.Scan(&row.userID, &row.dim.countryID, &row.dim.regionID, &row.registeredAtMS); err != nil {
			return err
		}
		row.registeredDay = dayFromMS(row.registeredAtMS, loc)
		state.registrations = append(state.registrations, row)
		state.registeredByUserDay[userDayKey(row.registeredDay, row.userID)] = struct{}{}
		agg := state.ensureAgg(row.registeredDay, row.dim)
		agg.newUsers++
		// 产品口径要求完成注册即算当日活跃；补数时直接把注册事实写入活跃重建集合，
		// 后续房间/游戏活跃若命中同一用户同一天，只会更新更早的 first_active_at_ms，不会重复计数。
		state.addActive(row.registeredDay, row.userID, row.dim, row.registeredAtMS)
	}
	return rows.Err()
}

func rebuildActive(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string) error {
	// 房间/游戏活跃已经被实时消费进对应 stat_tz 的 stat_user_day_activity；回填只读取目标时区，避免用 UTC 活跃去污染中国自然日。
	rows, err := db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), user_id, MIN(first_active_at_ms)
		FROM stat_user_day_activity
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?
		GROUP BY stat_day, user_id`,
		app, statTZ, startDay, endDay)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var day string
		var userID, firstMS int64
		if err := rows.Scan(&day, &userID, &firstMS); err != nil {
			return err
		}
		if _, ok := state.knownUsers[userID]; !ok {
			// stat_user_day_activity 是历史活跃读模型，不是用户事实 owner；回填时必须丢弃 user-service 已不存在的孤儿活跃，避免脏事实继续汇总到 country_id=0。
			continue
		}
		state.addActive(day, userID, state.userDims[userID], firstMS)
	}
	return rows.Err()
}

func rebuildWalletRecharge(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
	// wallet_recharge_records 是到账事实表；Google 也会写入该表，payment_orders 只是 provider 审计，不能在回填里再扫一次导致双算。
	// 币商向用户转账只代表金币充值用户，不产生用户 USDT 充值；Google Play 则按记录里的 usd_minor_amount 计入用户充值。
	rows, err := db.QueryContext(ctx, `
		SELECT rr.user_id, rr.recharge_sequence, rr.coin_amount, rr.usd_minor_amount, rr.created_at_ms, wt.biz_type
		FROM wallet_recharge_records rr
		JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
		WHERE rr.app_code = ? AND rr.created_at_ms >= ? AND rr.created_at_ms < ?`,
		app, startMS, endMS)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var userID, sequence, coinAmount, usdMinor, createdAtMS int64
		var bizType string
		if err := rows.Scan(&userID, &sequence, &coinAmount, &usdMinor, &createdAtMS, &bizType); err != nil {
			return err
		}
		day := dayFromMS(createdAtMS, loc)
		dim := state.userDims[userID]
		state.addPayer(day, userID, dim, createdAtMS)
		agg := state.ensureAgg(day, dim)
		switch strings.ToLower(strings.TrimSpace(bizType)) {
		case "coin_seller_transfer":
			agg.coinSellerTransferCoin += coinAmount
		case "google_play_recharge":
			agg.rechargeUSDMinor += usdMinor
			agg.googleRechargeUSDMinor += usdMinor
		case "mifapay", "v5pay":
			agg.rechargeUSDMinor += usdMinor
			agg.mifaPayRechargeUSDMinor += usdMinor
		default:
			if usdMinor > 0 {
				agg.rechargeUSDMinor += usdMinor
			}
		}
		if sequence == 1 && usdMinor > 0 {
			agg.newUserRechargeUSDMinor += usdMinor
		}
	}
	return rows.Err()
}

func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
	// 币商进货是平台收到的 USDT 充值，按 seller 的国家/区域进入总充值，但不写入用户金币转账金额。
	rows, err := db.QueryContext(ctx, `
		SELECT seller_user_id, paid_amount_micro, coin_amount, created_at_ms
		FROM coin_seller_stock_records
		WHERE app_code = ? AND counts_as_seller_recharge = 1 AND (paid_amount_micro <> 0 OR coin_amount <> 0) AND created_at_ms >= ? AND created_at_ms < ?`,
		app, startMS, endMS)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var sellerUserID, amountMicro, coinAmount, createdAtMS int64
		if err := rows.Scan(&sellerUserID, &amountMicro, &coinAmount, &createdAtMS); err != nil {
			return err
		}
		usdMinor := amountMicro / 10_000
		day := dayFromMS(createdAtMS, loc)
		dim := state.userDims[sellerUserID]
		agg := state.ensureAgg(day, dim)
		agg.rechargeUSDMinor += usdMinor
		agg.coinSellerRechargeUSDMinor += usdMinor
		agg.coinSellerStockCoin += coinAmount
	}
	return rows.Err()
}

func rebuildWalletReportMetrics(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, startMS int64, endMS int64, loc *time.Location) error {
	if err := rebuildCoinTotals(ctx, db, state, app, startDay, endDay, startMS, endMS, loc); err != nil {
		return err
	}
	dayEnds, err := dayEndPoints(startDay, endDay, loc)
	if err != nil {
		return err
	}
	// 按天分段扫描：每条分录的归类只依赖自身，半开区间拼接后与整窗单条大查询的结果完全一致，
	// 但避免了一条 30 秒级慢 SQL 长时间持有 MVCC 读视图。
	chunkStartMS := startMS
	for _, point := range dayEnds {
		if err := rebuildWalletReportMetricsChunk(ctx, db, state, app, chunkStartMS, point.endMS, loc); err != nil {
			return err
		}
		chunkStartMS = point.endMS
	}
	return nil
}

type walletReportEntryRow struct {
	userID             int64
	counterpartyUserID int64
	assetType          string
	amountDelta        int64
	transactionID      string
	createdAtMS        int64
}

func rebuildWalletReportMetricsChunk(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS int64, endMS int64, loc *time.Location) error {
	// 先只扫 wallet_entries（命中 idx_wallet_entries_asset_time），再按去重后的 transaction_id 批量取交易信息；
	// 复式记账下两条分录共享一笔交易，去重能省一半 wallet_transactions 随机回表。
	rows, err := db.QueryContext(ctx, `
		SELECT e.user_id, e.counterparty_user_id, e.asset_type, e.available_delta + e.frozen_delta AS amount_delta,
		       e.transaction_id, e.created_at_ms
		FROM wallet_entries e
		WHERE e.app_code = ? AND e.asset_type IN ('COIN', 'COIN_SELLER_COIN') AND e.created_at_ms >= ? AND e.created_at_ms < ?`,
		app, startMS, endMS)
	if err != nil {
		return err
	}
	entries := []walletReportEntryRow{}
	transactionIDs := map[string]struct{}{}
	for rows.Next() {
		var entry walletReportEntryRow
		if err := rows.Scan(&entry.userID, &entry.counterpartyUserID, &entry.assetType, &entry.amountDelta, &entry.transactionID, &entry.createdAtMS); err != nil {
			rows.Close()
			return err
		}
		entries = append(entries, entry)
		transactionIDs[entry.transactionID] = struct{}{}
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	transactions, err := loadWalletReportTransactions(ctx, db, app, transactionIDs)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		tx, ok := transactions[entry.transactionID]
		if !ok {
			// 原实现是 INNER JOIN：缺交易主行的分录同样不参与口径。
			continue
		}
		day := dayFromMS(entry.createdAtMS, loc)
		normalizedBiz := strings.ToLower(strings.TrimSpace(tx.bizType))
		normalizedAsset := strings.ToUpper(strings.TrimSpace(entry.assetType))
		if entry.amountDelta > 0 && normalizedBiz == "salary_transfer_to_coin_seller" && normalizedAsset == "COIN_SELLER_COIN" {
			dimensionUserID := entry.userID
			if entry.counterpartyUserID > 0 {
				dimensionUserID = entry.counterpartyUserID
			}
			agg := state.ensureAgg(day, state.userDims[dimensionUserID])
			agg.salaryTransferCoin += entry.amountDelta
			continue
		}
		if normalizedAsset != "COIN" {
			continue
		}
		dim := state.userDims[entry.userID]
		agg := state.ensureAgg(day, dim)
		switch {
		case entry.amountDelta < 0 && isConsumedCoinBizType(normalizedBiz):
			agg.consumedCoin += -entry.amountDelta
		case entry.amountDelta > 0 && isOutputCoinBizType(normalizedBiz):
			agg.outputCoin += entry.amountDelta
		case entry.amountDelta > 0 && normalizedBiz == "manual_credit":
			agg.manualGrantCoin += entry.amountDelta
		case entry.amountDelta > 0 && normalizedBiz == "resource_grant" && isPlatformGrantCommandID(tx.commandID):
			agg.platformGrantCoin += entry.amountDelta
		case entry.amountDelta > 0 && isPlatformGrantBizType(normalizedBiz):
			agg.platformGrantCoin += entry.amountDelta
		}
	}
	return nil
}

type walletReportTransaction struct {
	bizType   string
	commandID string
}

func loadWalletReportTransactions(ctx context.Context, db *sql.DB, app string, transactionIDs map[string]struct{}) (map[string]walletReportTransaction, error) {
	ids := make([]string, 0, len(transactionIDs))
	for id := range transactionIDs {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	result := make(map[string]walletReportTransaction, len(ids))
	for start := 0; start < len(ids); start += 500 {
		end := start + 500
		if end > len(ids) {
			end = len(ids)
		}
		placeholders := strings.TrimRight(strings.Repeat("?,", end-start), ",")
		args := []any{app}
		for _, id := range ids[start:end] {
			args = append(args, id)
		}
		// command_id 只在 resource_grant 的平台发放判定里使用，其余业务类型直接取主表列，
		// 避免为每笔交易物化并解析 metadata_json。TRIM 对齐 Go 侧 TrimSpace 归一化，
		// 保证 SQL 判定和消费方对 biz_type 是否为 resource_grant 的结论一致。
		rows, err := db.QueryContext(ctx, `
			SELECT transaction_id, biz_type,
			       CASE WHEN TRIM(biz_type) = 'resource_grant'
			            THEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.command_id')), command_id)
			            ELSE command_id END
			FROM wallet_transactions
			WHERE app_code = ? AND transaction_id IN (`+placeholders+`)`, args...)
		if err != nil {
			return nil, err
		}
		for rows.Next() {
			var id string
			var tx walletReportTransaction
			if err := rows.Scan(&id, &tx.bizType, &tx.commandID); err != nil {
				rows.Close()
				return nil, err
			}
			result[id] = tx
		}
		if err := rows.Err(); err != nil {
			rows.Close()
			return nil, err
		}
		if err := rows.Close(); err != nil {
			return nil, err
		}
	}
	return result, nil
}

func rebuildAppDayCoinMetricsFromStatistics(ctx context.Context, db *sql.DB, state *rebuildState, app string, statTZ string, startDay string, endDay string) error {
	rows, err := db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, region_id,
		       COALESCE(gift_coin_spent, 0), COALESCE(game_turnover, 0), COALESCE(game_payout, 0)
		FROM stat_app_day_country
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
		app, statTZ, startDay, endDay)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var day string
		var dim countryRegion
		var giftSpent, gameTurnover, gamePayout int64
		if err := rows.Scan(&day, &dim.countryID, &dim.regionID, &giftSpent, &gameTurnover, &gamePayout); err != nil {
			return err
		}
		agg := state.ensureAgg(day, dim)
		// 本补数脚本不连接 room/game owner 原始库；礼物和游戏事实已经由统计服务实时聚合到日表，
		// 因此只复用这些 owner 事实读模型来重建总消耗/产出，避免重跑钱包补数时把礼物和游戏口径清零。
		agg.consumedCoin += giftSpent + gameTurnover
		agg.outputCoin += gamePayout
	}
	return rows.Err()
}

type walletBalanceEntry struct {
	userID      int64
	assetType   string
	balance     int64
	createdAtMS int64
}

func rebuildCoinTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowStartMS int64, windowEndMS int64, loc *time.Location) error {
	return rebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, windowStartMS, windowEndMS, loc, []string{"COIN"}, func(agg *appDayAgg, balance int64) {
		agg.coinTotal += balance
	})
}

func rebuildSalaryTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowStartMS int64, windowEndMS int64, loc *time.Location) error {
	for _, assetType := range []string{"HOST_SALARY_USD", "AGENCY_SALARY_USD"} {
		// 每个资产单独回放，可以命中 wallet_entries 的 asset/user/time 索引顺序；
		// 如果用户同时有多种工资钱包，也会按资产最新余额分别累计，不会因为跨资产排序打散同一用户状态。
		if err := rebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, windowStartMS, windowEndMS, loc, []string{assetType}, func(agg *appDayAgg, balance int64) {
			agg.salaryUSDMinor += balance
		}); err != nil {
			return err
		}
	}
	return nil
}

type walletOpeningKey struct {
	userID    int64
	assetType string
}

type walletOpeningRow struct {
	balance     int64
	createdAtMS int64
	entryID     int64
}

// loadWalletOpeningBalances 取每个用户每种资产在 beforeMS 之前的最后一条账后余额（期初快照）。
// 分组极值可以命中 idx_wallet_entries_asset_user_time；「最后一条」按 (created_at_ms, entry_id)
// 双键在 Go 侧判定，与全量回放 ORDER BY created_at_ms, entry_id 的口径一致，且不依赖列排序规则。
// 返回扁平 map 而不是按用户嵌套：全量 COIN 用户的期初快照会常驻内存，嵌套 map 的每用户开销要大数倍。
func loadWalletOpeningBalances(ctx context.Context, db *sql.DB, app string, assetTypes []string, beforeMS int64) (map[walletOpeningKey]walletOpeningRow, error) {
	opening := map[walletOpeningKey]walletOpeningRow{}
	if len(assetTypes) == 0 {
		return opening, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",")
	assetArgs := make([]any, 0, len(assetTypes))
	for _, assetType := range assetTypes {
		assetArgs = append(assetArgs, strings.ToUpper(strings.TrimSpace(assetType)))
	}
	args := []any{app}
	args = append(args, assetArgs...)
	args = append(args, beforeMS, app)
	args = append(args, assetArgs...)
	args = append(args, beforeMS)
	rows, err := db.QueryContext(ctx, `
		SELECT e.user_id, e.asset_type, e.available_after + e.frozen_after AS balance_after, e.created_at_ms, e.entry_id
		FROM wallet_entries e
		JOIN (
			SELECT user_id, asset_type, MAX(created_at_ms) AS last_ms
			FROM wallet_entries
			WHERE app_code = ? AND asset_type IN (`+placeholders+`) AND created_at_ms < ?
			GROUP BY user_id, asset_type
		) last ON last.user_id = e.user_id AND last.asset_type = e.asset_type AND last.last_ms = e.created_at_ms
		WHERE e.app_code = ? AND e.asset_type IN (`+placeholders+`) AND e.created_at_ms < ?`,
		args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		var assetType string
		var candidate walletOpeningRow
		if err := rows.Scan(&userID, &assetType, &candidate.balance, &candidate.createdAtMS, &candidate.entryID); err != nil {
			return nil, err
		}
		key := walletOpeningKey{userID: userID, assetType: strings.ToUpper(strings.TrimSpace(assetType))}
		if best, ok := opening[key]; ok &&
			(best.createdAtMS > candidate.createdAtMS ||
				(best.createdAtMS == candidate.createdAtMS && best.entryID >= candidate.entryID)) {
			continue
		}
		opening[key] = candidate
	}
	return opening, rows.Err()
}

func rebuildWalletBalanceTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowStartMS int64, windowEndMS int64, loc *time.Location, assetTypes []string, applyBalance func(*appDayAgg, int64)) error {
	dayEnds, err := dayEndPoints(startDay, endDay, loc)
	if err != nil {
		return err
	}
	if len(assetTypes) == 0 {
		return nil
	}
	// 窗口前的历史只需要期初快照，不必整段回放；窗口内分录再按原顺序回放。
	// 任何日终点都不早于窗口起点，所以「期初快照 + 窗口回放」与全历史回放的日终余额口径完全一致。
	opening, err := loadWalletOpeningBalances(ctx, db, app, assetTypes, windowStartMS)
	if err != nil {
		return err
	}
	normalizedAssets := make([]string, 0, len(assetTypes))
	for _, assetType := range assetTypes {
		normalizedAssets = append(normalizedAssets, strings.ToUpper(strings.TrimSpace(assetType)))
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",")
	args := []any{app}
	for _, assetType := range normalizedAssets {
		args = append(args, assetType)
	}
	args = append(args, windowStartMS, windowEndMS)
	rows, err := db.QueryContext(ctx, `
		SELECT e.user_id, e.asset_type, e.available_after + e.frozen_after AS balance_after, e.created_at_ms
		FROM wallet_entries e
		WHERE e.app_code = ? AND e.asset_type IN (`+placeholders+`) AND e.created_at_ms >= ? AND e.created_at_ms < ?
		ORDER BY e.user_id ASC, e.created_at_ms ASC, e.entry_id ASC`,
		args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	var currentUserID int64
	entries := []walletBalanceEntry{}
	flush := func() {
		if currentUserID <= 0 || len(entries) == 0 {
			return
		}
		dim := state.userDims[currentUserID]
		index := 0
		balancesByAsset := map[string]int64{}
		for _, asset := range normalizedAssets {
			key := walletOpeningKey{userID: currentUserID, assetType: asset}
			if row, ok := opening[key]; ok {
				balancesByAsset[asset] = row.balance
				delete(opening, key)
			}
		}
		for _, point := range dayEnds {
			for index < len(entries) && entries[index].createdAtMS < point.endMS {
				balancesByAsset[entries[index].assetType] = entries[index].balance
				index++
			}
			if len(balancesByAsset) == 0 {
				continue
			}
			balanceTotal := int64(0)
			for _, balance := range balancesByAsset {
				balanceTotal += balance
			}
			// 日终余额口径取该用户在当天结束前每个目标资产的最后一条账后余额；工资用户可能同时拥有主播和代理资产，
			// 因此先按资产覆盖最后余额再求和，避免同一用户多资产互相覆盖。
			applyBalance(state.ensureAgg(point.day, dim), balanceTotal)
		}
	}
	for rows.Next() {
		var entry walletBalanceEntry
		if err := rows.Scan(&entry.userID, &entry.assetType, &entry.balance, &entry.createdAtMS); err != nil {
			return err
		}
		entry.assetType = strings.ToUpper(strings.TrimSpace(entry.assetType))
		if currentUserID != 0 && entry.userID != currentUserID {
			flush()
			entries = entries[:0]
		}
		currentUserID = entry.userID
		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return err
	}
	flush()
	// 窗口内没有新分录的用户不会出现在流式结果里，但他们的期初存量仍然计入每一个日终总额，
	// 与全历史回放（旧分录在第一个日终点前就被消费掉）的结果一致。
	leftoverTotals := map[int64]int64{}
	for key, row := range opening {
		if key.userID <= 0 {
			continue
		}
		leftoverTotals[key.userID] += row.balance
	}
	for userID, balanceTotal := range leftoverTotals {
		dim := state.userDims[userID]
		for _, point := range dayEnds {
			applyBalance(state.ensureAgg(point.day, dim), balanceTotal)
		}
	}
	return nil
}

func rebuildMicStats(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string) error {
	rows, err := db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_date, '%Y-%m-%d'), user_id, mic_online_ms
		FROM user_mic_daily_stats
		WHERE app_code = ? AND stat_date BETWEEN ? AND ?`,
		app, startDay, endDay)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var day string
		var userID, micOnlineMS int64
		if err := rows.Scan(&day, &userID, &micOnlineMS); err != nil {
			return err
		}
		if micOnlineMS <= 0 {
			continue
		}
		dim := state.userDims[userID]
		state.addMic(day, userID, dim, micOnlineMS)
	}
	return rows.Err()
}

type dayEndPoint struct {
	day   string
	endMS int64
}

func dayEndPoints(startDay string, endDay string, loc *time.Location) ([]dayEndPoint, error) {
	start, err := parseDay(startDay, loc)
	if err != nil {
		return nil, err
	}
	end, err := parseDay(endDay, loc)
	if err != nil {
		return nil, err
	}
	points := []dayEndPoint{}
	for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 0, 1) {
		points = append(points, dayEndPoint{
			day:   formatDay(cursor),
			endMS: cursor.AddDate(0, 0, 1).UTC().UnixMilli(),
		})
	}
	return points, nil
}

func isConsumedCoinBizType(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "direct_gift_debit", "red_packet_create", "wheel_draw_debit":
		return true
	default:
		return false
	}
}

func isOutputCoinBizType(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "gift_debit", "direct_gift_debit", "lucky_gift_reward", "wheel_reward", "red_packet_claim", "red_packet_refund":
		return true
	default:
		return false
	}
}

func isPlatformGrantBizType(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "task_reward", "room_turnover_reward", "invite_activity_reward", "agency_opening_reward":
		return true
	default:
		return false
	}
}

func isPlatformGrantCommandID(value string) bool {
	normalized := strings.ToLower(strings.TrimSpace(value))
	if normalized == "" {
		return false
	}
	switch {
	case strings.HasPrefix(normalized, "weekly_star:"):
		return true
	case strings.HasPrefix(normalized, "cp_weekly_rank:"):
		return true
	case strings.HasPrefix(normalized, "room_turnover_reward:"):
		return true
	case strings.HasPrefix(normalized, "wcheckin_"):
		return true
	case strings.HasPrefix(normalized, "cmd_room_rocket_grant_"):
		return true
	case strings.HasPrefix(normalized, "wtask_"), strings.HasPrefix(normalized, "wfr_"), strings.HasPrefix(normalized, "wcr_"), strings.HasPrefix(normalized, "wreg_"):
		return true
	default:
		return false
	}
}

func (s *rebuildState) ensureAgg(day string, dim countryRegion) *appDayAgg {
	key := appDayKey{day: day, countryID: dim.countryID, regionID: dim.regionID}
	if s.appDays[key] == nil {
		s.appDays[key] = &appDayAgg{}
	}
	return s.appDays[key]
}

func (s *rebuildState) addPayer(day string, userID int64, dim countryRegion, firstMS int64) {
	key := fmt.Sprintf("%s:%d", day, userID)
	if existing, exists := s.payersByKey[key]; exists && existing.firstMS <= firstMS {
		return
	}
	if _, exists := s.payersByKey[key]; !exists {
		s.ensureAgg(day, dim).paidUsers++
	}
	s.payersByKey[key] = payerRow{day: day, userID: userID, dim: dim, firstMS: firstMS}
}

func (s *rebuildState) addActive(day string, userID int64, dim countryRegion, firstMS int64) {
	key := userDayKey(day, userID)
	if existing, exists := s.activeByKey[key]; exists {
		// 注册、进房和游戏都能证明同一用户同一天活跃；主键按用户日收敛，聚合只加一次，
		// 但 first_active_at_ms 要保留最早事实，便于后续排查“为什么算活跃”时能回到第一条证据。
		if firstMS > 0 && (existing.firstMS == 0 || firstMS < existing.firstMS) {
			existing.firstMS = firstMS
			s.activeByKey[key] = existing
		}
		return
	}
	s.activeByKey[key] = activeRow{day: day, userID: userID, dim: dim, firstMS: firstMS}
	s.ensureAgg(day, dim).activeUsers++
}

func (s *rebuildState) addMic(day string, userID int64, dim countryRegion, micOnlineMS int64) {
	key := userDayKey(day, userID)
	if existing, exists := s.micByKey[key]; exists {
		agg := s.ensureAgg(day, existing.dim)
		agg.micOnlineMS += micOnlineMS - existing.micOnlineMS
		existing.micOnlineMS = micOnlineMS
		s.micByKey[key] = existing
		return
	}
	s.micByKey[key] = micRow{day: day, userID: userID, dim: dim, micOnlineMS: micOnlineMS}
	agg := s.ensureAgg(day, dim)
	agg.micOnlineMS += micOnlineMS
	agg.micOnlineUsers++
}

func (s *rebuildState) userRegisteredOn(userID int64, day string) bool {
	_, ok := s.registeredByUserDay[userDayKey(day, userID)]
	return ok
}

func userDayKey(day string, userID int64) string {
	return fmt.Sprintf("%s:%d", day, userID)
}

func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string, apply bool) error {
	conn, err := db.Conn(ctx)
	if err != nil {
		return err
	}
	defer conn.Close()
	tx, err := conn.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if err := createTempTables(ctx, tx); err != nil {
		return err
	}
	if err := insertStageRows(ctx, tx, state, app, statTZ); err != nil {
		return err
	}
	if err := validateStage(ctx, tx); err != nil {
		return err
	}
	if !apply {
		return tx.Rollback()
	}
	nowMS := time.Now().UTC().UnixMilli()
	// 本 job 只覆盖能从当前源表和既有日聚合确定的列；未在本次源表范围内的礼物/游戏明细列保留原值，且只替换目标 stat_tz，避免重跑中国日时覆盖 UTC 读模型。
	if _, err := tx.ExecContext(ctx, `
		UPDATE stat_app_day_country
		SET new_users = 0, active_users = 0, paid_users = 0,
		    recharge_usd_minor = 0, new_user_recharge_usd_minor = 0,
		    coin_seller_recharge_usd_minor = 0,
		    coin_seller_stock_coin = 0,
		    mifapay_recharge_usd_minor = 0, google_recharge_usd_minor = 0, coin_seller_transfer_coin = 0,
		    coin_total = 0, consumed_coin = 0, output_coin = 0, platform_grant_coin = 0, manual_grant_coin = 0,
		    salary_usd_minor = 0, salary_transfer_coin = 0, mic_online_ms = 0, mic_online_users = 0,
		    updated_at_ms = ?
		WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
		nowMS, app, statTZ, startDay, endDay); err != nil {
		return err
	}
	for _, statement := range []string{
		`DELETE FROM stat_user_registration WHERE app_code = ? AND stat_tz = ? AND registered_day BETWEEN ? AND ?`,
		`DELETE FROM stat_user_day_activity WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
		`DELETE FROM stat_recharge_day_payers WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
		`DELETE FROM stat_user_day_mic WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
	} {
		if _, err := tx.ExecContext(ctx, statement, app, statTZ, startDay, endDay); err != nil {
			return err
		}
	}
	// 注册投影的主键是 app_code+stat_tz+user_id，不包含 registered_day。历史投影如果曾用错误时区写入，
	// 仅按日期窗口删除会漏掉同一 user_id 的旧行；本次重算的用户集合必须先清掉，保证补数可重复执行。
	if _, err := tx.ExecContext(ctx, `
			DELETE FROM stat_user_registration
			WHERE app_code = ? AND stat_tz = ?
			  AND user_id IN (SELECT user_id FROM tmp_stat_backfill_registration)`,
		app, statTZ); err != nil {
		return err
	}
	for userID, dim := range state.userDims {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
			VALUES (?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
			  country_id = VALUES(country_id),
			  region_id = VALUES(region_id),
			  updated_at_ms = VALUES(updated_at_ms)
		`, app, userID, dim.countryID, dim.regionID, nowMS); err != nil {
			return err
		}
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO stat_app_day_country (
			app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
			recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
			coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
			coin_total, consumed_coin, output_coin, platform_grant_coin, manual_grant_coin,
			salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users, updated_at_ms
		)
		SELECT app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
		       recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
		       coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
		       coin_total, consumed_coin, output_coin, platform_grant_coin, manual_grant_coin,
		       salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users, ?
		FROM tmp_stat_backfill_app_day
		ON DUPLICATE KEY UPDATE
		  new_users = VALUES(new_users), active_users = VALUES(active_users), paid_users = VALUES(paid_users),
		  recharge_usd_minor = VALUES(recharge_usd_minor),
		  new_user_recharge_usd_minor = VALUES(new_user_recharge_usd_minor),
		  coin_seller_recharge_usd_minor = VALUES(coin_seller_recharge_usd_minor),
		  coin_seller_stock_coin = VALUES(coin_seller_stock_coin),
		  mifapay_recharge_usd_minor = VALUES(mifapay_recharge_usd_minor),
		  google_recharge_usd_minor = VALUES(google_recharge_usd_minor),
		  coin_seller_transfer_coin = VALUES(coin_seller_transfer_coin),
		  coin_total = VALUES(coin_total),
		  consumed_coin = VALUES(consumed_coin),
		  output_coin = VALUES(output_coin),
		  platform_grant_coin = VALUES(platform_grant_coin),
		  manual_grant_coin = VALUES(manual_grant_coin),
		  salary_usd_minor = VALUES(salary_usd_minor),
		  salary_transfer_coin = VALUES(salary_transfer_coin),
		  mic_online_ms = VALUES(mic_online_ms),
		  mic_online_users = VALUES(mic_online_users),
		  updated_at_ms = VALUES(updated_at_ms)`, nowMS); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO stat_user_registration (app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms)
		SELECT app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms FROM tmp_stat_backfill_registration`); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO stat_user_day_activity (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms)
		SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms FROM tmp_stat_backfill_active`); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO stat_recharge_day_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms)
		SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms FROM tmp_stat_backfill_payers`); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO stat_user_day_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, updated_at_ms)
		SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, ? FROM tmp_stat_backfill_mic`, nowMS); err != nil {
		return err
	}
	return tx.Commit()
}

func createTempTables(ctx context.Context, tx *sql.Tx) error {
	statements := []string{
		`CREATE TEMPORARY TABLE tmp_stat_backfill_app_day (
			app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL,
			new_users BIGINT NOT NULL, active_users BIGINT NOT NULL, paid_users BIGINT NOT NULL,
			recharge_usd_minor BIGINT NOT NULL, new_user_recharge_usd_minor BIGINT NOT NULL,
			coin_seller_recharge_usd_minor BIGINT NOT NULL, coin_seller_stock_coin BIGINT NOT NULL,
			mifapay_recharge_usd_minor BIGINT NOT NULL, google_recharge_usd_minor BIGINT NOT NULL,
			coin_seller_transfer_coin BIGINT NOT NULL,
			coin_total BIGINT NOT NULL, consumed_coin BIGINT NOT NULL, output_coin BIGINT NOT NULL, platform_grant_coin BIGINT NOT NULL,
			manual_grant_coin BIGINT NOT NULL, salary_usd_minor BIGINT NOT NULL, salary_transfer_coin BIGINT NOT NULL,
			mic_online_ms BIGINT NOT NULL, mic_online_users BIGINT NOT NULL,
			PRIMARY KEY(app_code, stat_tz, stat_day, country_id, region_id)
		)`,
		`CREATE TEMPORARY TABLE tmp_stat_backfill_registration (
			app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL,
			country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, registered_at_ms BIGINT NOT NULL,
			PRIMARY KEY(app_code, stat_tz, user_id)
		)`,
		`CREATE TEMPORARY TABLE tmp_stat_backfill_active (
			app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL,
			region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL,
			PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
		)`,
		`CREATE TEMPORARY TABLE tmp_stat_backfill_payers (
			app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL,
			region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
			PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
		)`,
		`CREATE TEMPORARY TABLE tmp_stat_backfill_mic (
			app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL,
			region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL,
			PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
		)`,
	}
	for _, statement := range statements {
		if _, err := tx.ExecContext(ctx, statement); err != nil {
			return err
		}
	}
	return nil
}

func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app string, statTZ string) error {
	for key, agg := range state.appDays {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO tmp_stat_backfill_app_day (
				app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
				recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
				coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
				coin_total, consumed_coin, output_coin, platform_grant_coin, manual_grant_coin, salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			app, statTZ, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers,
			agg.rechargeUSDMinor, agg.newUserRechargeUSDMinor, agg.coinSellerRechargeUSDMinor,
			agg.coinSellerStockCoin, agg.mifaPayRechargeUSDMinor, agg.googleRechargeUSDMinor, agg.coinSellerTransferCoin,
			agg.coinTotal, agg.consumedCoin, agg.outputCoin, agg.platformGrantCoin, agg.manualGrantCoin, agg.salaryUSDMinor, agg.salaryTransferCoin, agg.micOnlineMS, agg.micOnlineUsers); err != nil {
			return err
		}
	}
	for _, row := range state.registrations {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO tmp_stat_backfill_registration (app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.userID, row.registeredDay, row.dim.countryID, row.dim.regionID, row.registeredAtMS); err != nil {
			return err
		}
	}
	for _, row := range state.activeByKey {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO tmp_stat_backfill_active (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil {
			return err
		}
	}
	for _, row := range state.payersByKey {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO tmp_stat_backfill_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil {
			return err
		}
	}
	for _, row := range state.micByKey {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO tmp_stat_backfill_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.micOnlineMS); err != nil {
			return err
		}
	}
	return nil
}

func validateStage(ctx context.Context, tx *sql.Tx) error {
	var negativeCount int64
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM tmp_stat_backfill_app_day
		WHERE new_users < 0 OR active_users < 0 OR paid_users < 0 OR recharge_usd_minor < 0 OR coin_seller_transfer_coin < 0
		   OR coin_total < 0 OR consumed_coin < 0 OR output_coin < 0 OR platform_grant_coin < 0 OR manual_grant_coin < 0
		   OR salary_usd_minor < 0 OR salary_transfer_coin < 0 OR mic_online_ms < 0 OR mic_online_users < 0`).Scan(&negativeCount); err != nil {
		return err
	}
	if negativeCount > 0 {
		return fmt.Errorf("stage contains negative counters")
	}
	return nil
}
