package com.jsjdesigns.poker.sim

import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.OpponentModel
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.core.Card
import com.jsjdesigns.poker.core.PreflopChart
import com.jsjdesigns.poker.core.Suit
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.DecisionContext
import com.jsjdesigns.poker.game.PlayerAgent
import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Street
import com.jsjdesigns.poker.game.Table
import kotlin.math.abs
import kotlin.math.sqrt
import kotlinx.coroutines.runBlocking
import kotlin.random.Random

private const val SMALL_BLIND = 1
private const val BIG_BLIND = 2
private const val STARTING_STACK = 200 // 100 big blinds

private enum class StackPolicy {
    RESET_EACH_HAND,
    PERSIST_WITH_REBUY,
}

internal data class ShortStackCoverage(
    var decisions: Int = 0,
    var eligiblePotDiffersFromPot: Int = 0,
    var callClampedByStack: Int = 0,
) {
    val passes: Boolean
        get() = eligiblePotDiffersFromPot > 0 && callClampedByStack > 0

    fun observe(ctx: DecisionContext) {
        decisions++
        if (ctx.eligiblePot != ctx.pot) eligiblePotDiffersFromPot++
        if (ctx.toCall > ctx.stack) callClampedByStack++
    }
}

internal class Stats(val name: String, val profile: BotProfile) {
    var net = 0L
    var hands = 0
    var vpip = 0
    var pfr = 0
    var wins = 0
    var postflopBets = 0
    var postflopCalls = 0
    var actions = 0
    var aggressiveActions = 0

    fun bbPer100(): Double = if (hands == 0) 0.0 else (net.toDouble() / BIG_BLIND) / hands * 100.0
    fun pct(n: Int): Double = if (hands == 0) 0.0 else n * 100.0 / hands
    fun aggressionFactor(): Double =
        if (postflopCalls == 0) postflopBets.toDouble() else postflopBets.toDouble() / postflopCalls
    fun aggressiveActionShare(): Double =
        if (actions == 0) 0.0 else aggressiveActions.toDouble() / actions
}

private fun runTable(
    label: String,
    roster: List<BotProfile>,
    hands: Int,
    seed: Long,
    verbose: Boolean = true,
    stackPolicy: StackPolicy = StackPolicy.RESET_EACH_HAND,
    shortStackCoverage: ShortStackCoverage? = null,
): List<Stats> = runBlocking {
    // The deck gets its own RNG. If bots drew from the same stream, the number of
    // Monte Carlo rollouts a bot performs — which varies by skill level — would
    // shift every subsequent deal, so changing a profile would silently change the
    // cards and no two runs would be comparable.
    val deckRandom = Random(seed)
    val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) }
    val stats = roster.map { Stats(it.name, it) }
    val seats = roster.mapIndexed { i, p ->
        val observedAgent = PlayerAgent { ctx ->
            shortStackCoverage?.observe(ctx)
            bots[i].act(ctx)
        }
        Seat(i, p.name, STARTING_STACK, observedAgent)
    }
    val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)

    if (verbose) {
        println("\n=== $label ===")
        println("$hands hands, ${roster.size}-handed, ${STARTING_STACK / BIG_BLIND}bb stacks, seed=$seed")
    }
    val started = System.nanoTime()

    // VPIP/PFR are per-hand booleans, not action counts: a player who calls and
    // then calls a re-raise has still only entered the pot once.
    val enteredPot = BooleanArray(seats.size)
    val raisedPre = BooleanArray(seats.size)

    repeat(hands) {
        when (stackPolicy) {
            StackPolicy.RESET_EACH_HAND ->
                for (s in seats) s.stack = STARTING_STACK
            StackPolicy.PERSIST_WITH_REBUY ->
                for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
        }
        java.util.Arrays.fill(enteredPot, false)
        java.util.Arrays.fill(raisedPre, false)
        table.advanceButton()

        val result = table.playHand()

        for (e in result.events) {
            val st = stats[e.seat]
            st.actions++
            if (e.action.type == ActionType.BET || e.action.type == ActionType.RAISE) {
                st.aggressiveActions++
            }
            if (e.street == Street.PREFLOP) {
                when (e.action.type) {
                    ActionType.CALL -> enteredPot[e.seat] = true
                    ActionType.BET, ActionType.RAISE -> {
                        enteredPot[e.seat] = true; raisedPre[e.seat] = true
                    }
                    else -> Unit
                }
            } else {
                when (e.action.type) {
                    ActionType.BET, ActionType.RAISE -> st.postflopBets++
                    ActionType.CALL -> st.postflopCalls++
                    else -> Unit
                }
            }
        }

        for (i in seats.indices) {
            val st = stats[i]
            st.hands++
            st.net += result.net[i]
            if (enteredPot[i]) st.vpip++
            if (raisedPre[i]) st.pfr++
            if (i in result.winners) st.wins++

            val bb = abs(result.net[i]).toDouble() / BIG_BLIND
            if (result.net[i] > 0) bots[i].mood.recordWin(bb)
            else if (result.net[i] < 0) bots[i].mood.recordLoss(bb, wasBadBeat = result.wentToShowdown && bb > 25)
            bots[i].mood.decay()
        }
    }

    val elapsed = (System.nanoTime() - started) / 1_000_000.0
    if (verbose) {
        println("%.1f ms  (%.0f hands/sec)\n".format(elapsed, hands / (elapsed / 1000.0)))

        println("%-7s %-28s %9s %7s %7s %6s %7s".format("Player", "Profile", "bb/100", "VPIP%", "PFR%", "AF", "Won%"))
        println("-".repeat(78))
        for (s in stats.sortedByDescending { it.bbPer100() }) {
            println(
                "%-7s %-28s %9.2f %7.1f %7.1f %6.2f %7.1f".format(
                    s.name, s.profile.description, s.bbPer100(),
                    s.pct(s.vpip), s.pct(s.pfr), s.aggressionFactor(), s.pct(s.wins),
                )
            )
        }
        println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net }))
    }
    check(stats.sumOf { it.net } == 0L) { "$label seed=$seed did not conserve chips" }
    stats
}

/**
 * Exercises the pricing paths that an equal-stack, reset-every-hand simulation
 * cannot reach. Stacks persist exactly as they do for app-controlled opponents:
 * a busted seat reloads only when it has less than one big blind.
 */
internal fun runShortStackCoverage(
    hands: Int,
    seed: Long,
    verbose: Boolean = true,
): ShortStackCoverage {
    require(hands > 0) { "short-stack coverage requires at least one hand" }
    val coverage = ShortStackCoverage()
    val roster = PlayStyle.ALL.map { style ->
        // Beginner keeps this structural coverage pass inexpensive. Pricing is
        // shared by every skill level and is asserted separately by engine tests.
        BotProfile(style.label, SkillLevel.BEGINNER, style)
    }
    runTable(
        label = "Persistent-stack coverage",
        roster = roster,
        hands = hands,
        seed = seed,
        verbose = false,
        stackPolicy = StackPolicy.PERSIST_WITH_REBUY,
        shortStackCoverage = coverage,
    )
    if (verbose) {
        println(
            "Persistent-stack coverage ($hands hands, seed=$seed): " +
                "decisions=${coverage.decisions}  " +
                "eligiblePotDiffersFromPot=${coverage.eligiblePotDiffersFromPot}  " +
                "callClampedByStack=${coverage.callClampedByStack}  " +
                if (coverage.passes) "PASS" else "FAIL",
        )
    }
    return coverage
}

internal data class LadderSeedResult(
    val seed: Long,
    val bbPer100: Map<SkillLevel, Double>,
)

internal data class SeparationResult(
    val label: String,
    val meanDifference: Double,
    val lower95: Double,
) {
    val passes: Boolean get() = lower95 > 0.0
}

internal data class CalibrationReport(
    val seeds: List<LadderSeedResult>,
    val separations: List<SeparationResult>,
) {
    val passes: Boolean get() = separations.all { it.passes }
}

internal data class StyleMetrics(
    val label: String,
    val loosenessSetting: Double,
    val vpip: Double,
    val pfr: Double,
    val aggressionFactor: Double,
    val aggressiveActionShare: Double,
)

internal data class ContractResult(val label: String, val passes: Boolean, val detail: String)

internal fun evaluateStyleCalibration(metrics: List<StyleMetrics>): List<ContractResult> {
    val byName = metrics.associateBy { it.label }
    fun style(style: PlayStyle): StyleMetrics =
        requireNotNull(byName[style.label]) { "missing ${style.label} style metrics" }

    val rock = style(PlayStyle.ROCK)
    val maniac = style(PlayStyle.MANIAC)
    val station = style(PlayStyle.CALLING_STATION)
    val lag = style(PlayStyle.LOOSE_AGGRESSIVE)
    val tag = style(PlayStyle.TIGHT_AGGRESSIVE)

    val expectedOrder = metrics.sortedBy { it.loosenessSetting }.map { it.label }
    val actualOrder = metrics.sortedBy { it.vpip }.map { it.label }

    return listOf(
        ContractResult(
            "Rock remains tight",
            rock.vpip in 7.0..20.0,
            "VPIP ${"%.1f".format(rock.vpip)}% for ${"%.1f".format(rock.loosenessSetting * 100)}% setting",
        ),
        ContractResult(
            "Maniac remains loose",
            maniac.vpip in 55.0..90.0,
            "VPIP ${"%.1f".format(maniac.vpip)}% for ${"%.1f".format(maniac.loosenessSetting * 100)}% setting",
        ),
        ContractResult(
            "Styles preserve looseness order",
            expectedOrder == actualOrder,
            "expected $expectedOrder, observed $actualOrder",
        ),
        ContractResult(
            "Calling Station stays passive",
            station.aggressionFactor < 0.8,
            "AF ${"%.2f".format(station.aggressionFactor)}",
        ),
        ContractResult(
            "Calling Station rarely raises pre-flop",
            station.pfr < 10.0,
            "PFR ${"%.1f".format(station.pfr)}%",
        ),
        ContractResult(
            "Maniac stays aggressive",
            maniac.aggressionFactor > 1.5,
            "AF ${"%.2f".format(maniac.aggressionFactor)}",
        ),
        ContractResult(
            "LAG raises more often than Calling Station",
            lag.pfr > station.pfr,
            "LAG PFR ${"%.1f".format(lag.pfr)}%, station ${"%.1f".format(station.pfr)}%",
        ),
        ContractResult(
            "Opponent-model prior matches neutral TAG",
            abs(tag.aggressiveActionShare - OpponentModel.NEUTRAL_AGGRESSION) <= 0.05,
            "observed ${"%.3f".format(tag.aggressiveActionShare)}, prior ${OpponentModel.NEUTRAL_AGGRESSION}",
        ),
    )
}

/**
 * Evaluates the skill contract without pretending Advanced and Expert have a
 * reliable order. Both top tiers must beat Intermediate, and Intermediate must
 * beat Beginner. Each comparison is paired by seed so card/run variance cancels
 * as much as this simulator permits.
 */
internal fun evaluateCalibration(results: List<LadderSeedResult>): CalibrationReport {
    require(results.size >= 2) { "calibration needs at least two independent seeds" }

    fun separation(label: String, stronger: SkillLevel, weaker: SkillLevel): SeparationResult {
        val differences = results.map { result ->
            val high = requireNotNull(result.bbPer100[stronger]) { "$stronger missing for seed ${result.seed}" }
            val low = requireNotNull(result.bbPer100[weaker]) { "$weaker missing for seed ${result.seed}" }
            high - low
        }
        val mean = differences.average()
        val variance = differences.sumOf { (it - mean) * (it - mean) } / (differences.size - 1)
        val standardError = sqrt(variance / differences.size)
        val lower95 = mean - studentTCritical95(differences.size - 1) * standardError
        return SeparationResult(label, mean, lower95)
    }

    return CalibrationReport(
        seeds = results,
        separations = listOf(
            separation("Expert > Intermediate", SkillLevel.EXPERT, SkillLevel.INTERMEDIATE),
            separation("Advanced > Intermediate", SkillLevel.ADVANCED, SkillLevel.INTERMEDIATE),
            separation("Intermediate > Beginner", SkillLevel.INTERMEDIATE, SkillLevel.BEGINNER),
        ),
    )
}

/** Two-sided 95% Student-t critical value, used for a one-sided conservative lower bound. */
private fun studentTCritical95(degreesOfFreedom: Int): Double = when (degreesOfFreedom) {
    1 -> 12.706
    2 -> 4.303
    3 -> 3.182
    4 -> 2.776
    5 -> 2.571
    6 -> 2.447
    7 -> 2.365
    8 -> 2.306
    9 -> 2.262
    10 -> 2.228
    in 11..14 -> 2.201
    in 15..19 -> 2.120
    in 20..29 -> 2.086
    else -> 1.960
}

private fun skillCandidateRoster(candidate: SkillLevel): List<BotProfile> = listOf(
    BotProfile("Target", candidate, PlayStyle.TIGHT_AGGRESSIVE),
    BotProfile("ControlTAG", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE),
    BotProfile("ControlLAG", SkillLevel.INTERMEDIATE, PlayStyle.LOOSE_AGGRESSIVE),
    BotProfile("ControlStation", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION),
)

private fun styleRoster(target: PlayStyle): List<BotProfile> = buildList {
    add(BotProfile("Target", SkillLevel.INTERMEDIATE, target))
    repeat(5) { index ->
        add(BotProfile("Control${index + 1}", SkillLevel.INTERMEDIATE, PlayStyle.TIGHT_AGGRESSIVE))
    }
}

private fun runStyleCalibration(hands: Int, seed: Long): List<ContractResult> {
    // One target at the same seat against the same neutral roster and deck seed.
    // Putting all styles in one table changes each player's matchup, so a style
    // comparison would be confounded by who happened to sit around it.
    val metrics = PlayStyle.ALL.map { style ->
        val target = runTable(
            label = "Style calibration: ${style.label}",
            roster = styleRoster(style),
            hands = hands,
            seed = seed,
            verbose = false,
        ).first()
        StyleMetrics(
            label = style.label,
            loosenessSetting = style.looseness,
            vpip = target.pct(target.vpip),
            pfr = target.pct(target.pfr),
            aggressionFactor = target.aggressionFactor(),
            aggressiveActionShare = target.aggressiveActionShare(),
        )
    }

    println("\nStyle behavior (${hands} hands, seed=$seed):")
    for (metric in metrics.sortedBy { it.loosenessSetting }) {
        println(
            "  %-18s VPIP=%5.1f  PFR=%5.1f  AF=%4.2f  AggShare=%5.3f".format(
                metric.label,
                metric.vpip,
                metric.pfr,
                metric.aggressionFactor,
                metric.aggressiveActionShare,
            )
        )
    }
    return evaluateStyleCalibration(metrics)
}

private fun runCalibration(handsPerSeed: Int, seedCount: Int, baseSeed: Long) {
    require(handsPerSeed >= 50_000) { "calibration requires at least 50,000 hands per seed" }
    require(seedCount >= 2) { "calibration requires at least two seeds" }

    println("=== Skill calibration ===")
    println("$seedCount seeds × $handsPerSeed hands; top pair unordered by contract")

    val results = (0 until seedCount).map { offset ->
        val seed = baseSeed + offset
        // Each candidate occupies the same seat against the same opponents and
        // deal seed. Putting all candidates in one table makes every score depend
        // on the other candidates' strengths and non-transitive matchups.
        val rates = SkillLevel.entries.associateWith { candidate ->
            runTable(
                label = "Skill candidate: ${candidate.label}",
                roster = skillCandidateRoster(candidate),
                hands = handsPerSeed,
                seed = seed,
                verbose = false,
            ).first().bbPer100()
        }
        println(
            "seed=$seed  E=%7.2f  A=%7.2f  I=%7.2f  B=%7.2f".format(
                rates.getValue(SkillLevel.EXPERT),
                rates.getValue(SkillLevel.ADVANCED),
                rates.getValue(SkillLevel.INTERMEDIATE),
                rates.getValue(SkillLevel.BEGINNER),
            )
        )
        LadderSeedResult(seed, rates)
    }

    val report = evaluateCalibration(results)
    println("\nPaired 95% lower bounds:")
    for (result in report.separations) {
        println(
            "  %-26s mean=%7.2f  lower95=%7.2f  %s".format(
                result.label,
                result.meanDifference,
                result.lower95,
                if (result.passes) "PASS" else "FAIL",
            )
        )
    }

    val styleContracts = runStyleCalibration(
        hands = maxOf(20_000, handsPerSeed / 5),
        seed = baseSeed + seedCount,
    )
    println("\nStyle contracts:")
    for (contract in styleContracts) {
        println("  %-42s %s  %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
    }

    println()
    val shortStackCoverage = runShortStackCoverage(
        hands = 3_000,
        seed = baseSeed + seedCount + 1,
    )

    check(report.passes && styleContracts.all { it.passes } && shortStackCoverage.passes) {
        "bot calibration failed; do not tune constants against a single seed or one aggregate number"
    }
}

/** Dumps the starting-hand ranking so the ordering can be eyeballed against a real chart. */
private fun printChart() {
    val rows = ArrayList<Pair<String, Double>>()
    for (hi in 14 downTo 2) for (lo in hi downTo 2) {
        if (hi == lo) {
            val h = intArrayOf(Card.of(hi, Suit.CLUBS).index, Card.of(hi, Suit.HEARTS).index)
            rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)}  " to PreflopChart.percentile(h)
        } else {
            val s = intArrayOf(Card.of(hi, Suit.SPADES).index, Card.of(lo, Suit.SPADES).index)
            val o = intArrayOf(Card.of(hi, Suit.SPADES).index, Card.of(lo, Suit.HEARTS).index)
            rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)}s " to PreflopChart.percentile(s)
            rows += "${Card.rankSymbol(hi)}${Card.rankSymbol(lo)}o " to PreflopChart.percentile(o)
        }
    }
    rows.sortBy { it.second }
    println("Top 30 starting hands:")
    rows.take(30).forEachIndexed { i, (h, v) -> print("%2d.%s%.3f   ".format(i + 1, h, v)); if ((i + 1) % 5 == 0) println() }
    println("\nBottom 10:")
    rows.takeLast(10).forEach { (h, v) -> print("$h%.3f   ".format(v)) }
    println()
}

fun main(args: Array<String>) {
    if (args.firstOrNull() == "chart") { printChart(); return }
    if (args.firstOrNull() == "short-stacks") {
        val hands = args.getOrNull(1)?.toIntOrNull() ?: 3_000
        val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_729L
        check(runShortStackCoverage(hands, seed).passes) {
            "persistent-stack calibration did not exercise both short-stack pricing paths"
        }
        return
    }
    if (args.firstOrNull() == "styles") {
        val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
        val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
        val contracts = runStyleCalibration(hands, seed)
        println("\nStyle contracts:")
        for (contract in contracts) {
            println("  %-42s %s  %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
        }
        check(contracts.all { it.passes }) { "style calibration failed" }
        return
    }
    if (args.firstOrNull() == "calibrate") {
        val hands = args.getOrNull(1)?.toIntOrNull() ?: 100_000
        val seeds = args.getOrNull(2)?.toIntOrNull() ?: 4
        val baseSeed = args.getOrNull(3)?.toLongOrNull() ?: 20_260_724L
        runCalibration(hands, seeds, baseSeed)
        return
    }
    val hands = args.getOrNull(0)?.toIntOrNull() ?: 50_000
    val seed = args.getOrNull(1)?.toLongOrNull() ?: 20_260_724L

    // A mixed table: what a real game looks like.
    runTable(
        "Mixed table", listOf(
            BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
            BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."),
            BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."),
            BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."),
            BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
            BotProfile("Fay", SkillLevel.BEGINNER, PlayStyle.ROCK, "Waits for aces."),
        ), hands, seed
    )

    // Quick diagnostic only. The enforced calibration evaluates each candidate
    // separately against a fixed pool; this mixed ladder is intentionally not
    // accepted as evidence because matchups are non-transitive.
    val ladder = runTable(
        "Skill ladder (style held constant at Tight-Aggressive)",
        SkillLevel.entries.reversed().map { level ->
            BotProfile(level.label, level, PlayStyle.TIGHT_AGGRESSIVE)
        },
        hands,
        seed + 1,
    )

    println("\nDifficulty gradient:")
    val byLevel = SkillLevel.entries.reversed().mapNotNull { level ->   // strongest first
        ladder.firstOrNull { it.profile.skill == level }?.let { level to it.bbPer100() }
    }
    for ((level, v) in byLevel) println("  %-14s %9.2f bb/100".format(level.label, v))

    var strictlyMonotonic = true
    for (i in 1 until byLevel.size) if (byLevel[i].second > byLevel[i - 1].second) strictlyMonotonic = false

    println(
        if (strictlyMonotonic) "  -> strictly monotonic this run."
        else "  -> adjacent tiers overlap this run (expected; they are close by design)."
    )
    println("  -> diagnostic only; run `:sim:run --args=\"calibrate\"` for an enforced multi-seed result.")
}
