package com.jsjdesigns.poker.bot

import com.jsjdesigns.poker.core.Equity
import com.jsjdesigns.poker.core.PreflopChart
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.DecisionContext
import com.jsjdesigns.poker.game.PlayerAgent
import com.jsjdesigns.poker.game.Street
import kotlin.math.roundToInt
import kotlin.random.Random

/**
 * The numbers behind one decision.
 *
 * Kept deliberately explicit because the deterministic coach UI consumes this
 * contract directly, and a later LLM may narrate it. Neither consumer computes
 * or reconstructs poker maths.
 */
data class DecisionTrace(
    /** Monte Carlo equity when equity is actually estimated; null pre-flop. */
    val estimatedEquity: Double?,
    /** Pre-flop chart percentile, where 0 is strongest; null post-flop. */
    val handStrengthPercentile: Double?,
    /** Raw pot-odds threshold when it drives the decision; null pre-flop. */
    val breakEvenEquity: Double?,
    /** Final post-flop equity threshold after adjustments; null pre-flop. */
    val decisionThreshold: Double?,
    /** Top fraction of starting hands played in this spot; null post-flop. */
    val preflopRangeThreshold: Double?,
    val potOdds: String?,
    /** What the strategy selected before a skill error was applied. */
    val intended: Action,
    /** Action actually taken by the bot or human whose decision is reviewed. */
    val chosen: Action,
    /** True only when bot skill-error injection changed [intended]. */
    val mistakeApplied: Boolean,
    /** Multipliers applied to the raw threshold, in evaluation order. */
    val adjustments: List<ThresholdAdjustment>,
    val reason: String,
)

data class ThresholdAdjustment(val name: String, val factor: Double)

/**
 * Balances an in-position multiplier against all out-of-position seats.
 *
 * If exactly one of N active players is in position, the weighted average factor
 * is 1.0 for any table size. Positive [inPositionDelta] widens a range in
 * position; negative values lower a calling threshold in position.
 */
internal fun balancedPositionFactor(
    activeOpponents: Int,
    inPosition: Boolean,
    awareness: Double,
    inPositionDelta: Double,
): Double {
    val players = (activeOpponents + 1).coerceAtLeast(2)
    val inPositionShare = 1.0 / players
    val delta = inPositionDelta * awareness
    val outOfPositionDelta = -delta * inPositionShare / (1.0 - inPositionShare)
    return if (inPosition) 1.0 + delta else 1.0 + outOfPositionDelta
}

/**
 * A bot that decides from equity and pot odds, then distorts that decision through
 * its [SkillLevel] and [PlayStyle].
 *
 * Everything here is deterministic given the seed, runs in well under a
 * millisecond, and needs no network — the LLM layer sits *on top* of this, never
 * inside it.
 */
class MathBot(
    val profile: BotProfile,
    private val random: Random = Random.Default,
) : PlayerAgent {

    val mood = BotMood(profile.style.tiltSusceptibility)
    val reads = OpponentModel()

    var lastTrace: DecisionTrace? = null
        private set

    override suspend fun act(ctx: DecisionContext): Action {
        val skill = profile.skill
        val style = profile.style
        val opponents = ctx.activeOpponents.coerceAtLeast(1)

        if (skill.readsOpponents) reads.observe(ctx.history, ctx.handNumber)
        if (ctx.street == Street.PREFLOP) return actPreflop(ctx)

        // Weaker players run fewer rollouts, so they genuinely misjudge their hand
        // rather than playing well and then blundering at random.
        val equity = Equity.estimate(
            hole = ctx.hole,
            board = ctx.board,
            opponents = opponents,
            iterations = skill.equityIterations,
            random = random,
        )

        val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.0, 1.0)
        val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0)

        // A short stack cannot win side-pot chips above its final contribution.
        // Price the affordable call against only the pot this seat can contest.
        val affordableCall = minOf(ctx.toCall, ctx.stack)
        val breakEven = Equity.potOdds(ctx.eligiblePot, affordableCall)

        // Pot odds are the honest baseline. In particular, the river has no
        // future street from which to earn "implied" chips, so a blanket discount
        // is mathematically wrong. Flop/turn future value can be added later only
        // as an explicit model that also accounts for future costs and reverse
        // implied odds; until then raw pot odds are the defensible threshold.
        val adjustments = ArrayList<ThresholdAdjustment>()
        val discipline = skill.potOddsRespect
        val misjudgement = (1.0 - discipline) * 0.50
        val accuracyFactor = 1.0 + (random.nextDouble() * 2 - 1) * misjudgement
        var bar = breakEven * accuracyFactor
        adjustments += ThresholdAdjustment("skill estimate", accuracyFactor)

        val styleFactor = 1.0 - looseness * 0.35
        bar *= styleFactor
        adjustments += ThresholdAdjustment("style risk tolerance", styleFactor)

        // Position changes where calls are made, not how often overall. Derive
        // the out-of-position counterpart from the active table size.
        val positionFactor = balancedPositionFactor(
            activeOpponents = ctx.activeOpponents,
            inPosition = ctx.inPosition,
            awareness = skill.positionAwareness,
            inPositionDelta = -0.12,
        )
        bar *= positionFactor
        adjustments += ThresholdAdjustment("position", positionFactor)

        // Exploitation: a habitual bettor's bet means less, so call wider against
        // them; a passive player's bet means strength, so fold more.
        if (skill.readsOpponents && ctx.toCall > 0) {
            val bettor = ctx.history.lastOrNull {
                it.action.type == ActionType.BET || it.action.type == ActionType.RAISE
            }?.seat
            if (bettor != null && bettor != ctx.seat.index && reads.actionsObserved(bettor) >= 25) {
                val edge = reads.aggressionRate(bettor) - OpponentModel.NEUTRAL_AGGRESSION
                val readFactor = (1.0 - edge * 0.50).coerceIn(0.7, 1.3)
                bar *= readFactor
                adjustments += ThresholdAdjustment("opponent read", readFactor)
            }
        }

        val raiseBar = (0.62 - aggression * 0.22).coerceIn(0.30, 0.75)

        val intended = decide(ctx, equity, bar, raiseBar, aggression, style, opponents)

        // Skill errors degrade the selected action; style has already chosen how
        // this player prefers to express a close decision.
        val chosen = if (random.nextDouble() < skill.errorRate) {
            postflopMistake(ctx, intended, equity, breakEven)
        } else {
            intended
        }
        val mistakeApplied = chosen != intended

        lastTrace = DecisionTrace(
            estimatedEquity = equity,
            handStrengthPercentile = null,
            breakEvenEquity = breakEven,
            decisionThreshold = bar,
            preflopRangeThreshold = null,
            potOdds = if (ctx.toCall > 0) "${ctx.eligiblePot}:$affordableCall" else "no bet to call",
            intended = intended,
            chosen = chosen,
            mistakeApplied = mistakeApplied,
            adjustments = adjustments,
            reason = traceReason(equity, breakEven, bar, intended, chosen, mistakeApplied, ctx),
        )
        return chosen
    }

    /**
     * Pre-flop is range-based rather than equity-based: real players think "I open
     * the top N%", so [PlayStyle.looseness] sets N directly and a Rock at 0.12
     * genuinely plays 12% of hands.
     */
    private fun actPreflop(ctx: DecisionContext): Action {
        val skill = profile.skill
        val style = profile.style
        val pct = PreflopChart.percentile(ctx.hole)

        val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.02, 1.0)
        val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0)

        // Position awareness shifts where a range is played without changing its
        // average width. One of N players is last to act; the out-of-position
        // reduction is derived from that share rather than tuned independently.
        val positional = preflopPositionFactor(ctx, skill.positionAwareness)

        val facingRaise = ctx.toCall > ctx.bigBlind
        var gate = looseness * positional
        if (facingRaise) gate *= 0.45
        gate = gate.coerceIn(0.01, 1.0)

        // Aggression controls what share of the playable range is raised. The old
        // 30% floor made even a zero-aggression "Calling Station" raise nearly a
        // third of every entered range, contradicting the profile name.
        val raiseShare = (0.05 + aggression * 0.75).coerceIn(0.05, 0.85)
        val raiseGate = gate * raiseShare

        val action = when {
            pct <= raiseGate && ctx.canRaise ->
                Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise))
            pct <= gate ->
                if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
            else ->
                if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
        }

        val final = if (random.nextDouble() < skill.errorRate) {
            preflopMistake(ctx, action, pct, gate)
        } else {
            action
        }
        val mistakeApplied = final != action
        lastTrace = DecisionTrace(
            estimatedEquity = null,
            handStrengthPercentile = pct,
            breakEvenEquity = null,
            decisionThreshold = null,
            preflopRangeThreshold = gate,
            potOdds = null,
            intended = action,
            chosen = final,
            mistakeApplied = mistakeApplied,
            adjustments = listOf(
                ThresholdAdjustment("position", positional),
                ThresholdAdjustment("facing raise", if (facingRaise) 0.45 else 1.0),
            ),
            reason = buildString {
                append("Starting hand is in the top ${(pct * 100).roundToInt()}%; ")
                append("this profile's range here is ${(gate * 100).roundToInt()}%.")
                if (mistakeApplied) append(" Skill error changed $action to $final.")
            },
        )
        return final
    }

    private fun preflopRaiseTo(ctx: DecisionContext, facingRaise: Boolean): Int {
        if (ctx.maxRaiseTo <= ctx.minRaiseTo) return ctx.maxRaiseTo
        val desired = if (facingRaise) ctx.minRaiseTo + (ctx.pot * 0.40).roundToInt()
        else ctx.bigBlind * 3
        return desired.coerceIn(ctx.minRaiseTo, ctx.maxRaiseTo)
    }

    private fun decide(
        ctx: DecisionContext,
        equity: Double,
        bar: Double,
        raiseBar: Double,
        aggression: Double,
        style: PlayStyle,
        opponents: Int,
    ): Action {
        val strong = equity >= raiseBar
        val monster = equity >= 0.82

        // Sandbagging: under-represent a monster to keep them in.
        if (monster && random.nextDouble() < style.slowplayFrequency && ctx.street != Street.RIVER) {
            return if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
        }

        if (ctx.canCheck) {
            // Continuation bet: having taken the lead pre-flop, fire again on the
            // flop regardless of what it brought.
            val tookPreflopLead = ctx.history.lastOrNull {
                it.street == Street.PREFLOP &&
                    (it.action.type == ActionType.BET || it.action.type == ActionType.RAISE)
            }?.seat == ctx.seat.index
            if (tookPreflopLead && ctx.street == Street.FLOP &&
                random.nextDouble() < style.contBetFrequency
            ) {
                return Action(ActionType.BET, sizeBet(ctx, equity, aggression))
            }

            // No bet to face: either take the lead or check behind.
            val bluffing = equity < 0.35 &&
                random.nextDouble() < style.bluffFrequency / opponents.coerceAtLeast(1)
            if (strong || bluffing) {
                if (random.nextDouble() < aggression || bluffing) {
                    return Action(ActionType.BET, sizeBet(ctx, equity, aggression))
                }
            }
            return Action(ActionType.CHECK)
        }

        // Facing a bet.
        if (equity < bar) {
            // Bluff-raising with nothing, occasionally, when heads-up.
            if (opponents == 1 && equity > 0.18 &&
                random.nextDouble() < style.bluffFrequency * 0.5 && ctx.canRaise
            ) {
                return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression))
            }
            return Action(ActionType.FOLD)
        }

        if (strong && ctx.canRaise && random.nextDouble() < aggression) {
            return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression))
        }

        return Action(ActionType.CALL, ctx.callAmount)
    }

    /** Pot-fraction sizing, widening with equity and aggression. */
    private fun sizeBet(ctx: DecisionContext, equity: Double, aggression: Double): Int {
        val fraction = when {
            equity > 0.85 -> 0.75 + aggression * 0.45
            equity > 0.65 -> 0.55 + aggression * 0.30
            equity > 0.45 -> 0.45 + aggression * 0.20
            else -> 0.40 + aggression * 0.25 // bluff sizing
        }
        val target = ctx.committedThisRoundPlus(((ctx.pot * fraction).roundToInt()))
        // A short stack that cannot afford a full min-raise may still shove; that
        // is legal, it simply does not reopen the betting.
        if (ctx.maxRaiseTo <= ctx.minRaiseTo) return ctx.maxRaiseTo
        return target.coerceIn(ctx.minRaiseTo, ctx.maxRaiseTo)
    }

    /**
     * Pre-flop errors stay local to the style's range boundary.
     *
     * A global FOLD→CALL rewrite gave every beginner a 30% VPIP floor. Here a
     * marginal hand can cross the boundary, and a marginal call can be folded,
     * while trash remains trash and a missed raise remains inside the same range.
     */
    private fun preflopMistake(
        ctx: DecisionContext,
        intended: Action,
        percentile: Double,
        gate: Double,
    ): Action = when (intended.type) {
        ActionType.FOLD ->
            if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) {
                Action(ActionType.CALL, ctx.callAmount)
            } else {
                intended
            }
        ActionType.CALL ->
            if (percentile >= (gate - PREFLOP_ERROR_BAND).coerceAtLeast(0.0)) {
                Action(ActionType.FOLD)
            } else {
                intended
            }
        ActionType.BET, ActionType.RAISE ->
            if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
        ActionType.CHECK -> intended
    }

    /**
     * Post-flop errors choose an action with worse immediate value whenever that
     * comparison is available. Style has already selected the intended action.
     */
    private fun postflopMistake(
        ctx: DecisionContext,
        intended: Action,
        equity: Double,
        rawBreakEven: Double,
    ): Action = when (intended.type) {
        ActionType.FOLD ->
            if (ctx.toCall > 0) Action(ActionType.CALL, ctx.callAmount) else intended
        ActionType.CALL -> when {
            equity >= rawBreakEven -> Action(ActionType.FOLD)
            ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo)
            else -> intended
        }
        ActionType.BET, ActionType.RAISE ->
            if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
        ActionType.CHECK ->
            if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended
    }

    private fun preflopPositionFactor(ctx: DecisionContext, awareness: Double): Double {
        return balancedPositionFactor(
            activeOpponents = ctx.activeOpponents,
            inPosition = ctx.inPosition,
            awareness = awareness,
            inPositionDelta = 0.45,
        )
    }

    private companion object {
        const val PREFLOP_ERROR_BAND = 0.08
    }

    private fun traceReason(
        equity: Double,
        breakEven: Double,
        threshold: Double,
        intended: Action,
        chosen: Action,
        mistakeApplied: Boolean,
        ctx: DecisionContext,
    ): String {
        val pct = (equity * 100).roundToInt()
        return if (ctx.toCall > 0) {
            val need = (breakEven * 100).roundToInt()
            val used = (threshold * 100).roundToInt()
            buildString {
                append("About $pct% equity against ${ctx.activeOpponents} opponent(s); ")
                append("raw pot odds require $need%, adjusted decision threshold $used%.")
                if (mistakeApplied) append(" Skill error changed $intended to $chosen.")
            }
        } else {
            buildString {
                append("About $pct% equity against ${ctx.activeOpponents} opponent(s), no bet to face.")
                if (mistakeApplied) append(" Skill error changed $intended to $chosen.")
            }
        }
    }
}

/** Converts a pot-fraction bet into a raise-to figure. */
private fun DecisionContext.committedThisRoundPlus(extra: Int): Int =
    seat.committedThisRound + toCall + extra
