package com.jsjdesigns.poker.game

import com.jsjdesigns.poker.core.Card
import com.jsjdesigns.poker.core.CardSource
import com.jsjdesigns.poker.core.Deck
import com.jsjdesigns.poker.core.HandEvaluator
import kotlin.random.Random

enum class Street { PREFLOP, FLOP, TURN, RIVER }

enum class ActionType { FOLD, CHECK, CALL, BET, RAISE }

/** For BET and RAISE, [amount] is the total this player is committing *to* this round. */
data class Action(val type: ActionType, val amount: Int = 0) {
    override fun toString(): String = when (type) {
        ActionType.FOLD -> "folds"
        ActionType.CHECK -> "checks"
        ActionType.CALL -> "calls $amount"
        ActionType.BET -> "bets $amount"
        ActionType.RAISE -> "raises to $amount"
    }
}

data class HandEvent(val street: Street, val seat: Int, val name: String, val action: Action)

class Seat(
    val index: Int,
    val name: String,
    var stack: Int,
    val agent: PlayerAgent,
) {
    var hole: IntArray = IntArray(0)
    var committedThisRound = 0
    var committedThisHand = 0
    var folded = false
    var allIn = false
    var hasActed = false

    /**
     * The table's `currentBet` at the moment this player last acted this round;
     * -1 before they have acted.
     *
     * A boolean cannot express TDA Rule 47: betting reopens when a player is
     * facing *at least a full raise* since their last action, which several
     * incomplete all-ins can reach cumulatively even though no single one does.
     * Storing the level they last acted at makes that a subtraction.
     */
    var lastActedAtBet = -1

    val canAct: Boolean get() = !folded && !allIn && stack > 0
    val contesting: Boolean get() = !folded
}

/** Everything a player may legally know when it is their turn. */
class DecisionContext(
    val street: Street,
    val seat: Seat,
    val board: IntArray,
    val pot: Int,
    val toCall: Int,
    val minRaiseTo: Int,
    val maxRaiseTo: Int,
    val activeOpponents: Int,
    /** How many players act after this one on this street; 0 means last to act. */
    val seatsActingAfter: Int,
    val bigBlind: Int,
    val history: List<HandEvent>,
    /** Increments once per hand; use it to detect hand boundaries. */
    val handNumber: Int,
    /**
     * Identifies this specific decision, table-wide and monotonic.
     *
     * Owned by the engine so a snapshot and the decision it invites carry the
     * same value; matching on hand and street alone is ambiguous, because a
     * player can face two decisions on one street.
     */
    val decisionToken: Long,
    /** TDA Rule 47: is this player facing at least a full raise since acting? */
    val bettingReopened: Boolean,
    /**
     * Portion of the current [pot] this seat can contest if it pays its affordable
     * call. The call itself is not included. Excludes unmatched excess and side
     * pots above this seat's final level.
     */
    val eligiblePot: Int,
    /** Highest amount committed by any seat in the current betting round. */
    val currentBet: Int,
) {
    val hole: IntArray get() = seat.hole
    val stack: Int get() = seat.stack
    val canCheck: Boolean get() = toCall == 0
    val callAmount: Int get() = minOf(toCall, stack)
    val callIsAllIn: Boolean get() = toCall >= stack

    /**
     * True when this player may still put in a raise.
     *
     * A player already acted at this level and facing only an *incomplete* raise
     * owes the difference but may not re-raise. Betting reopens once the increase
     * since their last action reaches a full raise — whether from one raise or
     * several short all-ins adding up.
     */
    val canRaise: Boolean get() = seat.stack > toCall && bettingReopened
    val inPosition: Boolean get() = seatsActingAfter == 0
}

/**
 * Suspending because a human player has to wait for a tap. Bots return
 * immediately and are unaffected; the simulator wraps the whole thing in
 * `runBlocking`.
 */
fun interface PlayerAgent {
    suspend fun act(ctx: DecisionContext): Action
}

data class Pot(val amount: Int, val eligible: List<Int>)

data class HandResult(
    val board: IntArray,
    /** Net chip change per seat for this hand. */
    val net: IntArray,
    val winners: List<Int>,
    val wentToShowdown: Boolean,
    val potSize: Int,
    val events: List<HandEvent>,
)

/**
 * A no-limit Texas Hold'em table.
 *
 * Deliberately headless and synchronous: the same engine runs the on-device game
 * and the batch simulator used to tune bot profiles.
 */
class Table(
    val seats: List<Seat>,
    val smallBlind: Int,
    val bigBlind: Int,
    private val random: Random = Random.Default,
    private val deck: CardSource = Deck(random),
    /**
     * Called after the deal and after every action. Defaults to a no-op so the
     * simulator pays nothing for it.
     */
    private val observer: suspend (TableSnapshot) -> Unit = {},
) {
    var button: Int = 0
        private set

    /** Increments once per hand so stateful observers can detect hand boundaries. */
    var handNumber: Int = 0
        private set

    val board = ArrayList<Int>(5)
    private val events = ArrayList<HandEvent>()

    private var currentBet = 0
    private var minRaiseSize = 0
    private var currentStreet = Street.PREFLOP
    private val revealed = HashSet<Int>()
    private var decisionSeq = 0L
    private var pendingToken = 0L

    private fun snapshot(phase: TableSnapshot.Phase, toAct: Int?, potOverride: Int?) = TableSnapshot(
        handNumber = handNumber,
        street = currentStreet,
        phase = phase,
        board = board.toList(),
        // settle() zeroes contributions once the pot is awarded, so a terminal
        // snapshot has to report the pot that was contested rather than 0 — that
        // is the number the UI needs to show at exactly that moment.
        pot = potOverride ?: pot(),
        currentBet = currentBet,
        minRaiseSize = minRaiseSize,
        button = button,
        seats = seats.map { s ->
            SeatSnapshot(
                index = s.index,
                name = s.name,
                stack = s.stack,
                committedThisRound = s.committedThisRound,
                committedThisHand = s.committedThisHand,
                folded = s.folded,
                allIn = s.allIn,
                hole = if (s.hole.isEmpty()) null else s.hole.toList(),
                revealed = s.index in revealed,
                isButton = s.index == button,
            )
        },
        toAct = toAct,
        toActToken = if (toAct != null) pendingToken else null,
        lastAction = events.lastOrNull(),
    )

    private suspend fun emit(
        phase: TableSnapshot.Phase,
        toAct: Int? = null,
        potOverride: Int? = null,
    ) {
        observer(snapshot(phase, toAct, potOverride))
    }

    fun advanceButton() {
        button = nextOccupied(button)
    }

    private fun nextOccupied(from: Int): Int {
        var i = (from + 1) % seats.size
        var guard = 0
        while (seats[i].stack <= 0 && guard++ < seats.size) i = (i + 1) % seats.size
        return i
    }

    private fun nextInHand(from: Int): Int {
        var i = (from + 1) % seats.size
        var guard = 0
        while (!seats[i].contesting && guard++ < seats.size) i = (i + 1) % seats.size
        return i
    }

    suspend fun playHand(): HandResult {
        handNumber++
        val startingStacks = IntArray(seats.size) { seats[it].stack }
        resetForHand()

        val live = seats.filter { it.stack > 0 }
        require(live.size >= 2) { "need at least two funded players" }

        postBlinds()
        dealHoleCards()
        currentStreet = Street.PREFLOP
        prepareRound(Street.PREFLOP)
        emit(TableSnapshot.Phase.DEALT)

        var street = Street.PREFLOP
        var finished = false

        while (!finished) {
            currentStreet = street
            val first = firstToAct(street)
            runBettingRound(street, first)

            val stillIn = seats.count { it.contesting }
            if (stillIn <= 1) {
                finished = true
                break
            }

            // If everyone left is all-in, run the remaining board out unopposed.
            val canStillBet = seats.count { it.contesting && !it.allIn }
            if (canStillBet <= 1 && street != Street.RIVER) {
                dealRemainingBoard(street)
                street = Street.RIVER
                finished = true
                break
            }

            street = when (street) {
                Street.PREFLOP -> { dealFlop(); Street.FLOP }
                Street.FLOP -> { dealTurn(); Street.TURN }
                Street.TURN -> { dealRiver(); Street.RIVER }
                Street.RIVER -> { finished = true; Street.RIVER }
            }
            currentStreet = street
            // Clear the previous street's betting BEFORE publishing, so the new
            // board never arrives alongside last street's chips.
            prepareRound(street)
            emit(TableSnapshot.Phase.STREET_COMPLETE)
        }

        val result = settle(startingStacks)
        emit(
            if (result.wentToShowdown) TableSnapshot.Phase.SHOWDOWN else TableSnapshot.Phase.COMPLETE,
            potOverride = result.potSize,
        )
        return result
    }

    private fun resetForHand() {
        deck.shuffle()
        board.clear()
        events.clear()
        revealed.clear()
        currentBet = 0
        minRaiseSize = bigBlind
        for (s in seats) {
            s.hole = IntArray(0)
            s.committedThisRound = 0
            s.committedThisHand = 0
            s.folded = s.stack <= 0
            s.allIn = false
            s.hasActed = false
        }
    }

    private fun postBlinds() {
        val funded = seats.filter { it.stack > 0 }
        val headsUp = funded.size == 2
        // Heads-up: the button posts the small blind and acts first pre-flop.
        val sbSeat = if (headsUp) button else nextOccupied(button)
        val bbSeat = nextOccupied(sbSeat)

        commit(seats[sbSeat], smallBlind)
        commit(seats[bbSeat], bigBlind)
        currentBet = bigBlind
        minRaiseSize = bigBlind
    }

    private fun dealHoleCards() {
        for (s in seats) if (s.stack > 0 || s.committedThisHand > 0) {
            if (!s.folded) s.hole = deck.deal(2)
        }
    }

    private fun dealFlop() {
        deck.deal() // burn
        repeat(3) { board.add(deck.deal()) }
    }

    private fun dealTurn() {
        deck.deal()
        board.add(deck.deal())
    }

    private fun dealRiver() {
        deck.deal()
        board.add(deck.deal())
    }

    /**
     * Runs the board out when nobody can act any more.
     *
     * Publishes each street as it lands rather than dealing all five cards
     * silently: an all-in runout is the moment a poker UI most needs to animate,
     * and it also keeps [currentStreet] honest — otherwise a pre-flop all-in ends
     * on a snapshot labelled PREFLOP that carries a five-card board.
     */
    private suspend fun dealRemainingBoard(from: Street) {
        var s = from
        while (s != Street.RIVER) {
            s = when (s) {
                Street.PREFLOP -> { dealFlop(); Street.FLOP }
                Street.FLOP -> { dealTurn(); Street.TURN }
                Street.TURN -> { dealRiver(); Street.RIVER }
                Street.RIVER -> Street.RIVER
            }
            currentStreet = s
            // Sweep the completed betting into the pot before showing the card.
            prepareRound(s)
            emit(TableSnapshot.Phase.STREET_COMPLETE)
        }
    }

    private fun firstToAct(street: Street): Int {
        val funded = seats.count { it.stack > 0 || it.committedThisHand > 0 }
        val headsUp = funded == 2
        return if (street == Street.PREFLOP) {
            if (headsUp) {
                button // heads-up SB/button acts first pre-flop
            } else {
                val sb = nextOccupied(button)
                val bb = nextOccupied(sb)
                nextInHand(bb)
            }
        } else {
            if (headsUp) nextInHand(button) else nextInHand(button)
        }
    }

    private fun commit(seat: Seat, amount: Int): Int {
        val actual = amount.coerceAtMost(seat.stack)
        seat.stack -= actual
        seat.committedThisRound += actual
        seat.committedThisHand += actual
        if (seat.stack == 0) seat.allIn = true
        return actual
    }

    /**
     * TDA Rule 47. A player who has not yet acted may always raise. One who has
     * may raise again only when the bet has climbed by at least a full raise
     * since they acted — which multiple incomplete all-ins can reach together.
     */
    private fun mayRaise(seat: Seat): Boolean =
        seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize

    /**
     * Clears per-round betting state for [street].
     *
     * Called *before* the new-street snapshot is published, not lazily at the top
     * of the betting round: otherwise a flop snapshot still carries pre-flop
     * `currentBet` and `committedThisRound`, and the UI paints stale chips in
     * front of every player.
     */
    private fun prepareRound(street: Street) {
        for (s in seats) {
            s.committedThisRound = 0
            s.hasActed = false
            s.lastActedAtBet = -1
        }

        if (street == Street.PREFLOP) {
            // Blinds were already committed; re-apply them to this round's totals.
            val funded = seats.filter { it.stack > 0 || it.committedThisHand > 0 }
            val headsUp = funded.size == 2
            val sbSeat = if (headsUp) button else nextOccupied(button)
            val bbSeat = nextOccupied(sbSeat)
            seats[sbSeat].committedThisRound = minOf(smallBlind, seats[sbSeat].committedThisHand)
            seats[bbSeat].committedThisRound = minOf(bigBlind, seats[bbSeat].committedThisHand)
            currentBet = bigBlind
        } else {
            currentBet = 0
        }
        minRaiseSize = bigBlind
    }

    /** Runs the round. [prepareRound] must already have been called for [street]. */
    private suspend fun runBettingRound(street: Street, firstSeat: Int) {
        if (seats.count { it.canAct } == 0) return

        var i = firstSeat
        var guard = 0
        val maxIterations = seats.size * 40

        while (guard++ < maxIterations) {
            if (roundComplete()) break

            val seat = seats[i]
            if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) {
                val toCall = (currentBet - seat.committedThisRound).coerceAtLeast(0)
                // One token per decision, stamped before the snapshot so the frame
                // and the offer it invites carry the same identity.
                pendingToken = ++decisionSeq
                emit(TableSnapshot.Phase.BETTING, toAct = seat.index)
                val ctx = buildContext(street, seat, toCall)
                val action = sanitise(seat, toCall, seat.agent.act(ctx))
                apply(street, seat, action, toCall)
                seat.hasActed = true
                // Record the level they acted at — after their own raise, if any.
                seat.lastActedAtBet = currentBet
                emit(TableSnapshot.Phase.BETTING)

                if (seats.count { it.contesting } <= 1) return
            }
            i = (i + 1) % seats.size
        }
    }

    private fun roundComplete(): Boolean {
        val actors = seats.filter { it.canAct }
        if (actors.isEmpty()) return true
        return actors.all { it.hasActed && it.committedThisRound == currentBet }
    }

    private fun buildContext(street: Street, seat: Seat, toCall: Int): DecisionContext {
        val minRaiseTo = currentBet + minRaiseSize
        val maxRaiseTo = seat.committedThisRound + seat.stack
        val affordableCall = minOf(toCall, seat.stack)
        val finalContribution = seat.committedThisHand + affordableCall
        val eligiblePot = seats.sumOf { minOf(it.committedThisHand, finalContribution) }

        var after = 0
        var i = (seat.index + 1) % seats.size
        while (i != seat.index) {
            val o = seats[i]
            if (o.canAct && (!o.hasActed || o.committedThisRound < currentBet)) after++
            i = (i + 1) % seats.size
        }

        return DecisionContext(
            street = street,
            seat = seat,
            board = board.toIntArray(),
            pot = pot(),
            toCall = toCall,
            minRaiseTo = minRaiseTo,
            maxRaiseTo = maxRaiseTo,
            activeOpponents = seats.count { it.contesting && it !== seat },
            seatsActingAfter = after,
            bigBlind = bigBlind,
            history = events,
            handNumber = handNumber,
            decisionToken = pendingToken,
            bettingReopened = mayRaise(seat),
            eligiblePot = eligiblePot,
            currentBet = currentBet,
        )
    }

    /**
     * The single way a call is expressed.
     *
     * `commit()` caps a short stack, so recording the full amount owed would put
     * chips in the hand history that never moved. Every path that resolves to a
     * call goes through here — a direct call, an illegal raise downgraded to one,
     * and a raise whose sanitised target cannot beat the current bet — because
     * three copies of this rule is how it went wrong the first time. That matters
     * most for malformed agent output, which is exactly what an LLM will produce.
     */
    private fun canonicalCall(seat: Seat, toCall: Int): Action =
        if (toCall == 0) Action(ActionType.CHECK)
        else Action(ActionType.CALL, minOf(toCall, seat.stack))

    /** Clamps whatever an agent returns into something legal. */
    private fun sanitise(seat: Seat, toCall: Int, action: Action): Action {
        return when (action.type) {
            // Folding is legal at any turn, including when checking is free — it
            // simply mucks. Rewriting it to CHECK was a lie to the caller: a UI
            // showing a Fold button would fold, and the player would keep getting
            // asked to act. Never silently substitute a different action.
            ActionType.FOLD -> action
            ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action
            ActionType.CALL -> canonicalCall(seat, toCall)
            ActionType.BET, ActionType.RAISE -> {
                // Facing only an incomplete raise after already acting: call or fold.
                if (!mayRaise(seat) && toCall > 0) return canonicalCall(seat, toCall)
                val maxTo = seat.committedThisRound + seat.stack
                val minTo = (currentBet + minRaiseSize).coerceAtMost(maxTo)
                val target = action.amount.coerceIn(minTo, maxTo)
                if (target <= currentBet) {
                    canonicalCall(seat, toCall)
                } else {
                    Action(if (currentBet == 0) ActionType.BET else ActionType.RAISE, target)
                }
            }
        }
    }

    private fun apply(street: Street, seat: Seat, action: Action, toCall: Int) {
        when (action.type) {
            ActionType.FOLD -> seat.folded = true
            ActionType.CHECK -> Unit
            ActionType.CALL -> commit(seat, toCall)
            ActionType.BET, ActionType.RAISE -> {
                val raiseSize = action.amount - currentBet
                commit(seat, action.amount - seat.committedThisRound)
                // Only a full raise raises the bar for subsequent raises. An
                // incomplete all-in leaves minRaiseSize alone, but still lifts
                // currentBet — so it counts toward reopening cumulatively, which
                // mayRaise() computes from each seat's lastActedAtBet.
                if (raiseSize >= minRaiseSize) minRaiseSize = raiseSize
                currentBet = maxOf(currentBet, seat.committedThisRound)
            }
        }
        events.add(HandEvent(street, seat.index, seat.name, action))
    }

    private fun pot(): Int = seats.sumOf { it.committedThisHand }

    /**
     * Refunds any uncalled excess, builds side pots, and awards them.
     *
     * Side pots are layered at each distinct all-in level: every player contributes
     * up to that level, and only players who reached it can win that layer.
     */
    private fun settle(startingStacks: IntArray): HandResult {
        // Return the portion of a bet nobody could match.
        for (s in seats) {
            val maxOther = seats.filter { it !== s }.maxOfOrNull { it.committedThisHand } ?: 0
            if (s.committedThisHand > maxOther) {
                val refund = s.committedThisHand - maxOther
                s.stack += refund
                s.committedThisHand -= refund
            }
        }

        val contenders = seats.filter { it.contesting }
        val winners = ArrayList<Int>()
        var wentToShowdown = false
        val potTotal = pot()

        if (contenders.size == 1) {
            val w = contenders.first()
            w.stack += potTotal
            for (s in seats) s.committedThisHand = 0
            winners.add(w.index)
        } else {
            wentToShowdown = true
            // Cards are face up from here, so snapshots may show them.
            revealed.addAll(contenders.map { it.index })
            val scores = HashMap<Int, Int>()
            for (c in contenders) {
                val seven = IntArray(7)
                seven[0] = c.hole[0]; seven[1] = c.hole[1]
                for (k in board.indices) seven[2 + k] = board[k]
                scores[c.index] = HandEvaluator.evaluate(seven, 2 + board.size)
            }

            val levels = contenders.map { it.committedThisHand }.distinct().sorted()
            var previous = 0
            val pots = ArrayList<Pot>()
            for (level in levels) {
                var amount = 0
                for (s in seats) {
                    amount += (s.committedThisHand.coerceAtMost(level) - s.committedThisHand.coerceAtMost(previous))
                }
                if (amount > 0) {
                    val eligible = contenders.filter { it.committedThisHand >= level }.map { it.index }
                    pots.add(Pot(amount, eligible))
                }
                previous = level
            }

            for (p in pots) {
                val best = p.eligible.maxOf { scores.getValue(it) }
                // TDA Rule 20: the odd chip goes to the first winning seat left of
                // the button, so order winners clockwise from there rather than by
                // seat-list index.
                val potWinners = p.eligible
                    .filter { scores.getValue(it) == best }
                    .sortedBy { (it - button - 1 + seats.size) % seats.size }
                val share = p.amount / potWinners.size
                var remainder = p.amount - share * potWinners.size
                for (w in potWinners) {
                    seats[w].stack += share
                    if (remainder > 0) { seats[w].stack += 1; remainder-- }
                    if (w !in winners) winners.add(w)
                }
            }
            for (s in seats) s.committedThisHand = 0
        }

        val net = IntArray(seats.size) { seats[it].stack - startingStacks[it] }
        return HandResult(
            board = board.toIntArray(),
            net = net,
            winners = winners,
            wentToShowdown = wentToShowdown,
            potSize = potTotal,
            events = ArrayList(events),
        )
    }

    fun boardString(): String = board.joinToString(" ") { Card(it).toString() }
}
