package com.jsjdesigns.poker.game

import com.jsjdesigns.poker.core.StackedDeck
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/** A snapshot of what a player was offered, taken before the seat mutates. */
private data class Offer(
    val street: Street,
    val toCall: Int,
    val canRaise: Boolean,
    val canCheck: Boolean,
    val pot: Int,
    val eligiblePot: Int,
    val minRaiseTo: Int,
    val maxRaiseTo: Int,
)

private class Scripted(private vararg val actions: Action) : PlayerAgent {
    private var i = 0
    val offers = mutableListOf<Offer>()

    override suspend fun act(ctx: DecisionContext): Action {
        offers += Offer(
            ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck,
            ctx.pot, ctx.eligiblePot, ctx.minRaiseTo, ctx.maxRaiseTo,
        )
        return actions.getOrElse(i++) {
            if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
        }
    }
}

private class RandomAgent(private val random: Random) : PlayerAgent {
    override suspend fun act(ctx: DecisionContext): Action = when (random.nextInt(5)) {
        0 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
        1, 2 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
        else -> Action(ActionType.RAISE, ctx.minRaiseTo + random.nextInt(50))
    }
}

class TableRulesTest {

    // ---------- incomplete (short all-in) raises ----------

    @Test
    fun `short all-in does not reopen betting for a player who already acted`() = runTest {
        val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 30))
        val p1 = Scripted(Action(ActionType.RAISE, 130))  // all-in, only a 30 raise
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 1000, p0),
            Seat(1, "P1", 130, p1),
            Seat(2, "P2", 1000, p2),
        )
        Table(seats, 10, 20, Random(1), StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand()

        // P0 acts twice: opens, then faces the incomplete all-in.
        assertEquals(2, p0.offers.size, "P0 should be asked to act twice")
        assertTrue(p0.offers[0].canRaise, "P0 may raise when first to act")
        assertEquals(30, p0.offers[1].toCall, "P0 owes the extra 30")
        assertFalse(
            p0.offers[1].canRaise,
            "an incomplete all-in must not reopen betting for a player who already acted",
        )
    }

    @Test
    fun `a full raise does reopen betting`() = runTest {
        val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.FOLD))
        val p1 = Scripted(Action(ActionType.RAISE, 300))  // full re-raise
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 1000, p0),
            Seat(1, "P1", 1000, p1),
            Seat(2, "P2", 1000, p2),
        )
        Table(seats, 10, 20, Random(1), StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand()

        assertEquals(2, p0.offers.size)
        assertTrue(p0.offers[1].canRaise, "a full raise restores the right to re-raise")
    }

    /**
     * TDA Rule 47: several incomplete all-ins that together add up to a full raise
     * DO reopen the betting, even though no single one of them would.
     */
    @Test
    fun `cumulative short all-ins reopen betting once they total a full raise`() = runTest {
        // P3 opens to 100 (min raise size becomes 80). Two short all-ins follow,
        // 40 each: neither reopens alone, but together they reach 80.
        val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80))
        val p0 = Scripted(Action(ActionType.RAISE, 140))  // all-in, +40
        val p1 = Scripted(Action(ActionType.RAISE, 180))  // all-in, +40
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 140, p0),
            Seat(1, "P1", 180, p1),
            Seat(2, "P2", 1000, p2),
            Seat(3, "P3", 1000, p3),
        )
        Table(seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()

        assertEquals(2, p3.offers.size, "P3 should face the raised action again")
        assertEquals(80, p3.offers[1].toCall, "P3 owes 180 - 100")
        assertTrue(
            p3.offers[1].canRaise,
            "two 40-chip short all-ins total a full 80 raise, which reopens betting",
        )
    }

    @Test
    fun `cumulative short all-ins below a full raise still do not reopen`() = runTest {
        // Same shape, but the shorts total only 40 against a min raise of 80.
        val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 40))
        val p0 = Scripted(Action(ActionType.RAISE, 120))  // all-in, +20
        val p1 = Scripted(Action(ActionType.RAISE, 140))  // all-in, +20
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 120, p0),
            Seat(1, "P1", 140, p1),
            Seat(2, "P2", 1000, p2),
            Seat(3, "P3", 1000, p3),
        )
        Table(seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()

        assertEquals(2, p3.offers.size)
        assertFalse(p3.offers[1].canRaise, "40 total is short of a full 80 raise")
    }

    @Test
    fun `an illegal raise attempt is downgraded to a call`() = runTest {
        // P0 tries to re-raise after only an incomplete all-in; engine must clamp it.
        val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.RAISE, 500))
        val p1 = Scripted(Action(ActionType.RAISE, 130))
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 1000, p0),
            Seat(1, "P1", 130, p1),
            Seat(2, "P2", 1000, p2),
        )
        val result = Table(seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand()

        val p0Raises = result.events.count { it.seat == 0 && it.action.type == ActionType.RAISE }
        assertEquals(1, p0Raises, "the illegal second raise must be downgraded, not accepted")
    }

    // ---------- side pots ----------

    @Test
    fun `side pots pay the short stack from the main pot only`() = runTest {
        // P0 all-in for 50 with aces, P1 and P2 fight for the rest.
        val p0 = Scripted(Action(ActionType.RAISE, 50))
        val p1 = Scripted(Action(ActionType.CALL, 40), Action(ActionType.RAISE, 150), Action(ActionType.CHECK))
        val p2 = Scripted(Action(ActionType.CALL, 30), Action(ActionType.CALL, 150), Action(ActionType.CHECK))

        val seats = listOf(
            Seat(0, "P0", 50, p0),
            Seat(1, "P1", 200, p1),
            Seat(2, "P2", 200, p2),
        )
        val result = Table(
            seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        assertEquals(0, result.net.sum(), "chips must be conserved")
        // Aces take the 150 main pot -> +100 net on a 50 stack.
        assertEquals(100, result.net[0], "short stack wins main pot only")
        // Kings beat queens for the side pot.
        assertTrue(result.net[1] > 0, "kings should win the side pot")
        assertTrue(result.net[2] < 0, "queens should lose")
    }

    @Test
    fun `decision context excludes inaccessible side-pot chips from pot odds`() = runTest {
        val short = Scripted(Action(ActionType.CALL, 20), Action(ActionType.FOLD))
        val raiser = Scripted(Action(ActionType.RAISE, 100))
        val caller = Scripted(Action(ActionType.CALL, 80))
        val seats = listOf(
            Seat(0, "Short", 50, short),
            Seat(1, "Raiser", 200, raiser),
            Seat(2, "Caller", 200, caller),
        )

        Table(
            seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        val facingRaise = short.offers[1]
        assertEquals(220, facingRaise.pot, "the visible table pot includes the higher side pot")
        assertEquals(
            120,
            facingRaise.eligiblePot,
            "short stack may contest only 50 from each opponent plus its 20 already committed",
        )
        assertEquals(80, facingRaise.toCall)
    }

    /**
     * A genuinely ODD pot: the folded small blind's 5 chips make 25, which two
     * winners cannot split evenly. TDA Rule 20 sends the odd chip to the first
     * winning seat left of the button.
     */
    @Test
    fun `odd chip in a split pot goes to the first winner left of the button`() = runTest {
        val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
        val p1 = Scripted(Action(ActionType.FOLD))
        val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))

        // Button is seat 0 -> SB seat 1 (folds, forfeiting 5), BB seat 2.
        val seats = listOf(Seat(0, "P0", 500, p0), Seat(1, "P1", 500, p1), Seat(2, "P2", 500, p2))
        val result = Table(
            seats, 5, 10, Random(1),
            // Royal flush on board: everyone left plays the board and ties.
            StackedDeck.of(listOf("2c 3d", "4c 5d", "2h 3s"), "Ah Kh Qh Jh Th"),
        ).playHand()

        assertEquals(25, result.potSize, "pot must actually be odd for this test to mean anything")
        assertEquals(1, result.potSize % 2, "pot must be odd")
        assertEquals(0, result.net.sum(), "odd chips must not vanish or be invented")
        assertEquals(2, result.winners.size, "board plays -> split pot")

        // Winners are seats 0 and 2. Clockwise from the button (seat 0), seat 2
        // comes first, so seat 2 takes the extra chip.
        assertEquals(3, result.net[2], "seat left of the button gets the odd chip")
        assertEquals(2, result.net[0], "the other winner gets the smaller share")
        assertEquals(-5, result.net[1], "the folded small blind loses its post")
    }

    // ---------- uncalled bets ----------

    @Test
    fun `an uncalled bet is returned`() = runTest {
        val p0 = Scripted(Action(ActionType.RAISE, 400))
        val p1 = Scripted(Action(ActionType.FOLD))
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 1000, p0),
            Seat(1, "P1", 1000, p1),
            Seat(2, "P2", 1000, p2),
        )
        val result = Table(seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand()

        assertEquals(0, result.net.sum())
        // P0 wins only the blinds; the uncalled 400 comes back.
        assertEquals(30, result.net[0], "winner collects the blinds, not their own uncalled bet")
    }

    // ---------- blinds and action order ----------

    @Test
    fun `heads up button posts the small blind and acts first preflop`() = runTest {
        val p0 = Scripted(Action(ActionType.CALL, 5), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
        val p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))

        val seats = listOf(Seat(0, "P0", 500, p0), Seat(1, "P1", 500, p1))
        val result = Table(seats, 5, 10, Random(1),
            StackedDeck.of(listOf("2c 3d", "2h 3s"), "Ah Kh Qh Jh Th")).playHand()

        val firstPreflop = result.events.first { it.street == Street.PREFLOP }
        assertEquals(0, firstPreflop.seat, "heads-up, the button acts first pre-flop")
        // Button posted the small blind, so it owes 5 to complete.
        assertEquals(5, p0.offers.first().toCall)
    }

    @Test
    fun `six handed action starts left of the big blind`() = runTest {
        val agents = List(6) { Scripted() }
        val seats = agents.mapIndexed { i, a -> Seat(i, "P$i", 500, a) }
        val result = Table(seats, 5, 10, Random(1), StackedDeck.of(List(6) { "" }.let {
            listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd", "Th Td", "9h 9d")
        }, "2c 7d 4s 5c 3h")).playHand()

        // Button defaults to seat 0 -> SB seat 1, BB seat 2, first to act seat 3.
        val firstPreflop = result.events.first { it.street == Street.PREFLOP }
        assertEquals(3, firstPreflop.seat, "under the gun is left of the big blind")
    }

    // ---------- malformed agent output ----------

    @Test
    fun `malformed actions are sanitised`() = runTest {
        // Tries to check facing a bet, and to bet far beyond its stack.
        val p0 = Scripted(Action(ActionType.RAISE, 999_999))
        val p1 = Scripted(Action(ActionType.CHECK))
        val p2 = Scripted(Action(ActionType.FOLD))

        val seats = listOf(
            Seat(0, "P0", 300, p0),
            Seat(1, "P1", 300, p1),
            Seat(2, "P2", 300, p2),
        )
        val result = Table(seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h")).playHand()

        assertEquals(0, result.net.sum())
        assertTrue(seats.all { it.stack >= 0 }, "no stack may go negative")
        // The oversized raise must have been clamped to the 300 stack.
        val raise = result.events.first { it.seat == 0 && it.action.type == ActionType.RAISE }
        assertTrue(raise.action.amount <= 300, "raise clamped to stack, was ${raise.action.amount}")
        // Checking into a bet becomes a fold.
        assertEquals(ActionType.FOLD, result.events.first { it.seat == 1 }.action.type)
    }

    // ---------- invariants under fuzzing ----------

    @Test
    fun `chips are conserved and stacks stay non-negative over many random hands`() = runTest {
        val random = Random(4242)
        val seats = List(6) { Seat(it, "P$it", 400, RandomAgent(random)) }
        val table = Table(seats, 5, 10, random)

        val startingTotal = seats.sumOf { it.stack }
        repeat(3000) {
            table.advanceButton()
            val result = table.playHand()
            assertEquals(0, result.net.sum(), "hand did not conserve chips")
            assertTrue(seats.all { it.stack >= 0 }, "a stack went negative")
            // Re-seed short stacks so play continues.
            for (s in seats) if (s.stack < 20) s.stack = 400
        }
        assertTrue(startingTotal > 0)
    }
}
