package com.jsjdesigns.poker.game

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

private class AlwaysChecks : PlayerAgent {
    override suspend fun act(ctx: DecisionContext): Action =
        if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
}

/** Folds the first time it acts, whatever the situation. */
private class FoldsOnce : PlayerAgent {
    var timesAsked = 0
        private set
    private var folded = false
    override suspend fun act(ctx: DecisionContext): Action {
        timesAsked++
        return if (!folded) {
            folded = true
            Action(ActionType.FOLD)
        } else if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
    }
}

class FoldAndTokenTest {

    private fun context(seat: Seat, canCheck: Boolean = false, token: Long = 1L) = DecisionContext(
        street = Street.FLOP,
        seat = seat,
        board = IntArray(0),
        pot = 20,
        toCall = if (canCheck) 0 else 10,
        minRaiseTo = 20,
        maxRaiseTo = 500,
        activeOpponents = 1,
        seatsActingAfter = 0,
        bigBlind = 10,
        history = emptyList(),
        handNumber = 1,
        decisionToken = token,
        bettingReopened = true,
        eligiblePot = 20,
        currentBet = if (canCheck) 0 else 10,
    )

    // ---------- fold is honoured, never silently substituted ----------

    /**
     * The engine used to rewrite FOLD to CHECK whenever checking was free. A UI
     * showing a Fold button would then fold, and the player would be asked to act
     * again on the next street — which is exactly what it looked like: a bug.
     */
    @Test
    fun `folding when checking is free actually folds`() = runTest {
        val folder = FoldsOnce()
        val seats = listOf(Seat(0, "A", 500, folder), Seat(1, "B", 500, AlwaysChecks()))
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        assertTrue(seats[0].folded, "a fold must fold, even when checking was free")
        assertEquals(listOf(1), result.winners, "the other player takes it uncontested")
    }

    @Test
    fun `a folded player is never asked to act again that hand`() = runTest {
        val folder = FoldsOnce()
        val seats = listOf(
            Seat(0, "A", 500, folder),
            Seat(1, "B", 500, AlwaysChecks()),
            Seat(2, "C", 500, AlwaysChecks()),
        )
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        assertTrue(seats[0].folded)
        assertEquals(
            1, folder.timesAsked,
            "a player who folded must not be offered another decision this hand",
        )
    }

    @Test
    fun `checking into a bet is still downgraded to a fold`() = runTest {
        // The reverse substitution is legitimate: CHECK is simply illegal there.
        val seats = listOf(
            Seat(0, "A", 500, PlayerAgent { Action(ActionType.RAISE, 100) }),
            Seat(1, "B", 500, PlayerAgent { Action(ActionType.CHECK) }),
        )
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        assertTrue(seats[1].folded, "an illegal check becomes a fold")
        assertEquals(0, result.net.sum())
    }

    // ---------- decision tokens ----------

    @Test
    fun `a stale token is rejected`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)

        val first = async { human.act(context(seat, token = 10L)) }
        while (!human.isAwaitingInput) yield()
        val staleToken = human.offer.value!!.token
        assertTrue(human.submit(staleToken, Action(ActionType.FOLD)))
        first.await()

        // A second decision arrives with a new token.
        val second = async { human.act(context(seat, token = 11L)) }
        while (!human.isAwaitingInput) yield()
        val freshToken = human.offer.value!!.token
        assertNotEquals(staleToken, freshToken, "each decision gets its own token")

        assertFalse(
            human.submit(staleToken, Action(ActionType.FOLD)),
            "a tap left over from the previous decision must not be applied here",
        )
        assertTrue(human.submit(freshToken, Action(ActionType.CHECK)))
        assertEquals(ActionType.CHECK, second.await().type)
    }

    @Test
    fun `a double tap does not act twice`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)

        val decision = async { human.act(context(seat)) }
        while (!human.isAwaitingInput) yield()
        val token = human.offer.value!!.token

        assertTrue(human.submit(token, Action(ActionType.FOLD)), "first tap lands")
        assertFalse(human.submit(token, Action(ActionType.CALL, 10)), "second tap is dropped")
        assertEquals(ActionType.FOLD, decision.await().type)
    }

    @Test
    fun `tokens keep advancing across decisions`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)
        val seen = mutableListOf<Long>()

        repeat(3) { i ->
            val d = async { human.act(context(seat, token = 100L + i)) }
            while (!human.isAwaitingInput) yield()
            val t = human.offer.value!!.token
            seen += t
            human.submit(t, Action(ActionType.CHECK))
            d.await()
        }
        assertEquals(seen.distinct(), seen, "tokens must never repeat")
        assertEquals(seen.sorted(), seen, "and should advance monotonically")
    }
}

/** The token has to be the same value on both sides of the UI boundary. */
class EngineTokenIdentityTest {

    @Test
    fun `the snapshot announcing a turn carries the same token as the offer`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val human = HumanAgent()
        val seats = listOf(Seat(0, "You", 500, human), Seat(1, "Bot", 500, AlwaysChecks()))
        val table = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        )

        val hand = kotlinx.coroutines.CoroutineScope(kotlin.coroutines.EmptyCoroutineContext).let {
            async { table.playHand() }
        }
        while (!human.isAwaitingInput) yield()

        val offerToken = human.offer.value!!.token
        val announcing = seen.last { it.toAct == 0 }
        assertEquals(
            announcing.toActToken, offerToken,
            "the frame that says it is your turn must identify the same decision",
        )

        human.submit(offerToken, Action(ActionType.FOLD))
        hand.await()
    }

    @Test
    fun `every decision in a hand gets a distinct token`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(
            Seat(0, "A", 500, AlwaysChecks()),
            Seat(1, "B", 500, AlwaysChecks()),
            Seat(2, "C", 500, AlwaysChecks()),
        )
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        val tokens = seen.mapNotNull { it.toActToken }
        assertTrue(tokens.size > 3, "several decisions should have occurred")
        assertEquals(tokens.distinct().sorted(), tokens.distinct(), "tokens advance")
        // Each announcing frame is a distinct decision.
        val announcing = seen.filter { it.toAct != null }.mapNotNull { it.toActToken }
        assertEquals(announcing.size, announcing.distinct().size, "no token reused")
    }
}

/**
 * Hand history is read by replay and, later, by the coach. It has to describe
 * chips that actually moved.
 */
class CallAmountHonestyTest {

    @Test
    fun `a call is recorded at what was committed, not what was owed`() = runTest {
        // Heads-up, seat 1 is the big blind: 10 is already posted, so of its 40 the
        // call itself can only commit the remaining 30.
        val seats = listOf(
            Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }),
            Seat(1, "B", 40, PlayerAgent { ctx -> Action(ActionType.CALL, ctx.toCall) }),
        )
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        val call = result.events.first { it.seat == 1 && it.action.type == ActionType.CALL }
        assertEquals(
            30, call.action.amount,
            "history must record the 30 the call actually committed, not the 90 owed",
        )
        assertTrue(seats[1].allIn, "the short stack is all in")
        assertEquals(0, result.net.sum(), "chips still conserve")
    }

    @Test
    fun `an affordable call is recorded at full price`() = runTest {
        val seats = listOf(
            Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }),
            Seat(1, "B", 1000, PlayerAgent { ctx -> Action(ActionType.CALL, ctx.toCall) }),
        )
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        val call = result.events.first { it.seat == 1 && it.action.type == ActionType.CALL }
        assertEquals(90, call.action.amount, "the big blind's 10 is already in, so 90 completes the 100")
    }

    @Test
    fun `the offer reports what a call really costs`() = runTest {
        val human = HumanAgent()
        val seats = listOf(
            Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 300) }),
            Seat(1, "You", 60, human),
        )
        val table = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        )
        val hand = async { table.playHand() }
        while (!human.isAwaitingInput) yield()

        val o = human.offer.value!!
        assertTrue(o.toCall > o.stack, "this setup should face more than the stack")
        assertEquals(o.stack, o.callAmount, "callAmount is capped at the stack")
        assertTrue(o.callIsAllIn, "and is flagged as an all-in call")

        human.submit(o.token, Action(ActionType.CALL, o.callAmount))
        val result = hand.await()
        assertEquals(0, result.net.sum())
    }
}

/**
 * Every path that resolves to a call must record what was committed, not what
 * was owed — including the two downgrade paths, which is where malformed agent
 * output lands. An LLM will produce exactly that kind of output.
 */
class DowngradedCallHonestyTest {

    @Test
    fun `an illegal raise from a short stack records only its remaining chips`() = runTest {
        val seats = listOf(
            Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }),
            // 40 total, 10 of it already posted as the big blind: 30 behind.
            Seat(1, "B", 40, PlayerAgent { Action(ActionType.RAISE, 500) }),
        )
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        ).playHand()
        val call = result.events.first { it.seat == 1 && it.action.type == ActionType.CALL }
        assertEquals(
            30, call.action.amount,
            "the illegal raise downgraded to a call must record the 30 actually committed",
        )
        assertTrue(seats[1].allIn)
        assertEquals(0, result.net.sum())
    }

    @Test
    fun `a short stack barred from re-raising records only its remaining chips`() = runTest {
        // P0 opens to 100 leaving 20 behind, P1 shoves short (does not reopen), so
        // P0 may no longer raise. It tries anyway and must be downgraded honestly.
        val seats = listOf(
            Seat(0, "P0", 120, PlayerAgent { Action(ActionType.RAISE, 100) }),
            Seat(1, "P1", 130, PlayerAgent { Action(ActionType.RAISE, 130) }),
            Seat(2, "P2", 1000, PlayerAgent { Action(ActionType.FOLD) }),
        )
        val result = Table(
            seats, 10, 20, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
        ).playHand()

        val calls = result.events.filter { it.seat == 0 && it.action.type == ActionType.CALL }
        assertTrue(calls.isNotEmpty(), "P0 should have been downgraded to a call")
        assertEquals(
            20, calls.last().action.amount,
            "P0 owes 30 but has only 20 behind; history must say 20",
        )
        assertEquals(0, result.net.sum())
    }
}
