package main

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

const ppmScale int64 = 1_000_000

type poolKind string

const (
	poolNovice       poolKind = "novice"
	poolIntermediate poolKind = "intermediate"
	poolAdvanced     poolKind = "advanced"
)

type tier struct {
	id     string
	reward int64
	weight int64
}

// poolProfile is a UX profile, not a money owner. RTP is enforced by the
// controller; these weights only decide which payable tier is preferred.
type poolProfile struct {
	kind      poolKind
	maxReward int64
	tiers     []tier
}

type drawOutcome struct {
	tierID     string
	reward     int64
	correction bool
}

// rtpController keeps one aggregate payout budget per fixed paid-draw window.
// It filters before random selection, so the demo never rewrites a result
// after the tier has been selected.
type rtpController struct {
	windowSize   int64
	cost         int64
	rtpPPM       int64
	minFutureMax int64

	carryMicros     int64
	windowIndex     int64
	remainingDraws  int64
	remainingPayout int64
}

type userStat struct {
	targetDraws int64
	draws       int64
	wager       int64
	payout      int64
	hits        int64
	maxWin      int64
	balance     int64
	topUp       int64
	stopped     bool
}

type aggregateStat struct {
	draws            int64
	wager            int64
	payout           int64
	hits             int64
	maxWin           int64
	topUp            int64
	correctionHits   int64
	correctionPayout int64
	stoppedUsers     int64
}

type blockTracker struct {
	size           int64
	lastDraws      int64
	lastWager      int64
	lastPayout     int64
	closedBlocks   int64
	maxAbsDevPP    float64
	lastBlockDevPP float64
}

func main() {
	usersFlag := flag.Int("users", 1000, "number of simulated users")
	drawsFlag := flag.Int64("draws", 100000, "paid draw attempts per user")
	drawsMinFlag := flag.Int64("draws-min", 0, "optional minimum paid draw attempts per user")
	drawsMaxFlag := flag.Int64("draws-max", 0, "optional maximum paid draw attempts per user")
	rtpFlag := flag.Float64("rtp", 95, "target RTP as percent, so 95 means 95%")
	initialFlag := flag.Int64("initial", 10000, "initial coins per user")
	costFlag := flag.Int64("cost", 500, "coin cost per paid draw")
	windowFlag := flag.Int64("window", 100000, "RTP control window paid draws")
	noviceLimitFlag := flag.Int64("novice-draws", 2000, "inclusive paid draw count for novice pool")
	intermediateLimitFlag := flag.Int64("intermediate-draws", 20000, "inclusive paid draw count for intermediate pool")
	seedFlag := flag.Int64("seed", 20260516, "deterministic random seed")
	strictWalletFlag := flag.Bool("strict-wallet", false, "stop a user when balance is below draw cost instead of adding test top-up")
	userCSVFlag := flag.String("user-csv", "", "optional path to write per-user RTP and final balance CSV")
	flag.Parse()

	if *usersFlag <= 0 || *initialFlag < 0 || *costFlag <= 0 || *windowFlag <= 0 {
		log.Fatal("users, cost, and window must be positive; initial must be non-negative")
	}
	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")
		}
	} else if *drawsFlag <= 0 {
		log.Fatal("draws must be positive when draws-min/draws-max are not set")
	}
	if *noviceLimitFlag <= 0 || *intermediateLimitFlag < *noviceLimitFlag {
		log.Fatal("pool limits must satisfy 0 < novice-draws <= intermediate-draws")
	}
	rtpPPM := percentToPPM(*rtpFlag)
	if rtpPPM < 0 {
		log.Fatal("rtp must be non-negative")
	}

	profiles := defaultProfiles(*costFlag)
	minFutureMax := minProfileMaxReward(profiles)
	if err := validateRTPCapacity(*costFlag, rtpPPM, minFutureMax); err != nil {
		log.Fatal(err)
	}

	rng := rand.New(rand.NewSource(*seedFlag))
	controller := newRTPController(*windowFlag, *costFlag, rtpPPM, minFutureMax)
	users := make([]userStat, *usersFlag)
	for i := range users {
		users[i].balance = *initialFlag
		users[i].targetDraws = *drawsFlag
		if randomDraws {
			users[i].targetDraws = *drawsMinFlag + rng.Int63n(*drawsMaxFlag-*drawsMinFlag+1)
		}
	}

	total := &aggregateStat{}
	poolStats := map[poolKind]*aggregateStat{
		poolNovice:       {},
		poolIntermediate: {},
		poolAdvanced:     {},
	}
	tracker100K := &blockTracker{size: 100000}
	tracker1M := &blockTracker{size: 1000000}

	maxRounds := *drawsFlag
	if randomDraws {
		maxRounds = *drawsMaxFlag
	}
	for drawRound := int64(0); drawRound < maxRounds; drawRound++ {
		for userIndex := range users {
			u := &users[userIndex]
			if u.stopped {
				continue
			}
			if u.draws >= u.targetDraws {
				continue
			}
			if u.balance < *costFlag {
				if *strictWalletFlag {
					u.stopped = true
					total.stoppedUsers++
					continue
				}
				// Fixed-draw RTP pressure tests need every requested draw to be paid.
				// Top-up is test bankroll only; it is not counted as payout or RTP.
				topUp := *initialFlag
				if need := *costFlag - u.balance; topUp < need {
					topUp = need
				}
				u.balance += topUp
				u.topUp += topUp
				total.topUp += topUp
			}

			pool := choosePool(u.draws+1, *noviceLimitFlag, *intermediateLimitFlag)
			outcome, err := controller.next(profiles[pool], rng)
			if err != nil {
				log.Fatalf("draw failed after %d paid draws: %v", total.draws, err)
			}

			u.balance -= *costFlag
			u.balance += outcome.reward
			applyDraw(u, total, *costFlag, outcome)
			applyDraw(nil, poolStats[pool], *costFlag, outcome)

			tracker100K.observe(total.draws, total.wager, total.payout, rtpPPM)
			tracker1M.observe(total.draws, total.wager, total.payout, rtpPPM)
		}
	}

	if *strictWalletFlag {
		for i := range users {
			if users[i].stopped {
				continue
			}
			if users[i].balance < *costFlag {
				total.stoppedUsers++
			}
		}
	}

	printResult(resultInput{
		users:             *usersFlag,
		drawsPerUser:      *drawsFlag,
		randomDraws:       randomDraws,
		drawsMin:          *drawsMinFlag,
		drawsMax:          *drawsMaxFlag,
		rtpPPM:            rtpPPM,
		initialCoins:      *initialFlag,
		cost:              *costFlag,
		windowSize:        *windowFlag,
		noviceLimit:       *noviceLimitFlag,
		intermediateLimit: *intermediateLimitFlag,
		seed:              *seedFlag,
		strictWallet:      *strictWalletFlag,
	}, total, poolStats, users, 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)
	}
}

func newRTPController(windowSize, cost, rtpPPM, minFutureMax int64) *rtpController {
	return &rtpController{
		windowSize:   windowSize,
		cost:         cost,
		rtpPPM:       rtpPPM,
		minFutureMax: minFutureMax,
	}
}

func (c *rtpController) next(profile *poolProfile, rng *rand.Rand) (drawOutcome, error) {
	if c.remainingDraws == 0 {
		c.openWindow()
	}
	if c.remainingDraws == 1 {
		return c.applyExactPayout(profile, c.remainingPayout)
	}

	// minRequired is the smallest payout that still lets future draws close the
	// current RTP window without exceeding the lowest max reward across pools.
	remainingAfter := c.remainingDraws - 1
	minRequired := c.remainingPayout - c.minFutureMax*remainingAfter
	if minRequired < 0 {
		minRequired = 0
	}

	var candidates [16]tier
	n := 0
	var totalWeight int64
	for _, candidate := range profile.tiers {
		if candidate.reward < minRequired || candidate.reward > c.remainingPayout {
			continue
		}
		candidates[n] = candidate
		n++
		totalWeight += candidate.weight
	}

	if n == 0 {
		return c.applyExactPayout(profile, minRequired)
	}

	roll := rng.Int63n(totalWeight)
	selected := candidates[0]
	for i := 0; i < n; i++ {
		if roll < candidates[i].weight {
			selected = candidates[i]
			break
		}
		roll -= candidates[i].weight
	}
	return c.applyTier(selected, false)
}

func (c *rtpController) openWindow() {
	// carryMicros preserves fractional ppm value across windows. That keeps
	// arbitrary RTP inputs stable without floating-point drift.
	targetMicros := c.windowSize*c.cost*c.rtpPPM + c.carryMicros
	c.windowIndex++
	c.remainingDraws = c.windowSize
	c.remainingPayout = targetMicros / ppmScale
	c.carryMicros = targetMicros % ppmScale
}

func (c *rtpController) applyExactPayout(profile *poolProfile, reward int64) (drawOutcome, error) {
	if reward < 0 {
		return drawOutcome{}, fmt.Errorf("negative exact reward: %d", reward)
	}
	if reward > profile.maxReward {
		return drawOutcome{}, fmt.Errorf("exact reward %d exceeds %s max reward %d", reward, profile.kind, profile.maxReward)
	}
	tierID := "none"
	correction := false
	if reward > 0 {
		// rtp_balance is a pre-random eligible balancing tier. In production it
		// should be represented in audit data, not hidden as a post-draw change.
		tierID = "rtp_balance"
		correction = true
	}
	return c.applyTier(tier{id: tierID, reward: reward, weight: 1}, correction)
}

func (c *rtpController) applyTier(selected tier, correction bool) (drawOutcome, error) {
	if selected.reward < 0 || selected.reward > c.remainingPayout {
		return drawOutcome{}, fmt.Errorf("tier %s reward %d cannot consume remaining payout %d", selected.id, selected.reward, c.remainingPayout)
	}
	c.remainingPayout -= selected.reward
	c.remainingDraws--
	return drawOutcome{
		tierID:     selected.id,
		reward:     selected.reward,
		correction: correction,
	}, nil
}

func defaultProfiles(cost int64) map[poolKind]*poolProfile {
	// The three profiles deliberately use different variance shapes while the
	// controller keeps the aggregate RTP equal to the configured target.
	return map[poolKind]*poolProfile{
		poolNovice: {
			kind:      poolNovice,
			maxReward: cost * 10,
			tiers: []tier{
				{id: "none", reward: 0, weight: 270000},
				{id: "rebate_0_5x", reward: cost / 2, weight: 200000},
				{id: "rebate_1x", reward: cost, weight: 350000},
				{id: "small_2x", reward: cost * 2, weight: 140000},
				{id: "small_5x", reward: cost * 5, weight: 35000},
				{id: "small_10x", reward: cost * 10, weight: 5000},
			},
		},
		poolIntermediate: {
			kind:      poolIntermediate,
			maxReward: cost * 50,
			tiers: []tier{
				{id: "none", reward: 0, weight: 760000},
				{id: "rebate_1x", reward: cost, weight: 100000},
				{id: "small_2x", reward: cost * 2, weight: 80000},
				{id: "medium_5x", reward: cost * 5, weight: 45000},
				{id: "medium_20x", reward: cost * 20, weight: 13000},
				{id: "medium_50x", reward: cost * 50, weight: 2000},
			},
		},
		poolAdvanced: {
			kind:      poolAdvanced,
			maxReward: cost * 500,
			tiers: []tier{
				{id: "none", reward: 0, weight: 900000},
				{id: "small_2x", reward: cost * 2, weight: 50000},
				{id: "medium_5x", reward: cost * 5, weight: 30000},
				{id: "large_20x", reward: cost * 20, weight: 15000},
				{id: "large_100x", reward: cost * 100, weight: 4500},
				{id: "jackpot_500x", reward: cost * 500, weight: 500},
			},
		},
	}
}

func choosePool(paidDrawNumber, noviceLimit, intermediateLimit int64) poolKind {
	if paidDrawNumber <= noviceLimit {
		return poolNovice
	}
	if paidDrawNumber <= intermediateLimit {
		return poolIntermediate
	}
	return poolAdvanced
}

func applyDraw(user *userStat, stat *aggregateStat, cost int64, outcome drawOutcome) {
	stat.draws++
	stat.wager += cost
	stat.payout += outcome.reward
	if outcome.reward > 0 {
		stat.hits++
	}
	if outcome.reward > stat.maxWin {
		stat.maxWin = outcome.reward
	}
	if outcome.correction {
		stat.correctionHits++
		stat.correctionPayout += outcome.reward
	}
	if user == nil {
		return
	}
	user.draws++
	user.wager += cost
	user.payout += outcome.reward
	if outcome.reward > 0 {
		user.hits++
	}
	if outcome.reward > user.maxWin {
		user.maxWin = outcome.reward
	}
}

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.lastBlockDevPP = devPP
	b.closedBlocks++
	b.lastDraws = totalDraws
	b.lastWager = totalWager
	b.lastPayout = totalPayout
}

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

func validateRTPCapacity(cost, rtpPPM, minFutureMax int64) error {
	targetAverage := float64(cost) * float64(rtpPPM) / float64(ppmScale)
	if targetAverage > float64(minFutureMax) {
		return fmt.Errorf("target average payout %.2f exceeds minimum future max reward %d; increase pool max rewards or lower RTP", targetAverage, minFutureMax)
	}
	return nil
}

func minProfileMaxReward(profiles map[poolKind]*poolProfile) int64 {
	minValue := int64(math.MaxInt64)
	for _, profile := range profiles {
		if profile.maxReward < minValue {
			minValue = profile.maxReward
		}
	}
	return minValue
}

type resultInput struct {
	users             int
	drawsPerUser      int64
	randomDraws       bool
	drawsMin          int64
	drawsMax          int64
	rtpPPM            int64
	initialCoins      int64
	cost              int64
	windowSize        int64
	noviceLimit       int64
	intermediateLimit int64
	seed              int64
	strictWallet      bool
}

func printResult(input resultInput, total *aggregateStat, poolStats map[poolKind]*aggregateStat, users []userStat, tracker100K, tracker1M *blockTracker) {
	actualRTP := rtpPercent(total.wager, total.payout)
	targetRTP := float64(input.rtpPPM) / 10000
	totalDeviation := actualRTP - targetRTP
	plannedDraws := int64(input.users) * input.drawsPerUser
	drawsLabel := fmt.Sprintf("draws_per_user=%d planned_paid_draws=%s", input.drawsPerUser, comma(plannedDraws))
	if input.randomDraws {
		drawsLabel = fmt.Sprintf("draws_per_user=random[%d,%d] actual_paid_draws=%s", input.drawsMin, input.drawsMax, comma(total.draws))
	}

	fmt.Println("Lucky Gift RTP Demo Result")
	fmt.Printf("mode=%s users=%d %s seed=%d\n", modeName(input.strictWallet), input.users, drawsLabel, input.seed)
	fmt.Printf("initial_coins=%s cost_per_draw=%s target_rtp=%.4f%% control_window=%s\n", comma(input.initialCoins), comma(input.cost), targetRTP, comma(input.windowSize))
	fmt.Printf("pool_rule=novice[1,%d] intermediate[%d,%d] advanced[%d,+inf)\n", input.noviceLimit, input.noviceLimit+1, input.intermediateLimit, input.intermediateLimit+1)
	fmt.Println()

	fmt.Println("Aggregate")
	fmt.Printf("paid_draws=%s wager=%s payout=%s actual_rtp=%.6f%% deviation=%+.6fpp\n", comma(total.draws), comma(total.wager), comma(total.payout), actualRTP, totalDeviation)
	fmt.Printf("hits=%s hit_rate=%.4f%% max_win=%s test_top_up=%s stopped_users=%d\n", comma(total.hits), ratioPercent(total.hits, total.draws), comma(total.maxWin), comma(total.topUp), total.stoppedUsers)
	fmt.Printf("rtp_balance_hits=%s rtp_balance_payout=%s\n", comma(total.correctionHits), comma(total.correctionPayout))
	fmt.Println()

	fmt.Println("RTP Window Check")
	fmt.Printf("100k_blocks=%d max_abs_deviation=%+.6fpp required=<1.000000pp status=%s\n", tracker100K.closedBlocks, tracker100K.maxAbsDevPP, passFail(tracker100K.maxAbsDevPP < 1))
	fmt.Printf("1m_blocks=%d max_abs_deviation=%+.6fpp required=<0.500000pp status=%s\n", tracker1M.closedBlocks, tracker1M.maxAbsDevPP, passFail(tracker1M.maxAbsDevPP < 0.5))
	fmt.Println()

	fmt.Println("Pool Model")
	fmt.Printf("%-14s %14s %14s %14s %12s %12s %12s\n", "pool", "draws", "wager", "payout", "rtp", "hit_rate", "max_win")
	for _, kind := range []poolKind{poolNovice, poolIntermediate, poolAdvanced} {
		stat := poolStats[kind]
		fmt.Printf("%-14s %14s %14s %14s %11.4f%% %11.4f%% %12s\n",
			kind,
			comma(stat.draws),
			comma(stat.wager),
			comma(stat.payout),
			rtpPercent(stat.wager, stat.payout),
			ratioPercent(stat.hits, stat.draws),
			comma(stat.maxWin),
		)
	}
	fmt.Println()

	printUserModel(users)
	if !input.strictWallet {
		fmt.Println()
		fmt.Println("Note")
		fmt.Println("auto-top-up only keeps the requested fixed draw count running; RTP uses wager and payout only.")
	}
}

func printUserModel(users []userStat) {
	if len(users) == 0 {
		return
	}
	nets := make([]int64, len(users))
	balances := make([]int64, len(users))
	draws := make([]int64, len(users))
	var totalNet, totalBalance, totalTopUp int64
	for i, u := range users {
		net := u.payout - u.wager
		nets[i] = net
		balances[i] = u.balance
		draws[i] = u.draws
		totalNet += net
		totalBalance += u.balance
		totalTopUp += u.topUp
	}
	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] })
	sort.Slice(draws, func(i, j int) bool { return draws[i] < draws[j] })

	fmt.Println("User Result Model")
	fmt.Printf("avg_net=%s p05_net=%s median_net=%s p95_net=%s\n", comma(totalNet/int64(len(users))), comma(quantile(nets, 0.05)), comma(quantile(nets, 0.50)), comma(quantile(nets, 0.95)))
	fmt.Printf("avg_final_balance=%s p05_balance=%s median_balance=%s p95_balance=%s\n", comma(totalBalance/int64(len(users))), comma(quantile(balances, 0.05)), comma(quantile(balances, 0.50)), comma(quantile(balances, 0.95)))
	fmt.Printf("avg_top_up=%s min_paid_draws=%s median_paid_draws=%s max_paid_draws=%s\n", comma(totalTopUp/int64(len(users))), comma(draws[0]), comma(quantile(draws, 0.50)), comma(draws[len(draws)-1]))
}

func writeUserCSV(path string, users []userStat) error {
	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", "target_draws", "paid_draws", "wager", "payout", "rtp_percent", "final_coins", "net_profit", "hit_count", "max_win", "test_top_up"}); err != nil {
		return err
	}
	for i, user := range users {
		record := []string{
			fmt.Sprintf("%d", i+1),
			fmt.Sprintf("%d", user.targetDraws),
			fmt.Sprintf("%d", user.draws),
			fmt.Sprintf("%d", user.wager),
			fmt.Sprintf("%d", user.payout),
			fmt.Sprintf("%.6f", rtpPercent(user.wager, user.payout)),
			fmt.Sprintf("%d", user.balance),
			fmt.Sprintf("%d", user.payout-user.wager),
			fmt.Sprintf("%d", user.hits),
			fmt.Sprintf("%d", user.maxWin),
			fmt.Sprintf("%d", user.topUp),
		}
		if err := writer.Write(record); err != nil {
			return err
		}
	}
	return writer.Error()
}

func quantile(sorted []int64, q float64) int64 {
	if len(sorted) == 0 {
		return 0
	}
	if q <= 0 {
		return sorted[0]
	}
	if q >= 1 {
		return sorted[len(sorted)-1]
	}
	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 modeName(strictWallet bool) string {
	if strictWallet {
		return "strict_wallet"
	}
	return "fixed_draws_with_test_top_up"
}

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()
}
