package main

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"testing"
	"time"

	"hyapp/internal/testutil/mysqlschema"
)

// 这些测试验证钱包扫描的性能改写与旧实现口径逐字段一致：
// rebuildWalletBalanceTotals 的「期初快照 + 窗口回放」必须等价于旧的全历史回放，
// rebuildWalletReportMetrics 的「按天分段 + 批量取交易」必须等价于旧的整窗 JOIN。
// 基准实现是改写前代码的逐字拷贝，不要随新实现同步修改。

const (
	testApp      = "lalu"
	testStartDay = "2026-06-29"
	testEndDay   = "2026-07-01"
)

func newWalletSchema(t *testing.T) *mysqlschema.Schema {
	t.Helper()
	return mysqlschema.New(t, mysqlschema.Config{
		InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1),
			"..", "..", "..", "wallet-service", "deploy", "mysql", "initdb", "001_wallet_service.sql"),
		DatabasePrefix: "hy_test_backfill",
	})
}

func testWindow(t *testing.T) (loc *time.Location, windowStartMS int64, windowEndMS int64) {
	t.Helper()
	loc = time.UTC
	start, err := parseDay(testStartDay, loc)
	if err != nil {
		t.Fatal(err)
	}
	end, err := parseDay(testEndDay, loc)
	if err != nil {
		t.Fatal(err)
	}
	return loc, start.UTC().UnixMilli(), end.AddDate(0, 0, 1).UTC().UnixMilli()
}

type seedEntry struct {
	entryID        int64
	app            string
	txID           string
	userID         int64
	assetType      string
	availableDelta int64
	frozenDelta    int64
	availableAfter int64
	frozenAfter    int64
	counterparty   int64
	createdAtMS    int64
}

type seedTransaction struct {
	app          string
	txID         string
	commandID    string
	bizType      string
	metadataJSON sql.NullString
}

func seedWalletData(t *testing.T, db *sql.DB, transactions []seedTransaction, entries []seedEntry) {
	t.Helper()
	ctx := context.Background()
	for _, tx := range transactions {
		if _, err := db.ExecContext(ctx, `
			INSERT INTO wallet_transactions (app_code, transaction_id, command_id, biz_type, status, request_hash, metadata_json, created_at_ms, updated_at_ms)
			VALUES (?, ?, ?, ?, 'committed', ?, ?, 1, 1)`,
			tx.app, tx.txID, tx.commandID, tx.bizType, "hash_"+tx.txID, tx.metadataJSON); err != nil {
			t.Fatalf("seed transaction %s: %v", tx.txID, err)
		}
	}
	for _, entry := range entries {
		if _, err := db.ExecContext(ctx, `
			INSERT INTO wallet_entries (entry_id, app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, available_after, frozen_after, counterparty_user_id, room_id, created_at_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`,
			entry.entryID, entry.app, entry.txID, entry.userID, entry.assetType,
			entry.availableDelta, entry.frozenDelta, entry.availableAfter, entry.frozenAfter,
			entry.counterparty, entry.createdAtMS); err != nil {
			t.Fatalf("seed entry %d: %v", entry.entryID, err)
		}
	}
}

func newTestState() *rebuildState {
	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{}{},
	}
	// 一部分用户带维度，一部分故意缺失（走零值维度分支）。
	for userID := int64(101); userID <= 110; userID++ {
		state.userDims[userID] = countryRegion{countryID: userID % 5, regionID: userID % 3}
	}
	state.userDims[202] = countryRegion{countryID: 9, regionID: 2}
	return state
}

func requireEqualAppDays(t *testing.T, got map[appDayKey]*appDayAgg, want map[appDayKey]*appDayAgg) {
	t.Helper()
	for key := range want {
		if got[key] == nil {
			t.Errorf("missing agg for %+v (reference has %+v)", key, *want[key])
		}
	}
	for key, gotAgg := range got {
		wantAgg := want[key]
		if wantAgg == nil {
			t.Errorf("unexpected agg for %+v: %+v", key, *gotAgg)
			continue
		}
		if *gotAgg != *wantAgg {
			t.Errorf("agg mismatch for %+v:\n got=%+v\nwant=%+v", key, *gotAgg, *wantAgg)
		}
	}
}

// referenceRebuildWalletBalanceTotals 是优化前的全历史回放实现，逐字拷贝作为口径基准。
func referenceRebuildWalletBalanceTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, 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
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",")
	args := []any{app}
	for _, assetType := range assetTypes {
		args = append(args, strings.ToUpper(strings.TrimSpace(assetType)))
	}
	args = append(args, 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 < ?
		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 _, 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()
	return nil
}

// referenceRebuildWalletReportMetrics 是优化前的整窗 JOIN 实现，逐字拷贝作为口径基准。
func referenceRebuildWalletReportMetrics(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, startMS int64, endMS int64, loc *time.Location) error {
	if err := referenceRebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, endMS, loc, []string{"COIN"}, func(agg *appDayAgg, balance int64) {
		agg.coinTotal += balance
	}); err != nil {
		return err
	}
	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,
		       wt.biz_type, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.command_id')), wt.command_id), e.created_at_ms
		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 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
	}
	defer rows.Close()
	for rows.Next() {
		var userID, counterpartyUserID, amountDelta, createdAtMS int64
		var assetType, bizType, commandID string
		if err := rows.Scan(&userID, &counterpartyUserID, &assetType, &amountDelta, &bizType, &commandID, &createdAtMS); err != nil {
			return err
		}
		day := dayFromMS(createdAtMS, loc)
		normalizedBiz := strings.ToLower(strings.TrimSpace(bizType))
		normalizedAsset := strings.ToUpper(strings.TrimSpace(assetType))
		if amountDelta > 0 && normalizedBiz == "salary_transfer_to_coin_seller" && normalizedAsset == "COIN_SELLER_COIN" {
			dimensionUserID := userID
			if counterpartyUserID > 0 {
				dimensionUserID = counterpartyUserID
			}
			agg := state.ensureAgg(day, state.userDims[dimensionUserID])
			agg.salaryTransferCoin += amountDelta
			continue
		}
		if normalizedAsset != "COIN" {
			continue
		}
		dim := state.userDims[userID]
		agg := state.ensureAgg(day, dim)
		switch {
		case amountDelta < 0 && isConsumedCoinBizType(normalizedBiz):
			agg.consumedCoin += -amountDelta
		case amountDelta > 0 && isOutputCoinBizType(normalizedBiz):
			agg.outputCoin += amountDelta
		case amountDelta > 0 && normalizedBiz == "manual_credit":
			agg.manualGrantCoin += amountDelta
		case amountDelta > 0 && normalizedBiz == "resource_grant" && isPlatformGrantCommandID(commandID):
			agg.platformGrantCoin += amountDelta
		case amountDelta > 0 && isPlatformGrantBizType(normalizedBiz):
			agg.platformGrantCoin += amountDelta
		}
	}
	return rows.Err()
}

func jsonMeta(value string) sql.NullString {
	return sql.NullString{String: value, Valid: true}
}

// seedBalanceAndReportFixture 铺一份同时覆盖余额回放和报表归类所有分支的数据。
func seedBalanceAndReportFixture(t *testing.T, db *sql.DB, windowStartMS int64, windowEndMS int64) {
	t.Helper()
	day1 := windowStartMS                    // 2026-06-29 00:00 UTC
	day2 := day1 + 24*3600*1000              // 2026-06-30 00:00 UTC
	day3 := day2 + 24*3600*1000              // 2026-07-01 00:00 UTC
	pre := windowStartMS - 10*24*3600*1000   // 窗口前 10 天

	transactions := []seedTransaction{
		{testApp, "tx_pre_1", "cmd_pre_1", "recharge_credit", sql.NullString{}},
		{testApp, "tx_pre_2", "cmd_pre_2", "recharge_credit", sql.NullString{}},
		{testApp, "tx_pre_3", "cmd_pre_3", "recharge_credit", sql.NullString{}},
		{testApp, "tx_pre_4", "cmd_pre_4", "recharge_credit", sql.NullString{}},
		{testApp, "tx_pre_5", "cmd_pre_5", "recharge_credit", sql.NullString{}},
		{testApp, "tx_gift_neg", "cmd_gift_neg", "direct_gift_debit", sql.NullString{}},
		{testApp, "tx_gift_pos", "cmd_gift_pos", "gift_debit", sql.NullString{}},
		{testApp, "tx_gift_direct_pos", "cmd_gift_direct_pos", "direct_gift_debit", sql.NullString{}},
		{testApp, "tx_manual", "cmd_manual", "manual_credit", sql.NullString{}},
		{testApp, "tx_grant_meta", "cmd_grant_meta", "resource_grant", jsonMeta(`{"command_id":"weekly_star:abc"}`)},
		{testApp, "tx_grant_nometa", "wtask_grant_2", "resource_grant", jsonMeta(`{"other":1}`)},
		{testApp, "tx_grant_other", "cmd_grant_other", "resource_grant", sql.NullString{}},
		{testApp, "tx_grant_case", "cp_weekly_rank:z", "Resource_Grant", sql.NullString{}},
		{testApp, "tx_grant_leading", "cmd_not_platform", " resource_grant", jsonMeta(`{"command_id":"weekly_star:lead"}`)},
		{testApp, "tx_task", "cmd_task", "task_reward", sql.NullString{}},
		{testApp, "tx_redpacket", "cmd_redpacket", "red_packet_create", sql.NullString{}},
		{testApp, "tx_wheel", "cmd_wheel", "wheel_draw_debit", sql.NullString{}},
		{testApp, "tx_claim", "cmd_claim", "red_packet_claim", sql.NullString{}},
		{testApp, "tx_salary_transfer", "cmd_salary_transfer", "salary_transfer_to_coin_seller", jsonMeta(`{"command_id":"ignored"}`)},
		{testApp, "tx_seller_manual", "cmd_seller_manual", "manual_credit", sql.NullString{}},
		{testApp, "tx_lower", "cmd_lower", "manual_credit", sql.NullString{}},
		{testApp, "tx_boundary", "cmd_boundary", "manual_credit", sql.NullString{}},
		// 故意不给 tx_missing 建交易主行：INNER JOIN 语义下该分录不参与口径。
		{"other", "tx_other_app", "cmd_other_app", "manual_credit", sql.NullString{}},
	}

	entries := []seedEntry{
		// user 101：只有窗口前分录（期初快照兜底路径），日终余额恒为 500。
		{1, testApp, "tx_pre_1", 101, "COIN", 300, 0, 300, 0, 0, pre},
		{2, testApp, "tx_pre_2", 101, "COIN", 200, 0, 400, 100, 0, pre + 1000},
		// user 104：窗口前同一毫秒两条分录，entry_id 大者（余额 777）才是口径上的期初。
		{3, testApp, "tx_pre_3", 104, "COIN", 100, 0, 111, 0, 0, pre + 5000},
		{4, testApp, "tx_pre_4", 104, "COIN", 100, 0, 777, 0, 0, pre + 5000},
		// user 102：窗口前期初 900，day2 内变为 250。
		{5, testApp, "tx_pre_5", 102, "COIN", 900, 0, 900, 0, 0, pre + 9000},
		{6, testApp, "tx_gift_neg", 102, "COIN", -100, 0, 250, 0, 0, day2 + 3600_000},
		// user 103：只有窗口内分录，day3 起才计入。
		{7, testApp, "tx_gift_pos", 103, "COIN", 80, 0, 80, 0, 0, day3 + 7200_000},
		// user 105：COIN 与 HOST_SALARY_USD 双资产。
		{8, testApp, "tx_manual", 105, "COIN", 50, 0, 50, 0, 0, day1 + 1000},
		{9, testApp, "tx_grant_meta", 105, "HOST_SALARY_USD", 30, 0, 30, 0, 0, pre + 100},
		// user 106：负余额也按原样累计。
		{10, testApp, "tx_grant_nometa", 106, "COIN", 30, 0, -20, 0, 0, day1 + 2000},
		// user 107：AGENCY_SALARY_USD 只在窗口内。
		{11, testApp, "tx_grant_other", 107, "AGENCY_SALARY_USD", 12, 0, 12, 0, 0, day2 + 100},
		// user 108：HOST_SALARY_USD 只有期初。
		{12, testApp, "tx_task", 108, "HOST_SALARY_USD", 40, 0, 40, 0, 0, pre + 200},
		// 报表归类分支（均在窗口内，含各 biz_type 与正负号组合）。
		{13, testApp, "tx_grant_case", 105, "COIN", 5, 0, 55, 0, 0, day2 + 5000},
		{14, testApp, "tx_redpacket", 102, "COIN", -40, 0, 210, 0, 0, day2 + 6000},
		{15, testApp, "tx_wheel", 102, "COIN", -60, 0, 150, 0, 0, day3 + 1000},
		{16, testApp, "tx_claim", 103, "COIN", 25, 0, 105, 0, 0, day3 + 8000},
		{17, testApp, "tx_salary_transfer", 201, "COIN_SELLER_COIN", 500, 0, 500, 0, 202, day2 + 7000},
		{18, testApp, "tx_seller_manual", 201, "COIN_SELLER_COIN", 10, 0, 510, 0, 0, day2 + 8000},
		{19, testApp, "tx_missing", 109, "COIN", 33, 0, 33, 0, 0, day2 + 9000},
		// 小写 asset_type：ci 排序规则下新旧实现都会命中并归一化成大写。
		{20, testApp, "tx_lower", 110, "coin", 15, 0, 15, 0, 0, day1 + 3000},
		// 边界：恰好等于窗口起点与 day2 起点的分录。
		{21, testApp, "tx_boundary", 106, "COIN", 6, 0, -14, 0, 0, day2},
		{22, testApp, "tx_grant_meta", 104, "COIN", 30, 0, 807, 0, 0, windowStartMS},
		// 其他 app 的分录必须被过滤。
		{23, "other", "tx_other_app", 101, "COIN", 999, 0, 999, 0, 0, day1 + 100},
		// 正向 direct_gift_debit 归产出（新旧一致）。
		{24, testApp, "tx_gift_direct_pos", 103, "COIN", 10, 0, 90, 0, 0, day3 + 7500},
		// 前导空格 biz_type：Go 侧 TrimSpace 归到 resource_grant，SQL 侧 TRIM 门必须同样命中 metadata command_id。
		{25, testApp, "tx_grant_leading", 105, "COIN", 7, 0, 62, 0, 0, day2 + 9500},
	}
	seedWalletData(t, db, transactions, entries)
}

func TestRebuildWalletBalanceTotalsMatchesFullReplay(t *testing.T) {
	schema := newWalletSchema(t)
	ctx := context.Background()
	loc, windowStartMS, windowEndMS := testWindow(t)
	seedBalanceAndReportFixture(t, schema.DB, windowStartMS, windowEndMS)

	// 多资产切片没有生产调用方，但要覆盖占位符拼装的通用分支。
	for _, assets := range [][]string{{"COIN"}, {"HOST_SALARY_USD"}, {"AGENCY_SALARY_USD"}, {"HOST_SALARY_USD", "AGENCY_SALARY_USD"}} {
		newState := newTestState()
		refState := newTestState()
		apply := func(agg *appDayAgg, balance int64) { agg.coinTotal += balance }
		if err := rebuildWalletBalanceTotals(ctx, schema.DB, newState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc, assets, apply); err != nil {
			t.Fatalf("new impl (%v): %v", assets, err)
		}
		if err := referenceRebuildWalletBalanceTotals(ctx, schema.DB, refState, testApp, testStartDay, testEndDay, windowEndMS, loc, assets, apply); err != nil {
			t.Fatalf("reference impl (%v): %v", assets, err)
		}
		t.Run(fmt.Sprintf("assets_%s", strings.Join(assets, "_")), func(t *testing.T) {
			requireEqualAppDays(t, newState.appDays, refState.appDays)
		})
	}
}

func TestRebuildWalletReportMetricsMatchesJoinQuery(t *testing.T) {
	schema := newWalletSchema(t)
	ctx := context.Background()
	loc, windowStartMS, windowEndMS := testWindow(t)
	seedBalanceAndReportFixture(t, schema.DB, windowStartMS, windowEndMS)

	newState := newTestState()
	refState := newTestState()
	if err := rebuildWalletReportMetrics(ctx, schema.DB, newState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc); err != nil {
		t.Fatalf("new impl: %v", err)
	}
	if err := referenceRebuildWalletReportMetrics(ctx, schema.DB, refState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc); err != nil {
		t.Fatalf("reference impl: %v", err)
	}
	requireEqualAppDays(t, newState.appDays, refState.appDays)

	// 抽查一个绝对值，防止两套实现同时错成一样：user 202（维度 9/2）在 day2 收到 500 工资转账。
	key := appDayKey{day: "2026-06-30", countryID: 9, regionID: 2}
	agg := newState.appDays[key]
	if agg == nil || agg.salaryTransferCoin != 500 {
		t.Errorf("expected salaryTransferCoin=500 for %+v, got %+v", key, agg)
	}
}
