package main

import (
	"encoding/csv"
	"flag"
	"fmt"
	"log"
	"math"
	"math/rand"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

const ppmScale int64 = 1_000_000

const (
	defaultNoviceDrawLimit       = int64(2_000)
	defaultIntermediateDrawLimit = int64(20_000)
	defaultDrawsPerHour          = int64(3_600)
)

type experiencePool string

const (
	novicePool       experiencePool = "novice"
	intermediatePool experiencePool = "intermediate"
	advancedPool     experiencePool = "advanced"
)

type budgetSource string

const (
	sourceBaseRTP        budgetSource = "base_rtp"
	sourceRoomAtmosphere budgetSource = "room_atmosphere"
	sourceActivity       budgetSource = "activity_subsidy"
	sourcePresentation   budgetSource = "presentation_only"
)

type rewardTier struct {
	id            string
	reward        int64
	multiplierNum int64
	multiplierDen int64
	weight        int64
	highWaterOnly bool
}

func (t rewardTier) rewardFor(cost int64) int64 {
	if t.multiplierDen > 0 {
		return cost * t.multiplierNum / t.multiplierDen
	}
	return t.reward
}

type tierTable struct {
	maxReward int64
	maxNum    int64
	maxDen    int64
	tiers     []rewardTier
}

func (t tierTable) maxRewardFor(cost int64) int64 {
	if t.maxDen > 0 {
		return cost * t.maxNum / t.maxDen
	}
	return t.maxReward
}

type rewardCandidate struct {
	tierID         string
	source         budgetSource
	pool           experiencePool
	weight         int64
	baseReward     int64
	roomReward     int64
	activityReward int64
	presentation   bool
	correction     bool
	highMultiplier bool
}

func (c rewardCandidate) effectiveReward() int64 {
	return c.baseReward + c.roomReward + c.activityReward
}

type drawOutcome struct {
	rewardCandidate
	stageFeedback    bool
	riskCapLimited   bool
	poolCapLimited   bool
	highWaterLimited bool
}

type rtpWindow struct {
	name         string
	windowSize   int64
	cost         int64
	targetPPM    int64
	minFutureMax int64

	carryMicros     int64
	windowIndex     int64
	remainingDraws  int64
	remainingPayout int64
	totalWager      int64
	totalPayout     int64
}

type poolWeights struct {
	platformPPM int64
	roomPPM     int64
	giftPPM     int64
}

type accountingPool struct {
	name         string
	balance      int64
	reserveFloor int64
	totalIn      int64
	totalOut     int64
}

func (p *accountingPool) capacity() int64 {
	capacity := p.balance - p.reserveFloor
	if capacity < 0 {
		return 0
	}
	return capacity
}

func (p *accountingPool) credit(amount int64) {
	if amount <= 0 {
		return
	}
	p.balance += amount
	p.totalIn += amount
}

func (p *accountingPool) debit(amount int64) {
	if amount <= 0 {
		return
	}
	p.balance -= amount
	p.totalOut += amount
}

type roomAtmospherePool struct {
	roomID       int
	balance      int64
	reserveFloor int64
	totalIn      int64
	totalOut     int64
}

func (p *roomAtmospherePool) capacity() int64 {
	capacity := p.balance - p.reserveFloor
	if capacity < 0 {
		return 0
	}
	return capacity
}

func (p *roomAtmospherePool) credit(amount int64) {
	if amount <= 0 {
		return
	}
	p.balance += amount
	p.totalIn += amount
}

func (p *roomAtmospherePool) debit(amount int64) {
	if amount <= 0 {
		return
	}
	p.balance -= amount
	p.totalOut += amount
}

type activityBudget struct {
	budget       int64
	spent        int64
	dailyLimit   int64
	currentDay   int64
	dailySpent   int64
	enabled      bool
	totalOut     int64
	exhaustedHit int64
}

func (b *activityBudget) refresh(day int64) {
	if day == b.currentDay {
		return
	}
	b.currentDay = day
	b.dailySpent = 0
}

func (b *activityBudget) remaining() int64 {
	if !b.enabled {
		return 0
	}
	totalRemaining := b.budget - b.spent
	dailyRemaining := b.dailyLimit - b.dailySpent
	if totalRemaining < 0 {
		totalRemaining = 0
	}
	if dailyRemaining < 0 {
		dailyRemaining = 0
	}
	return minInt64(totalRemaining, dailyRemaining)
}

func (b *activityBudget) debit(amount int64) {
	if amount <= 0 {
		return
	}
	b.spent += amount
	b.dailySpent += amount
	b.totalOut += amount
}

type riskLimits struct {
	singlePayoutCap int64
	userHourlyCap   int64
	userDailyCap    int64
	deviceDailyCap  int64
	roomHourlyCap   int64
	anchorDailyCap  int64
	drawsPerHour    int64
}

type payoutCounter struct {
	bucket int64
	payout int64
}

func (c *payoutCounter) refresh(bucket int64) {
	if bucket == c.bucket {
		return
	}
	c.bucket = bucket
	c.payout = 0
}

func (c *payoutCounter) add(amount int64) {
	c.payout += amount
}

func (c *payoutCounter) remaining(cap int64) int64 {
	remaining := cap - c.payout
	if remaining < 0 {
		return 0
	}
	return remaining
}

type userStat struct {
	id          int
	deviceID    int
	roomID      int
	anchorID    int
	targetDraws int64
	draws       int64
	wager       int64

	basePayout     int64
	roomPayout     int64
	activityPayout int64
	effectivePay   int64

	hits    int64
	maxWin  int64
	balance int64

	currentHour int64
	currentDay  int64
	hourly      payoutCounter
	daily       payoutCounter
}

func (u *userStat) refreshRiskWindows(drawsPerHour int64) {
	u.currentHour = u.draws / drawsPerHour
	u.currentDay = u.draws / (drawsPerHour * 24)
	u.hourly.refresh(u.currentHour)
	u.daily.refresh(u.currentDay)
}

type roomState struct {
	id         int
	anchorID   int
	basePool   accountingPool
	atmosphere roomAtmospherePool
	hourly     payoutCounter
}

type anchorState struct {
	id    int
	daily payoutCounter
}

type deviceState struct {
	id    int
	daily payoutCounter
}

type aggregateStat struct {
	draws           int64
	wager           int64
	basePayout      int64
	roomPayout      int64
	activityPayout  int64
	effectivePayout int64

	hits             int64
	maxWin           int64
	correctionHits   int64
	correctionPayout int64

	noviceDraws       int64
	intermediateDraws int64
	advancedDraws     int64
	stageFeedbacks    int64
	presentationHits  int64

	highMultiplierHits    int64
	riskCapLimitedDraws   int64
	poolCapLimitedDraws   int64
	highWaterLimitedDraws int64
}

type blockTracker struct {
	size        int64
	lastWager   int64
	lastPayout  int64
	closed      int64
	maxAbsDevPP float64
}

func main() {
	usersFlag := flag.Int("users", 1000, "number of simulated users")
	devicesFlag := flag.Int("devices", 900, "number of simulated devices")
	roomsFlag := flag.Int("rooms", 50, "number of simulated rooms")
	drawsFlag := flag.Int64("draws", 100000, "draws per user")
	drawsMinFlag := flag.Int64("draws-min", 0, "optional minimum random draws per user")
	drawsMaxFlag := flag.Int64("draws-max", 0, "optional maximum random draws per user")
	uniqueDrawsFlag := flag.Bool("unique-draws", false, "make random draw counts unique per user")
	initialFlag := flag.Int64("initial", 1_000_000_000, "initial coins per user")
	costFlag := flag.Int64("cost", 500, "coin cost per draw")
	costMinFlag := flag.Int64("cost-min", 0, "optional minimum random coin cost per draw")
	costMaxFlag := flag.Int64("cost-max", 0, "optional maximum random coin cost per draw")
	baseRTPFlag := flag.Float64("base-rtp", 95, "base RTP percent")
	globalWindowFlag := flag.Int64("global-window", 100000, "global RTP control window paid draws")
	giftWindowFlag := flag.Int64("gift-window", 100000, "gift RTP control window paid draws")
	noviceDrawLimitFlag := flag.Int64("novice-draws", defaultNoviceDrawLimit, "paid draws kept in the novice experience pool")
	intermediateDrawLimitFlag := flag.Int64("intermediate-draws", defaultIntermediateDrawLimit, "paid draws kept before advanced experience pool")
	drawsPerHourFlag := flag.Int64("draws-per-hour", defaultDrawsPerHour, "simulated paid draws per user hour for risk windows")
	singleCapFlag := flag.Int64("single-cap", 0, "max total reward per draw, 0 means cost*500")
	userHourlyCapFlag := flag.Int64("user-hourly-cap", 0, "max user reward per simulated hour, 0 means expected hourly payout*2")
	userDailyCapFlag := flag.Int64("user-daily-cap", 0, "max user reward per simulated day, 0 means expected daily payout*1.5")
	deviceDailyCapFlag := flag.Int64("device-daily-cap", 0, "max device reward per simulated day, 0 means expected user daily payout*2.5")
	roomHourlyCapFlag := flag.Int64("room-hourly-cap", 0, "max room reward per simulated hour, 0 means expected room hourly payout*2")
	anchorDailyCapFlag := flag.Int64("anchor-daily-cap", 0, "max anchor-related reward per simulated day, 0 means expected room daily payout*1.5")
	highMultiplierFlag := flag.Int64("high-multiplier", 100, "reward multiplier treated as high tier")
	highWaterPoolMultipleFlag := flag.Int64("high-water-pool-multiple", 2, "pool capacity multiple required before high tiers open")
	poolRateFlag := flag.Int64("pool-rate-ppm", 0, "base prize pool inflow rate, 0 means target RTP ppm")
	platformWeightFlag := flag.Int64("platform-weight-ppm", 200000, "platform liability weight for base payout")
	roomWeightFlag := flag.Int64("room-weight-ppm", 300000, "room liability weight for base payout")
	giftWeightFlag := flag.Int64("gift-weight-ppm", 500000, "gift liability weight for base payout")
	initialPlatformPoolFlag := flag.Int64("initial-platform-pool", 0, "initial platform base pool, 0 means one global window liability")
	initialGiftPoolFlag := flag.Int64("initial-gift-pool", 0, "initial gift base pool, 0 means one gift window liability")
	initialRoomPoolFlag := flag.Int64("initial-room-pool", 5_000_000, "initial base pool per room")
	platformReserveFlag := flag.Int64("platform-reserve", 1_000_000, "platform pool reserve floor")
	giftReserveFlag := flag.Int64("gift-reserve", 1_000_000, "gift pool reserve floor")
	roomReserveFlag := flag.Int64("room-reserve", 100_000, "room pool reserve floor")
	roomAtmosphereRateFlag := flag.Int64("room-atmosphere-rate-ppm", 10000, "room atmosphere inflow from wager, independent from base RTP")
	roomAtmosphereInitialFlag := flag.Int64("room-atmosphere-initial", 100_000, "initial room atmosphere budget per room")
	roomAtmosphereReserveFlag := flag.Int64("room-atmosphere-reserve", 10_000, "room atmosphere reserve floor")
	activityBudgetFlag := flag.Int64("activity-budget", 50_000_000, "activity subsidy budget, independent from base RTP")
	activityDailyLimitFlag := flag.Int64("activity-daily-limit", 10_000_000, "activity subsidy daily spend cap")
	seedFlag := flag.Int64("seed", 20260517, "deterministic random seed")
	userCSVFlag := flag.String("user-csv", "", "optional path to write per-user result CSV")
	flag.Parse()

	if *usersFlag <= 0 || *devicesFlag <= 0 || *roomsFlag <= 0 || *initialFlag < 0 || *costFlag <= 0 || *globalWindowFlag <= 0 || *giftWindowFlag <= 0 || *drawsPerHourFlag <= 0 {
		log.Fatal("users, devices, rooms, initial, cost, windows, and draws-per-hour must be valid positive values")
	}
	randomCost := *costMinFlag > 0 || *costMaxFlag > 0
	if randomCost {
		if *costMinFlag <= 0 || *costMaxFlag < *costMinFlag {
			log.Fatal("cost-min and cost-max must satisfy 0 < cost-min <= cost-max")
		}
	}
	if *noviceDrawLimitFlag < 0 || *intermediateDrawLimitFlag < *noviceDrawLimitFlag || *highMultiplierFlag <= 0 || *highWaterPoolMultipleFlag <= 0 {
		log.Fatal("experience limits and high-water flags are invalid")
	}
	weights := poolWeights{platformPPM: *platformWeightFlag, roomPPM: *roomWeightFlag, giftPPM: *giftWeightFlag}
	if weights.platformPPM < 0 || weights.roomPPM < 0 || weights.giftPPM < 0 || weights.platformPPM+weights.roomPPM+weights.giftPPM != ppmScale {
		log.Fatal("platform, room, and gift pool weights must be non-negative and sum to 1000000")
	}

	randomDraws := *drawsMinFlag >= 0 && *drawsMaxFlag > 0
	if randomDraws {
		if *drawsMinFlag < 0 || *drawsMaxFlag < *drawsMinFlag {
			log.Fatal("draws-min and draws-max must satisfy 0 <= draws-min <= draws-max")
		}
		if *uniqueDrawsFlag && *drawsMaxFlag-*drawsMinFlag+1 < int64(*usersFlag) {
			log.Fatal("draw range is too small to assign unique draw counts")
		}
	} else if *drawsFlag <= 0 {
		log.Fatal("draws must be positive when draws-min/draws-max are not set")
	}

	rng := rand.New(rand.NewSource(*seedFlag))
	baseRTPPPM := percentToPPM(*baseRTPFlag)
	poolRatePPM := *poolRateFlag
	if poolRatePPM == 0 {
		poolRatePPM = baseRTPPPM
	}
	if poolRatePPM < baseRTPPPM {
		log.Fatalf("pool-rate-ppm %d cannot be below target RTP ppm %d in production mode", poolRatePPM, baseRTPPPM)
	}

	costBasis := *costFlag
	minCost := *costFlag
	maxCost := *costFlag
	if randomCost {
		minCost = *costMinFlag
		maxCost = *costMaxFlag
		costBasis = (*costMinFlag + *costMaxFlag) / 2
	}

	noviceTable := defaultNoviceTierTable(costBasis)
	intermediateTable := defaultIntermediateTierTable(costBasis)
	advancedTable := defaultAdvancedTierTable(costBasis)
	singleCap := *singleCapFlag
	if randomCost && singleCap == 0 {
		singleCap = maxCost * 500
	}
	risk := buildRiskLimits(riskInput{
		cost:           costBasis,
		rtpPPM:         baseRTPPPM,
		drawsPerHour:   *drawsPerHourFlag,
		users:          *usersFlag,
		rooms:          *roomsFlag,
		singleCap:      singleCap,
		userHourlyCap:  *userHourlyCapFlag,
		userDailyCap:   *userDailyCapFlag,
		deviceDailyCap: *deviceDailyCapFlag,
		roomHourlyCap:  *roomHourlyCapFlag,
		anchorDailyCap: *anchorDailyCapFlag,
	})
	if err := validateRiskLimits(costBasis, baseRTPPPM, risk); err != nil {
		log.Fatal(err)
	}

	minFutureMaxReward := minInt64(noviceTable.maxRewardFor(minCost), intermediateTable.maxRewardFor(minCost))
	minFutureMaxReward = minInt64(minFutureMaxReward, advancedTable.maxRewardFor(minCost))
	minFutureMaxReward = minInt64(minFutureMaxReward, risk.singlePayoutCap)
	if err := validateRTPCapacity(maxCost, baseRTPPPM, maxCost*20); err != nil {
		log.Fatal(err)
	}

	initialPlatformPool := *initialPlatformPoolFlag
	if initialPlatformPool == 0 {
		initialPlatformPool = weightedAmount(expectedPayoutForDraws(costBasis, baseRTPPPM, *globalWindowFlag), weights.platformPPM)
	}
	initialGiftPool := *initialGiftPoolFlag
	if initialGiftPool == 0 {
		initialGiftPool = weightedAmount(expectedPayoutForDraws(costBasis, baseRTPPPM, *giftWindowFlag), weights.giftPPM)
	}

	platformPool := accountingPool{name: "platform", balance: initialPlatformPool, reserveFloor: *platformReserveFlag}
	giftPool := accountingPool{name: "gift:lucky_500", balance: initialGiftPool, reserveFloor: *giftReserveFlag}
	rooms := buildRooms(*roomsFlag, *initialRoomPoolFlag, *roomReserveFlag, *roomAtmosphereInitialFlag, *roomAtmosphereReserveFlag)
	anchors := buildAnchors(*roomsFlag)
	devices := buildDevices(*devicesFlag)
	activity := activityBudget{
		budget:     *activityBudgetFlag,
		dailyLimit: *activityDailyLimitFlag,
		enabled:    *activityBudgetFlag > 0 && *activityDailyLimitFlag > 0,
	}

	highMultiplierMinReward := costBasis * *highMultiplierFlag
	globalRTP := newRTPWindow("global", *globalWindowFlag, *costFlag, baseRTPPPM, minFutureMaxReward)
	giftRTP := newRTPWindow("gift:lucky_500", *giftWindowFlag, *costFlag, baseRTPPPM, minFutureMaxReward)
	users := make([]userStat, *usersFlag)
	targetDraws := buildTargetDraws(*usersFlag, *drawsFlag, *drawsMinFlag, *drawsMaxFlag, *uniqueDrawsFlag, rng)
	for i := range users {
		roomID := i % *roomsFlag
		deviceID := i % *devicesFlag
		users[i] = userStat{
			id:          i + 1,
			deviceID:    deviceID,
			roomID:      roomID,
			anchorID:    rooms[roomID].anchorID,
			targetDraws: targetDraws[i],
			balance:     *initialFlag,
		}
	}

	total := &aggregateStat{}
	tracker100K := &blockTracker{size: 100000}
	tracker1M := &blockTracker{size: 1000000}

	maxRounds := *drawsFlag
	if randomDraws {
		maxRounds = *drawsMaxFlag
	}
	for round := int64(0); round < maxRounds; round++ {
		for userIndex := range users {
			user := &users[userIndex]
			if user.draws >= user.targetDraws {
				continue
			}
			drawCost := *costFlag
			if randomCost {
				drawCost = *costMinFlag + rng.Int63n(*costMaxFlag-*costMinFlag+1)
			}
			if user.balance < drawCost {
				log.Fatalf("user %d balance is not enough at draw %d", user.id, round+1)
			}

			room := &rooms[user.roomID]
			anchor := &anchors[user.anchorID]
			device := &devices[user.deviceID]
			user.refreshRiskWindows(risk.drawsPerHour)
			device.daily.refresh(user.currentDay)
			room.hourly.refresh(user.currentHour)
			anchor.daily.refresh(user.currentDay)
			activity.refresh(user.currentDay)

			creditBasePools(drawCost, poolRatePPM, weights, &platformPool, &room.basePool, &giftPool)
			room.atmosphere.credit(weightedAmount(drawCost, *roomAtmosphereRateFlag))

			pool := selectExperiencePool(user.draws+1, *noviceDrawLimitFlag, *intermediateDrawLimitFlag)
			table := tableForPool(pool, noviceTable, intermediateTable, advancedTable)
			outcome, err := drawProductionReward(drawRequest{
				table:                   table,
				pool:                    pool,
				stageFeedback:           hasStageFeedback(user.draws+1, *noviceDrawLimitFlag),
				globalRTP:               globalRTP,
				giftRTP:                 giftRTP,
				platformPool:            &platformPool,
				roomPool:                &room.basePool,
				giftPool:                &giftPool,
				roomAtmosphere:          &room.atmosphere,
				activity:                &activity,
				weights:                 weights,
				user:                    user,
				device:                  device,
				room:                    room,
				anchor:                  anchor,
				risk:                    risk,
				highMultiplierMinReward: drawCost * *highMultiplierFlag,
				highWaterPoolMultiple:   *highWaterPoolMultipleFlag,
				cost:                    drawCost,
				rng:                     rng,
			})
			if err != nil {
				log.Fatalf("draw failed after %d paid draws: %v", total.draws, err)
			}

			applyProductionDraw(applyRequest{
				user:           user,
				room:           room,
				anchor:         anchor,
				device:         device,
				activity:       &activity,
				globalRTP:      globalRTP,
				giftRTP:        giftRTP,
				platformPool:   &platformPool,
				giftPool:       &giftPool,
				weights:        weights,
				cost:           drawCost,
				outcome:        outcome,
				total:          total,
				baseRTPPPM:     baseRTPPPM,
				tracker100K:    tracker100K,
				tracker1M:      tracker1M,
				roomBasePool:   &room.basePool,
				roomAtmosphere: &room.atmosphere,
			})
		}
	}

	printResult(resultInput{
		users:              *usersFlag,
		devices:            *devicesFlag,
		rooms:              *roomsFlag,
		drawsPerUser:       *drawsFlag,
		randomDraws:        randomDraws,
		drawsMin:           *drawsMinFlag,
		drawsMax:           *drawsMaxFlag,
		uniqueDraws:        *uniqueDrawsFlag,
		initialCoins:       *initialFlag,
		cost:               *costFlag,
		costMin:            minCost,
		costMax:            maxCost,
		randomCost:         randomCost,
		baseRTPPPM:         baseRTPPPM,
		poolRatePPM:        poolRatePPM,
		globalWindow:       *globalWindowFlag,
		giftWindow:         *giftWindowFlag,
		noviceDraws:        *noviceDrawLimitFlag,
		intermediateDraws:  *intermediateDrawLimitFlag,
		risk:               risk,
		highTierMin:        highMultiplierMinReward,
		highPoolMultiple:   *highWaterPoolMultipleFlag,
		roomAtmosphereRate: *roomAtmosphereRateFlag,
		activityBudget:     *activityBudgetFlag,
		activityDailyLimit: *activityDailyLimitFlag,
		seed:               *seedFlag,
	}, total, users, rooms, &platformPool, &giftPool, &activity, globalRTP, giftRTP, tracker100K, tracker1M)
	if strings.TrimSpace(*userCSVFlag) != "" {
		if err := writeUserCSV(*userCSVFlag, users); err != nil {
			log.Fatalf("write user csv failed: %v", err)
		}
		fmt.Printf("\nuser_csv=%s\n", *userCSVFlag)
	}
}

type drawRequest struct {
	table                   tierTable
	pool                    experiencePool
	stageFeedback           bool
	globalRTP               *rtpWindow
	giftRTP                 *rtpWindow
	platformPool            *accountingPool
	roomPool                *accountingPool
	giftPool                *accountingPool
	roomAtmosphere          *roomAtmospherePool
	activity                *activityBudget
	weights                 poolWeights
	user                    *userStat
	device                  *deviceState
	room                    *roomState
	anchor                  *anchorState
	risk                    riskLimits
	highMultiplierMinReward int64
	highWaterPoolMultiple   int64
	cost                    int64
	rng                     *rand.Rand
}

func drawProductionReward(req drawRequest) (drawOutcome, error) {
	req.globalRTP.ensureOpen()
	req.giftRTP.ensureOpen()
	req.globalRTP.accrue(req.cost)
	req.giftRTP.accrue(req.cost)

	minRequired := maxInt64(req.globalRTP.minRequired(), req.giftRTP.minRequired())
	rtpMaxAllowed := minInt64(req.globalRTP.remainingPayout, req.giftRTP.remainingPayout)
	basePoolCapacity := weightedPoolCapacity(req.platformPool, req.roomPool, req.giftPool, req.weights)
	riskCapacity := riskRewardCapacity(req.user, req.device, req.room, req.anchor, req.risk)
	baseMaxAllowed := minInt64(rtpMaxAllowed, minInt64(basePoolCapacity, riskCapacity))

	outcome := drawOutcome{stageFeedback: req.stageFeedback}
	candidates := make([]rewardCandidate, 0, len(req.table.tiers)+5)
	var totalWeight int64
	var redirectedWeight int64

	for _, tier := range req.table.tiers {
		tierReward := tier.rewardFor(req.cost)
		if tierReward < minRequired {
			continue
		}
		if tierReward > baseMaxAllowed {
			if tierReward > riskCapacity {
				outcome.riskCapLimited = true
			}
			if tierReward > basePoolCapacity {
				outcome.poolCapLimited = true
			}
			if minRequired == 0 {
				redirectedWeight += tier.weight
			}
			continue
		}

		highMultiplier := isHighMultiplier(tierReward, req.highMultiplierMinReward) || tier.highWaterOnly
		if highMultiplier && !hasProductionHighWater(tierReward, req) {
			outcome.highWaterLimited = true
			if minRequired == 0 {
				redirectedWeight += tier.weight
			}
			continue
		}

		candidates = append(candidates, rewardCandidate{
			tierID:         tier.id,
			source:         sourceBaseRTP,
			pool:           req.pool,
			weight:         tier.weight,
			baseReward:     tierReward,
			highMultiplier: highMultiplier,
		})
		totalWeight += tier.weight
	}

	if redirectedWeight > 0 && minRequired == 0 {
		candidates = append(candidates, rewardCandidate{tierID: "disabled_to_none", source: sourceBaseRTP, pool: req.pool, weight: redirectedWeight})
		totalWeight += redirectedWeight
	}

	// 房间气氛和活动补贴只能在基础 RTP 当前不强制追平时进入候选；它们不承担基础 RTP 结清责任。
	if minRequired == 0 {
		roomCandidates := roomAtmosphereCandidates(req.cost, req.pool, req.roomAtmosphere, riskCapacity)
		for _, candidate := range roomCandidates {
			candidates = append(candidates, candidate)
			totalWeight += candidate.weight
		}
		activityCandidates := activitySubsidyCandidates(req.cost, req.pool, req.activity, riskCapacity)
		for _, candidate := range activityCandidates {
			candidates = append(candidates, candidate)
			totalWeight += candidate.weight
		}
		if req.stageFeedback {
			candidates = append(candidates, rewardCandidate{tierID: "stage_feedback", source: sourcePresentation, pool: req.pool, weight: 5_000, presentation: true})
			totalWeight += 5_000
		}
	}

	if len(candidates) == 0 {
		tableMaxReward := req.table.maxRewardFor(req.cost)
		if minRequired > tableMaxReward || minRequired > baseMaxAllowed {
			return drawOutcome{}, fmt.Errorf("cannot settle production windows: min_required=%d base_max_allowed=%d rtp_max=%d pool_capacity=%d risk_capacity=%d table_max=%d", minRequired, baseMaxAllowed, rtpMaxAllowed, basePoolCapacity, riskCapacity, tableMaxReward)
		}
		highMultiplier := isHighMultiplier(minRequired, req.highMultiplierMinReward)
		if highMultiplier && !hasProductionHighWater(minRequired, req) {
			return drawOutcome{}, fmt.Errorf("cannot pay high multiplier correction before high water: min_required=%d high_tier_min=%d", minRequired, req.highMultiplierMinReward)
		}
		outcome.rewardCandidate = rewardCandidate{
			tierID:         correctionTierID(minRequired),
			source:         sourceBaseRTP,
			pool:           req.pool,
			baseReward:     minRequired,
			correction:     minRequired > 0,
			highMultiplier: highMultiplier,
		}
		return outcome, nil
	}

	roll := req.rng.Int63n(totalWeight)
	selected := candidates[0]
	for _, candidate := range candidates {
		if roll < candidate.weight {
			selected = candidate
			break
		}
		roll -= candidate.weight
	}
	outcome.rewardCandidate = selected
	return outcome, nil
}

func roomAtmosphereCandidates(cost int64, pool experiencePool, room *roomAtmospherePool, riskCapacity int64) []rewardCandidate {
	capacity := minInt64(room.capacity(), riskCapacity)
	candidates := make([]rewardCandidate, 0, 2)
	if capacity >= cost {
		candidates = append(candidates, rewardCandidate{
			tierID:     "room_shared_1x",
			source:     sourceRoomAtmosphere,
			pool:       pool,
			weight:     1_200,
			roomReward: cost,
		})
	}
	if capacity >= cost*5 {
		candidates = append(candidates, rewardCandidate{
			tierID:     "room_burst_5x",
			source:     sourceRoomAtmosphere,
			pool:       pool,
			weight:     200,
			roomReward: cost * 5,
		})
	}
	return candidates
}

func activitySubsidyCandidates(cost int64, pool experiencePool, activity *activityBudget, riskCapacity int64) []rewardCandidate {
	capacity := minInt64(activity.remaining(), riskCapacity)
	if capacity < cost/2 {
		if activity.enabled {
			activity.exhaustedHit++
		}
		return nil
	}
	return []rewardCandidate{{
		tierID:         "activity_rebate_0_5x",
		source:         sourceActivity,
		pool:           pool,
		weight:         400,
		activityReward: cost / 2,
	}}
}

func hasProductionHighWater(reward int64, req drawRequest) bool {
	if req.globalRTP.highWaterHeadroom() < reward || req.giftRTP.highWaterHeadroom() < reward {
		return false
	}
	requiredPoolCapacity := reward * req.highWaterPoolMultiple
	return weightedPoolCapacity(req.platformPool, req.roomPool, req.giftPool, req.weights) >= requiredPoolCapacity
}

type applyRequest struct {
	user           *userStat
	room           *roomState
	anchor         *anchorState
	device         *deviceState
	activity       *activityBudget
	globalRTP      *rtpWindow
	giftRTP        *rtpWindow
	platformPool   *accountingPool
	roomBasePool   *accountingPool
	giftPool       *accountingPool
	roomAtmosphere *roomAtmospherePool
	weights        poolWeights
	cost           int64
	outcome        drawOutcome
	total          *aggregateStat
	baseRTPPPM     int64
	tracker100K    *blockTracker
	tracker1M      *blockTracker
}

func applyProductionDraw(req applyRequest) {
	effectiveReward := req.outcome.effectiveReward()

	req.user.balance -= req.cost
	req.user.balance += effectiveReward
	req.user.draws++
	req.user.wager += req.cost
	req.user.basePayout += req.outcome.baseReward
	req.user.roomPayout += req.outcome.roomReward
	req.user.activityPayout += req.outcome.activityReward
	req.user.effectivePay += effectiveReward
	if effectiveReward > 0 {
		req.user.hits++
	}
	if effectiveReward > req.user.maxWin {
		req.user.maxWin = effectiveReward
	}

	req.user.hourly.add(effectiveReward)
	req.user.daily.add(effectiveReward)
	req.device.daily.add(effectiveReward)
	req.room.hourly.add(effectiveReward)
	req.anchor.daily.add(effectiveReward)

	req.globalRTP.apply(req.cost, req.outcome.baseReward)
	req.giftRTP.apply(req.cost, req.outcome.baseReward)
	debitBasePools(req.outcome.baseReward, req.weights, req.platformPool, req.roomBasePool, req.giftPool)
	req.roomAtmosphere.debit(req.outcome.roomReward)
	req.activity.debit(req.outcome.activityReward)

	applyAggregate(req.total, req.cost, req.outcome)
	req.tracker100K.observe(req.total.draws, req.total.wager, req.total.basePayout, req.baseRTPPPM)
	req.tracker1M.observe(req.total.draws, req.total.wager, req.total.basePayout, req.baseRTPPPM)
}

func applyAggregate(stat *aggregateStat, cost int64, outcome drawOutcome) {
	stat.draws++
	stat.wager += cost
	stat.basePayout += outcome.baseReward
	stat.roomPayout += outcome.roomReward
	stat.activityPayout += outcome.activityReward
	stat.effectivePayout += outcome.effectiveReward()
	if outcome.effectiveReward() > 0 {
		stat.hits++
	}
	if outcome.effectiveReward() > stat.maxWin {
		stat.maxWin = outcome.effectiveReward()
	}
	if outcome.correction {
		stat.correctionHits++
		stat.correctionPayout += outcome.baseReward
	}
	switch outcome.pool {
	case novicePool:
		stat.noviceDraws++
	case intermediatePool:
		stat.intermediateDraws++
	case advancedPool:
		stat.advancedDraws++
	}
	if outcome.stageFeedback {
		stat.stageFeedbacks++
	}
	if outcome.presentation {
		stat.presentationHits++
	}
	if outcome.highMultiplier {
		stat.highMultiplierHits++
	}
	if outcome.riskCapLimited {
		stat.riskCapLimitedDraws++
	}
	if outcome.poolCapLimited {
		stat.poolCapLimitedDraws++
	}
	if outcome.highWaterLimited {
		stat.highWaterLimitedDraws++
	}
}

func newRTPWindow(name string, windowSize, cost, targetPPM, minFutureMax int64) *rtpWindow {
	return &rtpWindow{name: name, windowSize: windowSize, cost: cost, targetPPM: targetPPM, minFutureMax: minFutureMax}
}

func (w *rtpWindow) ensureOpen() {
	if w.remainingDraws > 0 {
		return
	}
	w.windowIndex++
	w.remainingDraws = w.windowSize
	w.remainingPayout = 0
}

func (w *rtpWindow) accrue(cost int64) {
	targetMicros := cost*w.targetPPM + w.carryMicros
	w.remainingPayout += targetMicros / ppmScale
	w.carryMicros = targetMicros % ppmScale
}

func (w *rtpWindow) minRequired() int64 {
	if w.remainingDraws <= 1 {
		return w.remainingPayout
	}
	required := w.remainingPayout - w.minFutureMax*(w.remainingDraws-1)
	if required < 0 {
		return 0
	}
	return required
}

func (w *rtpWindow) apply(cost, reward int64) {
	w.remainingPayout -= reward
	w.remainingDraws--
	w.totalWager += cost
	w.totalPayout += reward
}

func (w *rtpWindow) highWaterHeadroom() int64 {
	if w.remainingDraws <= 0 {
		return 0
	}
	// 动态 stake 模型只把已发生抽奖的目标返奖计入窗口；这里的剩余应付就是已沉淀的高水位。
	return maxInt64(0, w.remainingPayout)
}

func defaultNoviceTierTable(cost int64) tierTable {
	return tierTable{
		maxReward: cost * 20,
		maxNum:    20,
		maxDen:    1,
		tiers: []rewardTier{
			{id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 720000},
			{id: "novice_feedback_0_2x", reward: cost / 5, multiplierNum: 1, multiplierDen: 5, weight: 90000},
			{id: "novice_rebate_0_5x", reward: cost / 2, multiplierNum: 1, multiplierDen: 2, weight: 70000},
			{id: "novice_rebate_1x", reward: cost, multiplierNum: 1, multiplierDen: 1, weight: 70000},
			{id: "novice_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 35000},
			{id: "novice_small_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 12000},
			{id: "novice_medium_10x", reward: cost * 10, multiplierNum: 10, multiplierDen: 1, weight: 2500},
			{id: "novice_cap_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 500},
		},
	}
}

func defaultIntermediateTierTable(cost int64) tierTable {
	return tierTable{
		maxReward: cost * 50,
		maxNum:    50,
		maxDen:    1,
		tiers: []rewardTier{
			{id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 830000},
			{id: "inter_rebate_0_5x", reward: cost / 2, multiplierNum: 1, multiplierDen: 2, weight: 25000},
			{id: "inter_rebate_1x", reward: cost, multiplierNum: 1, multiplierDen: 1, weight: 40000},
			{id: "inter_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 45000},
			{id: "inter_medium_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 35000},
			{id: "inter_large_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 20000, highWaterOnly: true},
			{id: "inter_large_50x", reward: cost * 50, multiplierNum: 50, multiplierDen: 1, weight: 5000, highWaterOnly: true},
		},
	}
}

func defaultAdvancedTierTable(cost int64) tierTable {
	return tierTable{
		maxReward: cost * 500,
		maxNum:    500,
		maxDen:    1,
		tiers: []rewardTier{
			{id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 900000},
			{id: "adv_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 30000},
			{id: "adv_medium_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 30000},
			{id: "adv_large_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 30000, highWaterOnly: true},
			{id: "adv_large_100x", reward: cost * 100, multiplierNum: 100, multiplierDen: 1, weight: 9000, highWaterOnly: true},
			{id: "adv_jackpot_500x", reward: cost * 500, multiplierNum: 500, multiplierDen: 1, weight: 1000, highWaterOnly: true},
		},
	}
}

func selectExperiencePool(paidDrawNumber, noviceDrawLimit, intermediateDrawLimit int64) experiencePool {
	if noviceDrawLimit > 0 && paidDrawNumber <= noviceDrawLimit {
		return novicePool
	}
	if intermediateDrawLimit > 0 && paidDrawNumber <= intermediateDrawLimit {
		return intermediatePool
	}
	return advancedPool
}

func tableForPool(pool experiencePool, noviceTable, intermediateTable, advancedTable tierTable) tierTable {
	switch pool {
	case novicePool:
		return noviceTable
	case intermediatePool:
		return intermediateTable
	default:
		return advancedTable
	}
}

func hasStageFeedback(paidDrawNumber, noviceDrawLimit int64) bool {
	if noviceDrawLimit <= 0 || paidDrawNumber <= 0 || paidDrawNumber > noviceDrawLimit {
		return false
	}
	return paidDrawNumber == 1 ||
		paidDrawNumber == noviceDrawLimit/4 ||
		paidDrawNumber == noviceDrawLimit/2 ||
		paidDrawNumber == noviceDrawLimit*3/4 ||
		paidDrawNumber == noviceDrawLimit
}

func buildRooms(count int, initialBasePool, baseReserve, initialAtmosphere, atmosphereReserve int64) []roomState {
	rooms := make([]roomState, count)
	for i := range rooms {
		rooms[i] = roomState{
			id:       i,
			anchorID: i,
			basePool: accountingPool{
				name:         fmt.Sprintf("room:%d", i+1),
				balance:      initialBasePool,
				reserveFloor: baseReserve,
			},
			atmosphere: roomAtmospherePool{
				roomID:       i,
				balance:      initialAtmosphere,
				reserveFloor: atmosphereReserve,
			},
		}
	}
	return rooms
}

func buildAnchors(count int) []anchorState {
	anchors := make([]anchorState, count)
	for i := range anchors {
		anchors[i].id = i
	}
	return anchors
}

func buildDevices(count int) []deviceState {
	devices := make([]deviceState, count)
	for i := range devices {
		devices[i].id = i
	}
	return devices
}

func creditBasePools(cost, poolRatePPM int64, weights poolWeights, platform, room, gift *accountingPool) {
	poolIn := cost * poolRatePPM / ppmScale
	platformIn, roomIn, giftIn := splitWeighted(poolIn, weights)
	platform.credit(platformIn)
	room.credit(roomIn)
	gift.credit(giftIn)
}

func debitBasePools(reward int64, weights poolWeights, platform, room, gift *accountingPool) {
	platformOut, roomOut, giftOut := splitWeighted(reward, weights)
	platform.debit(platformOut)
	room.debit(roomOut)
	gift.debit(giftOut)
}

func splitWeighted(amount int64, weights poolWeights) (int64, int64, int64) {
	platformAmount := weightedAmount(amount, weights.platformPPM)
	roomAmount := weightedAmount(amount, weights.roomPPM)
	giftAmount := amount - platformAmount - roomAmount
	return platformAmount, roomAmount, giftAmount
}

func weightedAmount(amount, weightPPM int64) int64 {
	return amount * weightPPM / ppmScale
}

func weightedPoolCapacity(platform, room, gift *accountingPool, weights poolWeights) int64 {
	capacity := int64(math.MaxInt64)
	if weights.platformPPM > 0 {
		capacity = minInt64(capacity, platform.capacity()*ppmScale/weights.platformPPM)
	}
	if weights.roomPPM > 0 {
		capacity = minInt64(capacity, room.capacity()*ppmScale/weights.roomPPM)
	}
	if weights.giftPPM > 0 {
		capacity = minInt64(capacity, gift.capacity()*ppmScale/weights.giftPPM)
	}
	if capacity == int64(math.MaxInt64) {
		return 0
	}
	return capacity
}

func riskRewardCapacity(user *userStat, device *deviceState, room *roomState, anchor *anchorState, risk riskLimits) int64 {
	capacity := risk.singlePayoutCap
	capacity = minInt64(capacity, user.hourly.remaining(risk.userHourlyCap))
	capacity = minInt64(capacity, user.daily.remaining(risk.userDailyCap))
	capacity = minInt64(capacity, device.daily.remaining(risk.deviceDailyCap))
	capacity = minInt64(capacity, room.hourly.remaining(risk.roomHourlyCap))
	capacity = minInt64(capacity, anchor.daily.remaining(risk.anchorDailyCap))
	if capacity < 0 {
		return 0
	}
	return capacity
}

type riskInput struct {
	cost           int64
	rtpPPM         int64
	drawsPerHour   int64
	users          int
	rooms          int
	singleCap      int64
	userHourlyCap  int64
	userDailyCap   int64
	deviceDailyCap int64
	roomHourlyCap  int64
	anchorDailyCap int64
}

func buildRiskLimits(input riskInput) riskLimits {
	expectedUserHour := expectedPayoutForDraws(input.cost, input.rtpPPM, input.drawsPerHour)
	expectedUserDay := expectedPayoutForDraws(input.cost, input.rtpPPM, input.drawsPerHour*24)
	usersPerRoom := int64((input.users + input.rooms - 1) / input.rooms)
	expectedRoomHour := expectedUserHour * usersPerRoom
	expectedRoomDay := expectedUserDay * usersPerRoom

	if input.singleCap == 0 {
		input.singleCap = input.cost * 500
	}
	if input.userHourlyCap == 0 {
		input.userHourlyCap = expectedUserHour * 2
	}
	if input.userDailyCap == 0 {
		input.userDailyCap = expectedUserDay * 3 / 2
	}
	if input.deviceDailyCap == 0 {
		input.deviceDailyCap = expectedUserDay * 5 / 2
	}
	if input.roomHourlyCap == 0 {
		input.roomHourlyCap = expectedRoomHour * 2
	}
	if input.anchorDailyCap == 0 {
		input.anchorDailyCap = expectedRoomDay * 3 / 2
	}
	return riskLimits{
		singlePayoutCap: input.singleCap,
		userHourlyCap:   input.userHourlyCap,
		userDailyCap:    input.userDailyCap,
		deviceDailyCap:  input.deviceDailyCap,
		roomHourlyCap:   input.roomHourlyCap,
		anchorDailyCap:  input.anchorDailyCap,
		drawsPerHour:    input.drawsPerHour,
	}
}

func buildTargetDraws(users int, fixed, minDraws, maxDraws int64, unique bool, rng *rand.Rand) []int64 {
	targets := make([]int64, users)
	if minDraws == 0 && maxDraws == 0 {
		for i := range targets {
			targets[i] = fixed
		}
		return targets
	}

	if !unique {
		for i := range targets {
			targets[i] = minDraws + rng.Int63n(maxDraws-minDraws+1)
		}
		return targets
	}

	span := int(maxDraws - minDraws + 1)
	perm := rng.Perm(span)
	for i := range targets {
		targets[i] = minDraws + int64(perm[i])
	}
	return targets
}

type resultInput struct {
	users              int
	devices            int
	rooms              int
	drawsPerUser       int64
	randomDraws        bool
	drawsMin           int64
	drawsMax           int64
	uniqueDraws        bool
	initialCoins       int64
	cost               int64
	costMin            int64
	costMax            int64
	randomCost         bool
	baseRTPPPM         int64
	poolRatePPM        int64
	globalWindow       int64
	giftWindow         int64
	noviceDraws        int64
	intermediateDraws  int64
	risk               riskLimits
	highTierMin        int64
	highPoolMultiple   int64
	roomAtmosphereRate int64
	activityBudget     int64
	activityDailyLimit int64
	seed               int64
}

func printResult(input resultInput, total *aggregateStat, users []userStat, rooms []roomState, platformPool, giftPool *accountingPool, activity *activityBudget, globalRTP, giftRTP *rtpWindow, tracker100K, tracker1M *blockTracker) {
	fmt.Println("Lucky Gift V2 Production Control Demo Result")
	drawsLabel := fmt.Sprintf("draws_per_user=%d", input.drawsPerUser)
	if input.randomDraws {
		drawsLabel = fmt.Sprintf("draws_per_user=random[%d,%d] unique=%t", input.drawsMin, input.drawsMax, input.uniqueDraws)
	}
	fmt.Printf("users=%d devices=%d rooms=%d %s paid_draws=%s seed=%d\n", input.users, input.devices, input.rooms, drawsLabel, comma(total.draws), input.seed)
	costLabel := fmt.Sprintf("%s", comma(input.cost))
	if input.randomCost {
		costLabel = fmt.Sprintf("random[%s,%s]", comma(input.costMin), comma(input.costMax))
	}
	fmt.Printf("initial_coins=%s cost_per_draw=%s base_rtp_target=%.4f%% pool_rate=%.4f%% global_window=%s gift_window=%s\n", comma(input.initialCoins), costLabel, float64(input.baseRTPPPM)/10000, float64(input.poolRatePPM)/10000, comma(input.globalWindow), comma(input.giftWindow))
	fmt.Printf("novice_draws=%s intermediate_draws=%s high_tier_min_reward=%s high_pool_multiple=%dx\n", comma(input.noviceDraws), comma(input.intermediateDraws), comma(input.highTierMin), input.highPoolMultiple)
	fmt.Printf("single_cap=%s user_hourly_cap=%s user_daily_cap=%s device_daily_cap=%s room_hourly_cap=%s anchor_daily_cap=%s draws_per_hour=%s\n", comma(input.risk.singlePayoutCap), comma(input.risk.userHourlyCap), comma(input.risk.userDailyCap), comma(input.risk.deviceDailyCap), comma(input.risk.roomHourlyCap), comma(input.risk.anchorDailyCap), comma(input.risk.drawsPerHour))
	fmt.Printf("room_atmosphere_rate=%.4f%% activity_budget=%s activity_daily_limit=%s\n", float64(input.roomAtmosphereRate)/10000, comma(input.activityBudget), comma(input.activityDailyLimit))
	fmt.Println()

	fmt.Println("Aggregate")
	fmt.Printf("platform_income=%s\n", comma(total.wager))
	fmt.Printf("base_reward_payout=%s\n", comma(total.basePayout))
	fmt.Printf("room_atmosphere_payout=%s\n", comma(total.roomPayout))
	fmt.Printf("activity_subsidy_payout=%s\n", comma(total.activityPayout))
	fmt.Printf("total_user_visible_payout=%s\n", comma(total.effectivePayout))
	fmt.Printf("platform_net_after_visible_payout=%s\n", comma(total.wager-total.effectivePayout))
	fmt.Printf("base_rtp=%.6f%% deviation=%+.6fpp\n", rtpPercent(total.wager, total.basePayout), deviationPP(total.wager, total.basePayout, input.baseRTPPPM))
	fmt.Printf("effective_rtp=%.6f%% subsidy_lift=%+.6fpp\n", rtpPercent(total.wager, total.effectivePayout), rtpPercent(total.wager, total.effectivePayout)-rtpPercent(total.wager, total.basePayout))
	fmt.Printf("hits=%s hit_rate=%.4f%% max_visible_win=%s\n", comma(total.hits), ratioPercent(total.hits, total.draws), comma(total.maxWin))
	fmt.Printf("rtp_balance_hits=%s rtp_balance_payout=%s\n", comma(total.correctionHits), comma(total.correctionPayout))
	fmt.Println()

	fmt.Println("Experience And Risk")
	fmt.Printf("novice_draws=%s intermediate_draws=%s advanced_draws=%s stage_feedback_events=%s presentation_hits=%s\n", comma(total.noviceDraws), comma(total.intermediateDraws), comma(total.advancedDraws), comma(total.stageFeedbacks), comma(total.presentationHits))
	fmt.Printf("high_multiplier_hits=%s high_water_limited_draws=%s risk_cap_limited_draws=%s pool_cap_limited_draws=%s\n", comma(total.highMultiplierHits), comma(total.highWaterLimitedDraws), comma(total.riskCapLimitedDraws), comma(total.poolCapLimitedDraws))
	fmt.Println()

	fmt.Println("RTP Windows")
	fmt.Printf("global_base_rtp=%.6f%% gift_base_rtp=%.6f%%\n", rtpPercent(globalRTP.totalWager, globalRTP.totalPayout), rtpPercent(giftRTP.totalWager, giftRTP.totalPayout))
	fmt.Printf("100k_blocks=%d max_abs_deviation=%+.6fpp status=%s\n", tracker100K.closed, tracker100K.maxAbsDevPP, passFail(tracker100K.maxAbsDevPP < 1))
	fmt.Printf("1m_blocks=%d max_abs_deviation=%+.6fpp status=%s\n", tracker1M.closed, tracker1M.maxAbsDevPP, passFail(tracker1M.maxAbsDevPP < 0.5))
	fmt.Println()

	printPoolModel(rooms, platformPool, giftPool, activity)
	printUserModel(users)
}

func printPoolModel(rooms []roomState, platformPool, giftPool *accountingPool, activity *activityBudget) {
	roomBalances := make([]int64, len(rooms))
	atmosphereBalances := make([]int64, len(rooms))
	for i := range rooms {
		roomBalances[i] = rooms[i].basePool.balance
		atmosphereBalances[i] = rooms[i].atmosphere.balance
	}
	sort.Slice(roomBalances, func(i, j int) bool { return roomBalances[i] < roomBalances[j] })
	sort.Slice(atmosphereBalances, func(i, j int) bool { return atmosphereBalances[i] < atmosphereBalances[j] })

	fmt.Println("Pool Model")
	fmt.Printf("platform_pool_balance=%s gift_pool_balance=%s activity_remaining=%s\n", comma(platformPool.balance), comma(giftPool.balance), comma(activity.remaining()))
	fmt.Printf("room_pool_balance_p05=%s median=%s p95=%s\n", comma(quantile(roomBalances, 0.05)), comma(quantile(roomBalances, 0.50)), comma(quantile(roomBalances, 0.95)))
	fmt.Printf("room_atmosphere_balance_p05=%s median=%s p95=%s\n", comma(quantile(atmosphereBalances, 0.05)), comma(quantile(atmosphereBalances, 0.50)), comma(quantile(atmosphereBalances, 0.95)))
	fmt.Println()
}

func printUserModel(users []userStat) {
	baseRTPs := make([]float64, len(users))
	effectiveRTPs := make([]float64, len(users))
	nets := make([]int64, len(users))
	balances := make([]int64, len(users))
	for i, user := range users {
		baseRTPs[i] = rtpPercent(user.wager, user.basePayout)
		effectiveRTPs[i] = rtpPercent(user.wager, user.effectivePay)
		nets[i] = user.effectivePay - user.wager
		balances[i] = user.balance
	}
	sort.Slice(baseRTPs, func(i, j int) bool { return baseRTPs[i] < baseRTPs[j] })
	sort.Slice(effectiveRTPs, func(i, j int) bool { return effectiveRTPs[i] < effectiveRTPs[j] })
	sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] })
	sort.Slice(balances, func(i, j int) bool { return balances[i] < balances[j] })

	fmt.Println("User Result Model")
	fmt.Printf("user_base_rtp_p05=%.6f%% median=%.6f%% p95=%.6f%%\n", quantileFloat(baseRTPs, 0.05), quantileFloat(baseRTPs, 0.50), quantileFloat(baseRTPs, 0.95))
	fmt.Printf("user_effective_rtp_p05=%.6f%% median=%.6f%% p95=%.6f%%\n", quantileFloat(effectiveRTPs, 0.05), quantileFloat(effectiveRTPs, 0.50), quantileFloat(effectiveRTPs, 0.95))
	fmt.Printf("user_net_p05=%s median=%s p95=%s\n", comma(quantile(nets, 0.05)), comma(quantile(nets, 0.50)), comma(quantile(nets, 0.95)))
	fmt.Printf("final_balance_p05=%s median=%s p95=%s\n", comma(quantile(balances, 0.05)), comma(quantile(balances, 0.50)), comma(quantile(balances, 0.95)))
}

func writeUserCSV(path string, users []userStat) error {
	path = strings.TrimSpace(path)
	if path == "" {
		return nil
	}
	if dir := filepath.Dir(path); dir != "." && dir != "" {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
	}
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()

	writer := csv.NewWriter(file)
	defer writer.Flush()
	if err := writer.Write([]string{
		"user_id", "device_id", "room_id", "anchor_id", "target_draws", "actual_draws",
		"wager", "base_payout", "room_atmosphere_payout", "activity_subsidy_payout",
		"effective_payout", "base_rtp_percent", "effective_rtp_percent", "hits",
		"max_win", "net", "final_balance",
	}); err != nil {
		return err
	}
	for _, user := range users {
		if err := writer.Write([]string{
			fmt.Sprintf("%d", user.id),
			fmt.Sprintf("%d", user.deviceID+1),
			fmt.Sprintf("%d", user.roomID+1),
			fmt.Sprintf("%d", user.anchorID+1),
			fmt.Sprintf("%d", user.targetDraws),
			fmt.Sprintf("%d", user.draws),
			fmt.Sprintf("%d", user.wager),
			fmt.Sprintf("%d", user.basePayout),
			fmt.Sprintf("%d", user.roomPayout),
			fmt.Sprintf("%d", user.activityPayout),
			fmt.Sprintf("%d", user.effectivePay),
			fmt.Sprintf("%.6f", rtpPercent(user.wager, user.basePayout)),
			fmt.Sprintf("%.6f", rtpPercent(user.wager, user.effectivePay)),
			fmt.Sprintf("%d", user.hits),
			fmt.Sprintf("%d", user.maxWin),
			fmt.Sprintf("%d", user.effectivePay-user.wager),
			fmt.Sprintf("%d", user.balance),
		}); err != nil {
			return err
		}
	}
	return writer.Error()
}

func (b *blockTracker) observe(totalDraws, totalWager, totalPayout, rtpPPM int64) {
	if totalDraws == 0 || totalDraws%b.size != 0 {
		return
	}
	blockWager := totalWager - b.lastWager
	blockPayout := totalPayout - b.lastPayout
	devPP := deviationPP(blockWager, blockPayout, rtpPPM)
	if abs := math.Abs(devPP); abs > b.maxAbsDevPP {
		b.maxAbsDevPP = abs
	}
	b.closed++
	b.lastWager = totalWager
	b.lastPayout = totalPayout
}

func percentToPPM(percent float64) int64 {
	return int64(math.Round(percent * 10000))
}

func expectedPayoutForDraws(cost, rtpPPM, draws int64) int64 {
	return (cost*rtpPPM*draws + ppmScale - 1) / ppmScale
}

func validateRTPCapacity(cost, rtpPPM, maxReward int64) error {
	targetAverage := float64(cost) * float64(rtpPPM) / float64(ppmScale)
	if targetAverage > float64(maxReward) {
		return fmt.Errorf("target average payout %.2f exceeds max future reward %d", targetAverage, maxReward)
	}
	return nil
}

func validateRiskLimits(cost, rtpPPM int64, risk riskLimits) error {
	if risk.singlePayoutCap <= 0 || risk.userHourlyCap <= 0 || risk.userDailyCap <= 0 || risk.deviceDailyCap <= 0 || risk.roomHourlyCap <= 0 || risk.anchorDailyCap <= 0 {
		return fmt.Errorf("risk caps must be positive")
	}
	expectedUserHour := expectedPayoutForDraws(cost, rtpPPM, risk.drawsPerHour)
	expectedUserDay := expectedPayoutForDraws(cost, rtpPPM, risk.drawsPerHour*24)
	if risk.userHourlyCap < expectedUserHour {
		return fmt.Errorf("user hourly cap %d is below expected hourly base payout %d", risk.userHourlyCap, expectedUserHour)
	}
	if risk.userDailyCap < expectedUserDay {
		return fmt.Errorf("user daily cap %d is below expected daily base payout %d", risk.userDailyCap, expectedUserDay)
	}
	return nil
}

func correctionTierID(reward int64) string {
	if reward == 0 {
		return "none"
	}
	return "rtp_balance"
}

func isHighMultiplier(reward, highMultiplierMinReward int64) bool {
	return highMultiplierMinReward > 0 && reward >= highMultiplierMinReward
}

func minInt64(a, b int64) int64 {
	if a < b {
		return a
	}
	return b
}

func maxInt64(a, b int64) int64 {
	if a > b {
		return a
	}
	return b
}

func quantile(sorted []int64, q float64) int64 {
	if len(sorted) == 0 {
		return 0
	}
	index := int(math.Round(q * float64(len(sorted)-1)))
	return sorted[index]
}

func quantileFloat(sorted []float64, q float64) float64 {
	if len(sorted) == 0 {
		return 0
	}
	index := int(math.Round(q * float64(len(sorted)-1)))
	return sorted[index]
}

func rtpPercent(wager, payout int64) float64 {
	if wager == 0 {
		return 0
	}
	return float64(payout) * 100 / float64(wager)
}

func ratioPercent(numerator, denominator int64) float64 {
	if denominator == 0 {
		return 0
	}
	return float64(numerator) * 100 / float64(denominator)
}

func deviationPP(wager, payout, rtpPPM int64) float64 {
	return rtpPercent(wager, payout) - float64(rtpPPM)/10000
}

func passFail(ok bool) string {
	if ok {
		return "PASS"
	}
	return "FAIL"
}

func comma(value int64) string {
	if value == 0 {
		return "0"
	}
	negative := value < 0
	if negative {
		value = -value
	}
	raw := fmt.Sprintf("%d", value)
	var b strings.Builder
	if negative {
		b.WriteByte('-')
	}
	prefix := len(raw) % 3
	if prefix == 0 {
		prefix = 3
	}
	b.WriteString(raw[:prefix])
	for i := prefix; i < len(raw); i += 3 {
		b.WriteByte(',')
		b.WriteString(raw[i : i+3])
	}
	return b.String()
}
