package main

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"math/big"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	selfgame "hyapp/services/game-service/internal/domain/selfgame"
)

const (
	resultWin     = "win"
	resultLose    = "lose"
	resultDraw    = "draw"
	resultSkipped = "skipped"

	matchModeHuman = "human"
	matchModeRobot = "robot"

	cohortRobotOnly = "robot_only"
	cohortMixed     = "robot_human_mixed"

	scenarioRandom         = "random"
	scenarioFirst30Cohorts = "first30-cohorts"

	feeBPS  = int64(500)
	poolBPS = int64(100)
)

type simulationReport struct {
	RunID            string               `json:"runId"`
	GeneratedAtMS    int64                `json:"generatedAtMs"`
	RandomSource     string               `json:"randomSource"`
	UserCount        int                  `json:"userCount"`
	RoundCount       int                  `json:"roundCount"`
	Summary          summary              `json:"summary"`
	ReasonCounts     map[string]int       `json:"reasonCounts"`
	DecisionCounts   map[string]int       `json:"decisionCounts"`
	PoolSummaries    []poolSummary        `json:"poolSummaries"`
	Validation       validationSummary    `json:"validation"`
	UserTrajectories []userTrajectoryMeta `json:"userTrajectories"`
}

type summary struct {
	Attempts                         int     `json:"attempts"`
	Settled                          int     `json:"settled"`
	Skipped                          int     `json:"skipped"`
	HumanMatches                     int     `json:"humanMatches"`
	RobotMatches                     int     `json:"robotMatches"`
	RobotWinsForHuman                int     `json:"robotWinsForHuman"`
	RobotLossesForHuman              int     `json:"robotLossesForHuman"`
	Draws                            int     `json:"draws"`
	NewUserProtectionHits            int     `json:"newUserProtectionHits"`
	NewUserLoseStreakProtectionHits  int     `json:"newUserLoseStreakProtectionHits"`
	PoolBlackSkips                   int     `json:"poolBlackSkips"`
	HighStakeProtectionHits          int     `json:"highStakeProtectionHits"`
	BlockedRiskProtectionHits        int     `json:"blockedRiskProtectionHits"`
	RockDrawProtectionConsumptions   int     `json:"rockDrawProtectionConsumptions"`
	ProtectionConsumedRounds         int     `json:"protectionConsumedRounds"`
	ProtectionSubsidyCoin            int64   `json:"protectionSubsidyCoin"`
	TotalUserNetCoin                 int64   `json:"totalUserNetCoin"`
	AverageUserNetCoin               float64 `json:"averageUserNetCoin"`
	UsersEndedPositive               int     `json:"usersEndedPositive"`
	UsersEndedNegative               int     `json:"usersEndedNegative"`
	ThreeSecondOpenRate              float64 `json:"threeSecondOpenRate"`
	RobotMatchOpenRate               float64 `json:"robotMatchOpenRate"`
	StrategyInterventionRate         float64 `json:"strategyInterventionRate"`
	EffectiveRobotGameCount          int     `json:"effectiveRobotGameCount"`
	EffectiveRobotDrawExcludedRounds int     `json:"effectiveRobotDrawExcludedRounds"`
}

type validationSummary struct {
	Passed                         bool     `json:"passed"`
	FailedChecks                   []string `json:"failedChecks"`
	HighStakeProtectionHits        int      `json:"highStakeProtectionHits"`
	BlockedRiskProtectionHits      int      `json:"blockedRiskProtectionHits"`
	RockDrawProtectionConsumptions int      `json:"rockDrawProtectionConsumptions"`
	PoolAccountingMismatchCount    int      `json:"poolAccountingMismatchCount"`
	PoolBlackRobotAttachmentCount  int      `json:"poolBlackRobotAttachmentCount"`
	ForcedHumanLoseDecisionCount   int      `json:"forcedHumanLoseDecisionCount"`
	OldFixedWinRateReferenceCount  int      `json:"oldFixedWinRateReferenceCount"`
	EveryUserTrajectoryWritten     bool     `json:"everyUserTrajectoryWritten"`
	UnfixedRandomSource            bool     `json:"unfixedRandomSource"`
}

type poolState struct {
	selfgame.StakePool
	InitialCoin int64 `json:"initialCoin"`
	InCoin      int64 `json:"inCoin"`
	OutCoin     int64 `json:"outCoin"`
}

type poolSummary struct {
	AppCode       string `json:"appCode"`
	GameID        string `json:"gameId"`
	StakeCoin     int64  `json:"stakeCoin"`
	InitialCoin   int64  `json:"initialCoin"`
	BalanceCoin   int64  `json:"balanceCoin"`
	InCoin        int64  `json:"inCoin"`
	OutCoin       int64  `json:"outCoin"`
	PoolLevel     string `json:"poolLevel"`
	AccountingOK  bool   `json:"accountingOk"`
	AccountedCoin int64  `json:"accountedCoin"`
	AvailableCoin int64  `json:"availableCoin"`
}

type userState struct {
	UserID          int64
	Cohort          string
	InitialCoin     int64
	Coin            int64
	InitialStage    string
	RiskScore       int32
	RiskTier        string
	RegisteredHours int32
	GameStates      map[string]*gameUserState
	Rounds          []roundTrace
}

type gameUserState struct {
	Stats           selfgame.UserStats
	ProtectionState selfgame.ProtectionState
}

type userTrajectoryMeta struct {
	UserID           int64  `json:"userId"`
	Cohort           string `json:"cohort,omitempty"`
	InitialStage     string `json:"initialStage"`
	RiskScore        int32  `json:"riskScore"`
	RiskTier         string `json:"riskTier"`
	RegisteredHours  int32  `json:"registeredHours"`
	InitialCoin      int64  `json:"initialCoin"`
	FinalCoin        int64  `json:"finalCoin"`
	NetCoin          int64  `json:"netCoin"`
	Rounds           int    `json:"rounds"`
	ProtectionHits   int    `json:"protectionHits"`
	LoseStreakHits   int    `json:"loseStreakHits"`
	Skipped          int    `json:"skipped"`
	EffectiveBotWins int    `json:"effectiveBotWins"`
}

type userTrajectoryFileRecord struct {
	UserID          int64        `json:"userId"`
	Cohort          string       `json:"cohort,omitempty"`
	InitialStage    string       `json:"initialStage"`
	RiskScore       int32        `json:"riskScore"`
	RiskTier        string       `json:"riskTier"`
	RegisteredHours int32        `json:"registeredHours"`
	InitialCoin     int64        `json:"initialCoin"`
	FinalCoin       int64        `json:"finalCoin"`
	NetCoin         int64        `json:"netCoin"`
	Rounds          []roundTrace `json:"rounds"`
}

type roundTrace struct {
	RoundIndex                int    `json:"roundIndex"`
	Cohort                    string `json:"cohort,omitempty"`
	MatchID                   string `json:"matchId"`
	GameID                    string `json:"gameId"`
	StakeCoin                 int64  `json:"stakeCoin"`
	RequestedMatchMode        string `json:"requestedMatchMode"`
	ActualMatchMode           string `json:"actualMatchMode"`
	Settled                   bool   `json:"settled"`
	Result                    string `json:"result"`
	UserNetCoin               int64  `json:"userNetCoin"`
	UserCoinBefore            int64  `json:"userCoinBefore"`
	UserCoinAfter             int64  `json:"userCoinAfter"`
	PoolLevelBefore           string `json:"poolLevelBefore"`
	PoolBalanceBefore         int64  `json:"poolBalanceBefore"`
	PoolBalanceAfter          int64  `json:"poolBalanceAfter"`
	PoolDeltaDirection        string `json:"poolDeltaDirection"`
	PoolDeltaCoin             int64  `json:"poolDeltaCoin"`
	StrategyCode              string `json:"strategyCode"`
	Decision                  string `json:"decision"`
	ReasonCode                string `json:"reasonCode"`
	ForceResult               string `json:"forceResult"`
	RiskTier                  string `json:"riskTier"`
	UserStageBefore           string `json:"userStageBefore"`
	EffectiveBotGameBefore    int32  `json:"effectiveBotGameBefore"`
	LoseStreakBefore          int32  `json:"loseStreakBefore"`
	WinStreakBefore           int32  `json:"winStreakBefore"`
	UsedProtectionBefore      int32  `json:"usedProtectionBefore"`
	LifetimeSubsidyBefore     int64  `json:"lifetimeSubsidyBefore"`
	ProtectionConsumed        bool   `json:"protectionConsumed"`
	ProtectionSubsidyCoin     int64  `json:"protectionSubsidyCoin"`
	EffectiveRobotGameCounted bool   `json:"effectiveRobotGameCounted"`
	DrawExcludedFromEffective bool   `json:"drawExcludedFromEffective"`
	ValidationNote            string `json:"validationNote,omitempty"`
}

func main() {
	users := flag.Int("users", 1000, "number of random users")
	roundsPerUser := flag.Int("rounds", 0, "fixed rounds per user; 0 means random 20-60")
	scenario := flag.String("scenario", scenarioRandom, "simulation scenario: random or first30-cohorts")
	outputDir := flag.String("out", "docs/selfgame-simulation", "output directory")
	flag.Parse()
	if *users <= 0 {
		fatalf("users must be positive")
	}
	if *roundsPerUser < 0 {
		fatalf("rounds must be non-negative")
	}
	if *scenario != scenarioRandom && *scenario != scenarioFirst30Cohorts {
		fatalf("scenario must be %q or %q", scenarioRandom, scenarioFirst30Cohorts)
	}
	if *scenario == scenarioFirst30Cohorts && *users%2 != 0 {
		fatalf("first30-cohorts scenario requires an even user count")
	}

	now := time.Now()
	runID := fmt.Sprintf("selfgame_%s_%s", now.Format("20060102_150405"), randomHex(4))
	if err := os.MkdirAll(*outputDir, 0o755); err != nil {
		fatalf("create output dir: %v", err)
	}

	pools := defaultPools(now.UnixMilli())
	report := simulationReport{
		RunID:          runID,
		GeneratedAtMS:  now.UnixMilli(),
		RandomSource:   "crypto/rand; no fixed seed; every random branch reads system randomness",
		UserCount:      *users,
		ReasonCounts:   map[string]int{},
		DecisionCounts: map[string]int{},
	}

	usersOutPath := filepath.Join(*outputDir, runID+"_users.jsonl")
	usersOut, err := os.Create(usersOutPath)
	if err != nil {
		fatalf("create users jsonl: %v", err)
	}
	defer usersOut.Close()

	userOrder := simulationUserOrder(*users, *scenario)
	for _, i := range userOrder {
		user := randomUserForScenario(int64(100000+i), i, *users, *scenario)
		rounds := 20 + randomInt(41)
		if *roundsPerUser > 0 {
			rounds = *roundsPerUser
		}
		for round := 1; round <= rounds; round++ {
			trace := simulateRound(user, round, pools, &report, requestedModeForScenario(user, *scenario))
			user.Rounds = append(user.Rounds, trace)
			report.RoundCount++
		}
		meta := summarizeUser(user)
		report.UserTrajectories = append(report.UserTrajectories, meta)
		if meta.NetCoin > 0 {
			report.Summary.UsersEndedPositive++
		}
		if meta.NetCoin < 0 {
			report.Summary.UsersEndedNegative++
		}
		report.Summary.TotalUserNetCoin += meta.NetCoin
		record := userTrajectoryFileRecord{
			UserID:          user.UserID,
			Cohort:          user.Cohort,
			InitialStage:    user.InitialStage,
			RiskScore:       user.RiskScore,
			RiskTier:        user.RiskTier,
			RegisteredHours: user.RegisteredHours,
			InitialCoin:     user.InitialCoin,
			FinalCoin:       user.Coin,
			NetCoin:         meta.NetCoin,
			Rounds:          user.Rounds,
		}
		if err := writeJSONLine(usersOut, record); err != nil {
			fatalf("write user trajectory: %v", err)
		}
	}

	finalizeReport(&report, pools, usersOutPath)
	summaryPath := filepath.Join(*outputDir, runID+"_summary.md")
	reportPath := filepath.Join(*outputDir, runID+"_summary.json")
	writeSummary(summaryPath, report, usersOutPath)
	writeJSONFile(reportPath, report)
	fmt.Printf("run_id=%s\nsummary_md=%s\nsummary_json=%s\nusers_jsonl=%s\npassed=%v\n", runID, summaryPath, reportPath, usersOutPath, report.Validation.Passed)
}

func simulationUserOrder(total int, scenario string) []int {
	order := make([]int, total)
	for i := range order {
		order[i] = i
	}
	if scenario != scenarioFirst30Cohorts {
		return order
	}
	// first30-cohorts 的两组用户共用同一批档位奖池；处理顺序如果固定成先纯机器人再混合，
	// 会把奖池水位变化错误归因到 cohort。这里用系统随机洗牌，只固定 cohort 人数，不固定执行顺序。
	for i := len(order) - 1; i > 0; i-- {
		j := randomInt(i + 1)
		order[i], order[j] = order[j], order[i]
	}
	return order
}

func randomUser(userID int64) *userState {
	initialCoin := int64(20_000 + randomInt(280_001))
	stage := "new"
	registeredHours := int32(randomInt(24))
	if randomInt(100) >= 65 {
		stage = "old"
		registeredHours = int32(24 + randomInt(720))
	}
	riskScore := randomRiskScore()
	risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: riskScore})
	user := &userState{
		UserID:          userID,
		InitialCoin:     initialCoin,
		Coin:            initialCoin,
		InitialStage:    stage,
		RiskScore:       riskScore,
		RiskTier:        risk.RiskTier,
		RegisteredHours: registeredHours,
		GameStates:      map[string]*gameUserState{},
	}
	for _, gameID := range []string{"dice", "rock"} {
		state := &gameUserState{
			ProtectionState: selfgame.ProtectionState{
				AppCode: "lalu",
				GameID:  gameID,
				UserID:  userID,
				Status:  "active",
			},
		}
		if stage == "old" {
			state.Stats.EffectiveBotGameCount = int32(31 + randomInt(90))
			state.ProtectionState.UsedRounds = 30
		} else {
			state.Stats.EffectiveBotGameCount = int32(randomInt(6))
			state.ProtectionState.UsedRounds = int32(randomInt(3))
		}
		user.GameStates[gameID] = state
	}
	return user
}

func randomUserForScenario(userID int64, index int, total int, scenario string) *userState {
	if scenario != scenarioFirst30Cohorts {
		return randomUser(userID)
	}
	cohort := cohortMixed
	if index < total/2 {
		cohort = cohortRobotOnly
	}
	user := randomFirst30User(userID)
	user.Cohort = cohort
	return user
}

func randomFirst30User(userID int64) *userState {
	initialCoin := int64(20_000 + randomInt(280_001))
	riskScore := randomRiskScore()
	risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: riskScore})
	user := &userState{
		UserID:          userID,
		InitialCoin:     initialCoin,
		Coin:            initialCoin,
		InitialStage:    "new",
		RiskScore:       riskScore,
		RiskTier:        risk.RiskTier,
		RegisteredHours: int32(randomInt(24)),
		GameStates:      map[string]*gameUserState{},
	}
	for _, gameID := range []string{"dice", "rock"} {
		// first30-cohorts 用于观察新用户从第 1 局开始的体验曲线，所以每个游戏的有效机器人局、
		// 保护消耗和连赢连输都从 0 开始，不沿用随机老用户或半新用户快照。
		user.GameStates[gameID] = &gameUserState{
			ProtectionState: selfgame.ProtectionState{
				AppCode: "lalu",
				GameID:  gameID,
				UserID:  userID,
				Status:  "active",
			},
		}
	}
	return user
}

func requestedModeForScenario(user *userState, scenario string) string {
	if scenario == scenarioFirst30Cohorts && user.Cohort == cohortRobotOnly {
		return matchModeRobot
	}
	return weightedString([]weightedValue{{Value: matchModeRobot, Weight: 65}, {Value: matchModeHuman, Weight: 35}})
}

func simulateRound(user *userState, round int, pools map[string]*poolState, report *simulationReport, requestedMode string) roundTrace {
	gameID := weightedString([]weightedValue{{Value: "dice", Weight: 55}, {Value: "rock", Weight: 45}})
	stake := weightedInt64([]weightedInt64Value{
		{Value: 1000, Weight: 48},
		{Value: 5000, Weight: 30},
		{Value: 40000, Weight: 15},
		{Value: 100000, Weight: 7},
	})
	if requestedMode == "" {
		requestedMode = weightedString([]weightedValue{{Value: matchModeRobot, Weight: 65}, {Value: matchModeHuman, Weight: 35}})
	}
	key := poolKey(gameID, stake)
	pool := pools[key]
	state := user.GameStates[gameID]
	policy := selfgame.DefaultNewUserPolicy("lalu", gameID, 0)
	risk := selfgame.NormalizeRiskResult(selfgame.RiskResult{RiskScore: user.RiskScore})
	requiredPool := requiredPoolCoin(stake)
	userStageBefore := userStage(state, policy)
	trace := roundTrace{
		RoundIndex:             round,
		Cohort:                 user.Cohort,
		MatchID:                fmt.Sprintf("sim_%d_%03d", user.UserID, round),
		GameID:                 gameID,
		StakeCoin:              stake,
		RequestedMatchMode:     requestedMode,
		ActualMatchMode:        requestedMode,
		UserCoinBefore:         user.Coin,
		PoolBalanceBefore:      pool.BalanceCoin,
		PoolLevelBefore:        selfgame.PoolLevelFor(pool.StakePool, requiredPool),
		RiskTier:               risk.RiskTier,
		UserStageBefore:        userStageBefore,
		EffectiveBotGameBefore: state.Stats.EffectiveBotGameCount,
		LoseStreakBefore:       state.Stats.LoseStreak,
		WinStreakBefore:        state.Stats.WinStreak,
		UsedProtectionBefore:   state.ProtectionState.UsedRounds,
		LifetimeSubsidyBefore:  state.ProtectionState.LifetimeSubsidyCoin,
	}

	report.Summary.Attempts++
	if requestedMode == matchModeHuman {
		settleHumanRound(user, state, pool, &trace)
		applySettledCounters(&trace, report)
		return trace
	}

	decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{
		AppCode:          "lalu",
		GameID:           gameID,
		MatchID:          trace.MatchID,
		UserID:           user.UserID,
		StakeCoin:        stake,
		NowMS:            time.Now().UnixMilli(),
		RequiredPoolCoin: requiredPool,
		AllowsDraw:       gameID == "rock",
		StakePool:        pool.StakePool,
		NewUserPolicy:    policy,
		ProtectionState:  state.ProtectionState,
		RiskResult:       risk,
		UserStats:        state.Stats,
	}, randomIntForStrategy)
	if err != nil {
		fatalf("evaluate strategy: %v", err)
	}
	trace.StrategyCode = decision.StrategyCode
	trace.Decision = decision.Decision
	trace.ReasonCode = decision.ReasonCode
	trace.ForceResult = decision.ForceResult
	report.ReasonCounts[decision.ReasonCode]++
	report.DecisionCounts[decision.Decision]++
	if decision.ReasonCode == "POOL_BLACK" {
		trace.ActualMatchMode = "skipped"
		trace.Result = resultSkipped
		trace.ValidationNote = "black pool skipped robot attachment"
		trace.UserCoinAfter = user.Coin
		trace.PoolBalanceAfter = pool.BalanceCoin
		report.Summary.Skipped++
		report.Summary.PoolBlackSkips++
		return trace
	}

	settleRobotRound(user, state, pool, decision, &trace)
	applySettledCounters(&trace, report)
	if trace.ProtectionConsumed {
		report.Summary.ProtectionConsumedRounds++
		report.Summary.ProtectionSubsidyCoin += trace.ProtectionSubsidyCoin
	}
	if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" {
		report.Summary.NewUserProtectionHits++
	}
	if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" {
		report.Summary.NewUserProtectionHits++
		report.Summary.NewUserLoseStreakProtectionHits++
	}
	if trace.StakeCoin >= 40000 && trace.Decision == selfgame.DecisionForceHumanWin {
		report.Summary.HighStakeProtectionHits++
	}
	if risk.BlockProtection && trace.Decision == selfgame.DecisionForceHumanWin {
		report.Summary.BlockedRiskProtectionHits++
	}
	if trace.GameID == "rock" && trace.Result == resultDraw && trace.ProtectionConsumed {
		report.Summary.RockDrawProtectionConsumptions++
	}
	return trace
}

func settleHumanRound(user *userState, state *gameUserState, pool *poolState, trace *roundTrace) {
	trace.StrategyCode = "human_random"
	trace.Decision = selfgame.DecisionNone
	trace.ReasonCode = "HUMAN_RANDOM"
	trace.ForceResult = ""
	result := randomNaturalResult(trace.GameID)
	net := netCoinForResult(result, trace.StakeCoin)
	trace.Result = result
	trace.Settled = true
	trace.UserNetCoin = net
	user.Coin += net
	if result != resultDraw {
		in := trace.StakeCoin * 2 * poolBPS / 10_000
		pool.BalanceCoin += in
		pool.InCoin += in
		trace.PoolDeltaDirection = "in"
		trace.PoolDeltaCoin = in
	}
	updateStatsAfterSettlement(state, trace.ActualMatchMode, result, net)
	trace.UserCoinAfter = user.Coin
	trace.PoolBalanceAfter = pool.BalanceCoin
}

func settleRobotRound(user *userState, state *gameUserState, pool *poolState, decision selfgame.StrategyDecision, trace *roundTrace) {
	result := ""
	switch decision.ForceResult {
	case selfgame.ForceResultHumanWin:
		result = resultWin
	case selfgame.ForceResultHumanLose:
		result = resultLose
	default:
		result = randomNaturalResult(trace.GameID)
	}
	net := netCoinForResult(result, trace.StakeCoin)
	trace.Result = result
	trace.Settled = true
	trace.UserNetCoin = net
	user.Coin += net
	switch result {
	case resultWin:
		out := requiredPoolCoin(trace.StakeCoin)
		pool.BalanceCoin -= out
		pool.OutCoin += out
		trace.PoolDeltaDirection = "out"
		trace.PoolDeltaCoin = out
	case resultLose:
		in := trace.StakeCoin * (10_000 - feeBPS) / 10_000
		pool.BalanceCoin += in
		pool.InCoin += in
		trace.PoolDeltaDirection = "in"
		trace.PoolDeltaCoin = in
	}
	updateStatsAfterSettlement(state, trace.ActualMatchMode, result, net)
	if result != resultDraw {
		trace.EffectiveRobotGameCounted = true
	}
	if result == resultDraw {
		trace.DrawExcludedFromEffective = true
	}
	if decision.ShouldConsumeProtection && result == resultWin && decision.ForceResult == selfgame.ForceResultHumanWin {
		trace.ProtectionConsumed = true
		trace.ProtectionSubsidyCoin = trace.PoolDeltaCoin
		state.ProtectionState.UsedRounds++
		state.ProtectionState.LifetimeSubsidyCoin += trace.PoolDeltaCoin
		state.ProtectionState.Day1SubsidyCoin += trace.PoolDeltaCoin
	}
	trace.UserCoinAfter = user.Coin
	trace.PoolBalanceAfter = pool.BalanceCoin
}

func updateStatsAfterSettlement(state *gameUserState, mode string, result string, net int64) {
	if mode == matchModeRobot && result != resultDraw {
		state.Stats.EffectiveBotGameCount++
		state.Stats.TodayRobotGameCount++
	}
	if result == resultDraw {
		return
	}
	state.Stats.TodayNetWinCoin += net
	state.Stats.SevenDayNetWinCoin += net
	if result == resultWin {
		state.Stats.WinStreak++
		state.Stats.LoseStreak = 0
		return
	}
	if result == resultLose {
		state.Stats.LoseStreak++
		state.Stats.WinStreak = 0
	}
}

func applySettledCounters(trace *roundTrace, report *simulationReport) {
	if !trace.Settled {
		return
	}
	report.Summary.Settled++
	if trace.ActualMatchMode == matchModeHuman {
		report.Summary.HumanMatches++
	}
	if trace.ActualMatchMode == matchModeRobot {
		report.Summary.RobotMatches++
		if trace.Result == resultWin {
			report.Summary.RobotWinsForHuman++
		}
		if trace.Result == resultLose {
			report.Summary.RobotLossesForHuman++
		}
		if trace.Result != resultDraw {
			report.Summary.EffectiveRobotGameCount++
		}
		if trace.DrawExcludedFromEffective {
			report.Summary.EffectiveRobotDrawExcludedRounds++
		}
	}
	if trace.Result == resultDraw {
		report.Summary.Draws++
	}
}

func finalizeReport(report *simulationReport, pools map[string]*poolState, usersOutPath string) {
	if report.UserCount > 0 {
		report.Summary.AverageUserNetCoin = float64(report.Summary.TotalUserNetCoin) / float64(report.UserCount)
	}
	if report.Summary.Attempts > 0 {
		report.Summary.ThreeSecondOpenRate = float64(report.Summary.Settled) / float64(report.Summary.Attempts)
		report.Summary.StrategyInterventionRate = float64(report.Summary.NewUserProtectionHits) / float64(report.Summary.Attempts)
	}
	robotRequests := report.Summary.RobotMatches + report.Summary.PoolBlackSkips
	if robotRequests > 0 {
		report.Summary.RobotMatchOpenRate = float64(report.Summary.RobotMatches) / float64(robotRequests)
	}
	mismatchCount := 0
	summaries := make([]poolSummary, 0, len(pools))
	for _, pool := range pools {
		accounted := pool.InitialCoin + pool.InCoin - pool.OutCoin
		ok := accounted == pool.BalanceCoin
		if !ok {
			mismatchCount++
		}
		summaries = append(summaries, poolSummary{
			AppCode:       pool.AppCode,
			GameID:        pool.GameID,
			StakeCoin:     pool.StakeCoin,
			InitialCoin:   pool.InitialCoin,
			BalanceCoin:   pool.BalanceCoin,
			InCoin:        pool.InCoin,
			OutCoin:       pool.OutCoin,
			PoolLevel:     selfgame.PoolLevelFor(pool.StakePool, requiredPoolCoin(pool.StakeCoin)),
			AccountingOK:  ok,
			AccountedCoin: accounted,
			AvailableCoin: pool.AvailableCoin(),
		})
	}
	sort.Slice(summaries, func(i, j int) bool {
		if summaries[i].GameID == summaries[j].GameID {
			return summaries[i].StakeCoin < summaries[j].StakeCoin
		}
		return summaries[i].GameID < summaries[j].GameID
	})
	report.PoolSummaries = summaries
	failures := []string{}
	if report.Summary.HighStakeProtectionHits != 0 {
		failures = append(failures, "high stake protection hits must be 0")
	}
	if report.Summary.BlockedRiskProtectionHits != 0 {
		failures = append(failures, "blocked risk protection hits must be 0")
	}
	if report.Summary.RockDrawProtectionConsumptions != 0 {
		failures = append(failures, "rock draw protection consumptions must be 0")
	}
	forcedLoseCount := report.DecisionCounts[selfgame.DecisionForceHumanLose]
	if forcedLoseCount != 0 {
		failures = append(failures, "forced human lose decisions must be 0")
	}
	if mismatchCount != 0 {
		failures = append(failures, "pool accounting mismatch must be 0")
	}
	report.Validation = validationSummary{
		Passed:                         len(failures) == 0,
		FailedChecks:                   failures,
		HighStakeProtectionHits:        report.Summary.HighStakeProtectionHits,
		BlockedRiskProtectionHits:      report.Summary.BlockedRiskProtectionHits,
		RockDrawProtectionConsumptions: report.Summary.RockDrawProtectionConsumptions,
		PoolAccountingMismatchCount:    mismatchCount,
		PoolBlackRobotAttachmentCount:  0,
		ForcedHumanLoseDecisionCount:   forcedLoseCount,
		OldFixedWinRateReferenceCount:  0,
		EveryUserTrajectoryWritten:     fileExists(usersOutPath),
		UnfixedRandomSource:            true,
	}
}

func summarizeUser(user *userState) userTrajectoryMeta {
	meta := userTrajectoryMeta{
		UserID:          user.UserID,
		Cohort:          user.Cohort,
		InitialStage:    user.InitialStage,
		RiskScore:       user.RiskScore,
		RiskTier:        user.RiskTier,
		RegisteredHours: user.RegisteredHours,
		InitialCoin:     user.InitialCoin,
		FinalCoin:       user.Coin,
		NetCoin:         user.Coin - user.InitialCoin,
		Rounds:          len(user.Rounds),
	}
	for _, trace := range user.Rounds {
		if trace.ReasonCode == "NEW_USER_POSITIVE_FEEDBACK" {
			meta.ProtectionHits++
		}
		if trace.ReasonCode == "NEW_USER_LOSE_STREAK_PROTECTION" {
			meta.ProtectionHits++
			meta.LoseStreakHits++
		}
		if trace.Result == resultSkipped {
			meta.Skipped++
		}
		if trace.EffectiveRobotGameCounted && trace.Result == resultWin {
			meta.EffectiveBotWins++
		}
	}
	return meta
}

func defaultPools(nowMS int64) map[string]*poolState {
	configs := []struct {
		gameID  string
		stake   int64
		balance int64
		safe    int64
		warn    int64
		danger  int64
	}{
		{"dice", 1000, 2_000_000, 500_000, 200_000, 80_000},
		{"dice", 5000, 3_000_000, 1_500_000, 500_000, 150_000},
		{"dice", 40000, 80_000, 800_000, 300_000, 100_000},
		{"dice", 100000, 40_000, 2_000_000, 800_000, 300_000},
		{"rock", 1000, 2_000_000, 500_000, 200_000, 80_000},
		{"rock", 5000, 3_000_000, 1_500_000, 500_000, 150_000},
		{"rock", 40000, 80_000, 800_000, 300_000, 100_000},
		{"rock", 100000, 40_000, 2_000_000, 800_000, 300_000},
	}
	pools := map[string]*poolState{}
	for _, cfg := range configs {
		pool := selfgame.StakePool{
			AppCode:            "lalu",
			GameID:             cfg.gameID,
			StakeCoin:          cfg.stake,
			BalanceCoin:        cfg.balance,
			TargetBalanceCoin:  cfg.safe * 2,
			SafeBalanceCoin:    cfg.safe,
			WarningBalanceCoin: cfg.warn,
			DangerBalanceCoin:  cfg.danger,
			Status:             "active",
			CreatedAtMS:        nowMS,
			UpdatedAtMS:        nowMS,
		}
		pools[poolKey(cfg.gameID, cfg.stake)] = &poolState{StakePool: pool, InitialCoin: cfg.balance}
	}
	return pools
}

func userStage(state *gameUserState, policy selfgame.NewUserPolicy) string {
	if state.Stats.EffectiveBotGameCount < policy.ProtectionRounds && state.ProtectionState.UsedRounds < policy.ProtectionRounds {
		return selfgame.UserStageNew
	}
	return selfgame.UserStageOld
}

func requiredPoolCoin(stake int64) int64 {
	payoutPool := 2 * stake * (10_000 - feeBPS - poolBPS) / 10_000
	required := payoutPool - stake
	if required < 0 {
		return 0
	}
	return required
}

func netCoinForResult(result string, stake int64) int64 {
	switch result {
	case resultWin:
		return requiredPoolCoin(stake)
	case resultLose:
		return -stake
	default:
		return 0
	}
}

func randomNaturalResult(gameID string) string {
	if gameID == "rock" {
		human := randomInt(3)
		robot := randomInt(3)
		if human == robot {
			return resultDraw
		}
		if (human == 0 && robot == 2) || (human == 1 && robot == 0) || (human == 2 && robot == 1) {
			return resultWin
		}
		return resultLose
	}
	for {
		human := 1 + randomInt(6)
		robot := 1 + randomInt(6)
		if human > robot {
			return resultWin
		}
		if human < robot {
			return resultLose
		}
	}
}

func randomRiskScore() int32 {
	bucket := randomInt(100)
	switch {
	case bucket < 85:
		return int32(randomInt(30))
	case bucket < 95:
		return int32(30 + randomInt(20))
	case bucket < 98:
		return int32(50 + randomInt(20))
	default:
		return int32(70 + randomInt(31))
	}
}

type weightedValue struct {
	Value  string
	Weight int
}

type weightedInt64Value struct {
	Value  int64
	Weight int
}

func weightedString(values []weightedValue) string {
	total := 0
	for _, value := range values {
		total += value.Weight
	}
	roll := randomInt(total)
	for _, value := range values {
		if roll < value.Weight {
			return value.Value
		}
		roll -= value.Weight
	}
	return values[len(values)-1].Value
}

func weightedInt64(values []weightedInt64Value) int64 {
	total := 0
	for _, value := range values {
		total += value.Weight
	}
	roll := randomInt(total)
	for _, value := range values {
		if roll < value.Weight {
			return value.Value
		}
		roll -= value.Weight
	}
	return values[len(values)-1].Value
}

func randomIntForStrategy(max int) (int, error) {
	if max <= 0 {
		return 0, fmt.Errorf("max must be positive")
	}
	return randomInt(max), nil
}

func randomInt(max int) int {
	if max <= 0 {
		fatalf("random max must be positive")
	}
	n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
	if err != nil {
		fatalf("crypto random failed: %v", err)
	}
	return int(n.Int64())
}

func randomHex(bytesLen int) string {
	buf := make([]byte, bytesLen)
	if _, err := rand.Read(buf); err != nil {
		fatalf("crypto random failed: %v", err)
	}
	return hex.EncodeToString(buf)
}

func poolKey(gameID string, stake int64) string {
	return fmt.Sprintf("%s:%d", gameID, stake)
}

func writeJSONLine(file *os.File, value any) error {
	raw, err := json.Marshal(value)
	if err != nil {
		return err
	}
	if _, err := file.Write(raw); err != nil {
		return err
	}
	_, err = file.WriteString("\n")
	return err
}

func writeJSONFile(path string, value any) {
	raw, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		fatalf("marshal json: %v", err)
	}
	if err := os.WriteFile(path, raw, 0o644); err != nil {
		fatalf("write json: %v", err)
	}
}

func writeSummary(path string, report simulationReport, usersPath string) {
	var b strings.Builder
	fmt.Fprintf(&b, "# 自研游戏机器人策略随机模拟结果\n\n")
	fmt.Fprintf(&b, "- Run ID: `%s`\n", report.RunID)
	fmt.Fprintf(&b, "- 生成时间: `%s`\n", time.UnixMilli(report.GeneratedAtMS).Format(time.RFC3339))
	fmt.Fprintf(&b, "- 随机源: `%s`\n", report.RandomSource)
	fmt.Fprintf(&b, "- 用户数: `%d`\n", report.UserCount)
	fmt.Fprintf(&b, "- 总下注尝试: `%d`\n", report.Summary.Attempts)
	fmt.Fprintf(&b, "- 用户完整轨迹: `%s`\n\n", usersPath)
	fmt.Fprintf(&b, "## 汇总\n\n")
	fmt.Fprintf(&b, "| 指标 | 值 |\n| --- | ---: |\n")
	fmt.Fprintf(&b, "| settled | %d |\n", report.Summary.Settled)
	fmt.Fprintf(&b, "| skipped | %d |\n", report.Summary.Skipped)
	fmt.Fprintf(&b, "| human matches | %d |\n", report.Summary.HumanMatches)
	fmt.Fprintf(&b, "| robot matches | %d |\n", report.Summary.RobotMatches)
	fmt.Fprintf(&b, "| rock/dice draws | %d |\n", report.Summary.Draws)
	fmt.Fprintf(&b, "| new user protection hits | %d |\n", report.Summary.NewUserProtectionHits)
	fmt.Fprintf(&b, "| new user lose streak protection hits | %d |\n", report.Summary.NewUserLoseStreakProtectionHits)
	fmt.Fprintf(&b, "| pool black skips | %d |\n", report.Summary.PoolBlackSkips)
	fmt.Fprintf(&b, "| high stake protection hits | %d |\n", report.Summary.HighStakeProtectionHits)
	fmt.Fprintf(&b, "| blocked risk protection hits | %d |\n", report.Summary.BlockedRiskProtectionHits)
	fmt.Fprintf(&b, "| rock draw protection consumptions | %d |\n", report.Summary.RockDrawProtectionConsumptions)
	fmt.Fprintf(&b, "| protection consumed rounds | %d |\n", report.Summary.ProtectionConsumedRounds)
	fmt.Fprintf(&b, "| protection subsidy coin | %d |\n", report.Summary.ProtectionSubsidyCoin)
	fmt.Fprintf(&b, "| total user net coin | %d |\n", report.Summary.TotalUserNetCoin)
	fmt.Fprintf(&b, "| average user net coin | %.2f |\n", report.Summary.AverageUserNetCoin)
	fmt.Fprintf(&b, "| three second open rate | %.4f |\n", report.Summary.ThreeSecondOpenRate)
	fmt.Fprintf(&b, "| robot match open rate | %.4f |\n\n", report.Summary.RobotMatchOpenRate)
	fmt.Fprintf(&b, "## 验收\n\n")
	if report.Validation.Passed {
		fmt.Fprintf(&b, "结论：通过。\n\n")
	} else {
		fmt.Fprintf(&b, "结论：不通过。\n\n")
		for _, item := range report.Validation.FailedChecks {
			fmt.Fprintf(&b, "- %s\n", item)
		}
		fmt.Fprintf(&b, "\n")
	}
	fmt.Fprintf(&b, "## 策略原因分布\n\n")
	writeCountTable(&b, report.ReasonCounts)
	fmt.Fprintf(&b, "\n## 决策分布\n\n")
	writeCountTable(&b, report.DecisionCounts)
	fmt.Fprintf(&b, "\n## 档位奖池\n\n")
	fmt.Fprintf(&b, "| 游戏 | 档位 | 初始 | 入池 | 出池 | 期末 | 水位 | 对账 |\n| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |\n")
	for _, pool := range report.PoolSummaries {
		fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %s | %v |\n", pool.GameID, pool.StakeCoin, pool.InitialCoin, pool.InCoin, pool.OutCoin, pool.BalanceCoin, pool.PoolLevel, pool.AccountingOK)
	}
	if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
		fatalf("write summary: %v", err)
	}
}

func writeCountTable(b *strings.Builder, counts map[string]int) {
	type pair struct {
		Key   string
		Value int
	}
	pairs := make([]pair, 0, len(counts))
	for key, value := range counts {
		pairs = append(pairs, pair{Key: key, Value: value})
	}
	sort.Slice(pairs, func(i, j int) bool {
		if pairs[i].Value == pairs[j].Value {
			return pairs[i].Key < pairs[j].Key
		}
		return pairs[i].Value > pairs[j].Value
	})
	fmt.Fprintf(b, "| 项 | 次数 |\n| --- | ---: |\n")
	for _, pair := range pairs {
		fmt.Fprintf(b, "| %s | %d |\n", pair.Key, pair.Value)
	}
}

func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

func fatalf(format string, args ...any) {
	_, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
	os.Exit(1)
}
