package com.jsjdesigns.poker.game

import com.jsjdesigns.poker.core.StackedDeck
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
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.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

private class Folder : PlayerAgent {
    override suspend fun act(ctx: DecisionContext): Action =
        if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
}

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

/** Opens once so that someone downstream actually has a bet to fold to. */
private class Raiser(private val to: Int) : PlayerAgent {
    private var raised = false
    override suspend fun act(ctx: DecisionContext): Action = when {
        !raised && ctx.canRaise -> { raised = true; Action(ActionType.RAISE, to) }
        ctx.canCheck -> Action(ActionType.CHECK)
        else -> Action(ActionType.CALL, ctx.toCall)
    }
}

class SnapshotAndHumanAgentTest {

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

    // ---------- snapshots ----------

    @Test
    fun `snapshots are published for the deal, every action, and the finish`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(
            Seat(0, "A", 500, Caller()),
            Seat(1, "B", 500, Caller()),
        )
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        assertTrue(seen.isNotEmpty(), "the UI must receive something to render")
        assertEquals(TableSnapshot.Phase.DEALT, seen.first().phase, "first snapshot is the deal")
        assertTrue(
            seen.any { it.phase == TableSnapshot.Phase.BETTING && it.toAct != null },
            "some snapshot must name whose turn it is",
        )
        assertTrue(
            seen.last().phase == TableSnapshot.Phase.SHOWDOWN ||
                seen.last().phase == TableSnapshot.Phase.COMPLETE,
            "the hand must end on a terminal phase, was ${seen.last().phase}",
        )
    }

    @Test
    fun `a snapshot carries the board and pot as the hand progresses`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(Seat(0, "A", 500, Caller()), Seat(1, "B", 500, Caller()))
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        assertEquals(0, seen.first().board.size, "no board on the deal")
        assertEquals(5, seen.last().board.size, "full board by the end")
        assertTrue(seen.last().pot > 0)
        assertTrue(seen.all { it.handNumber == 1 })
    }

    @Test
    fun `masking hides other players hole cards until showdown`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(Seat(0, "A", 500, Caller()), Seat(1, "B", 500, Caller()))
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        val duringPlay = seen.first { it.phase == TableSnapshot.Phase.DEALT }.maskedFor(viewer = 0)
        assertNotNull(duringPlay.seats[0].hole, "a player always sees their own cards")
        assertNull(duringPlay.seats[1].hole, "an opponent's cards must be hidden")

        // Both called to the river, so both are revealed at showdown.
        val end = seen.last().maskedFor(viewer = 0)
        assertNotNull(end.seats[1].hole, "cards turned up at showdown stay visible")
    }

    @Test
    fun `folded hands are never revealed`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        // The raiser gives the folder something to fold to; a checker faces nothing
        // heads-up, and sanitise() turns a pointless FOLD into a CHECK.
        val seats = listOf(Seat(0, "A", 500, Raiser(to = 40)), Seat(1, "B", 500, Folder()))
        val result = Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        assertFalse(result.wentToShowdown, "the hand must actually end in a fold")
        val end = seen.last().maskedFor(viewer = 0)
        val folded = end.seats.first { it.folded }
        assertNull(folded.hole, "a folded hand must stay face down")
    }

    // ---------- human input ----------

    @Test
    fun `submit resolves the pending decision`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)
        val decision = async { human.act(context(seat)) }

        while (!human.isAwaitingInput) yield()
        assertNotNull(human.offer.value, "the UI must be able to see what is on offer")
        assertTrue(human.submit(human.offer.value!!.token, Action(ActionType.CALL, 10)))

        assertEquals(ActionType.CALL, decision.await().type)
        assertFalse(human.isAwaitingInput, "the wait must clear once answered")
    }

    @Test
    fun `submitting when nothing is pending is harmless`() = runTest {
        val human = HumanAgent()
        assertFalse(human.submit(1L, Action(ActionType.FOLD)), "a stale tap must not crash")
    }

    @Test
    fun `cancelling the hand releases a human waiting for input`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)
        val job = launch { human.act(context(seat)) }

        while (!human.isAwaitingInput) yield()
        job.cancel()
        job.join()

        assertFalse(
            human.isAwaitingInput,
            "cancellation must clear the pending wait, not strand it forever",
        )
        // The agent is reusable afterwards.
        val next = async { human.act(context(seat)) }
        while (!human.isAwaitingInput) yield()
        assertTrue(human.submit(human.offer.value!!.token, Action(ActionType.FOLD)))
        assertEquals(ActionType.FOLD, next.await().type)
    }

    @Test
    fun `a human can play a full hand against a bot`() = runTest {
        val human = HumanAgent()
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(Seat(0, "You", 500, human), Seat(1, "Bot", 500, Caller()))
        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 = async { table.playHand() }
        // Fold at the first opportunity.
        while (!human.isAwaitingInput) yield()
        human.submit(human.offer.value!!.token, Action(ActionType.FOLD))

        val result = hand.await()
        assertEquals(0, result.net.sum(), "chips conserved through a human-driven hand")
        assertTrue(seen.isNotEmpty())
        assertEquals(listOf(1), result.winners, "the bot wins once the human folds")
    }
}

class HumanOfferAndStreetStateTest {

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

    /** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */
    @Test
    fun `cancel releases the waiting agent and reports whether anything was pending`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)

        assertFalse(human.cancel(), "nothing pending yet")

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

        assertTrue(human.cancel(), "cancel reports that it released a pending wait")
        assertTrue(decision.await().isFailure, "act must complete exceptionally")
        assertNull(human.offer.value, "the offer must be cleared")
        assertFalse(human.isAwaitingInput)
    }

    @Test
    fun `the published offer is an immutable copy, not a live seat`() = runTest {
        val human = HumanAgent()
        val seat = Seat(0, "You", 500, human)
        seat.hole = intArrayOf(0, 5)

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

        // Mutating the live seat the way the game coroutine would must not be
        // visible through the already-published offer.
        seat.stack = 1
        seat.committedThisRound = 999
        assertEquals(500, offer.stack, "offer must not alias live seat state")
        assertEquals(listOf(0, 5), offer.hole)
        assertEquals(1, offer.activeOpponents)
        assertEquals(0, offer.seatsActingAfter)
        assertEquals(10, offer.bigBlind)
        assertEquals(15, offer.eligiblePot)
        assertEquals(10, offer.currentBet)

        human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
        decision.await()
    }

    @Test
    fun `a new street snapshot carries no stale betting from the previous street`() = runTest {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(Seat(0, "A", 500, Caller()), Seat(1, "B", 500, Caller()))
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()

        val streetStarts = seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE }
        assertTrue(streetStarts.isNotEmpty(), "there should be new-street snapshots")
        for (snap in streetStarts) {
            assertEquals(0, snap.currentBet, "a fresh street starts with no bet outstanding")
            assertTrue(
                snap.seats.all { it.committedThisRound == 0 },
                "chips from the previous street must be swept into the pot first",
            )
        }
        // The pot must still reflect everything committed so far.
        assertTrue(streetStarts.all { it.pot > 0 }, "the pot carries forward")
    }
}

/** Shoves everything in at the first opportunity. */
private class Shover : PlayerAgent {
    override suspend fun act(ctx: DecisionContext): Action = when {
        ctx.canRaise -> Action(ActionType.RAISE, ctx.maxRaiseTo)
        ctx.toCall > 0 -> Action(ActionType.CALL, ctx.toCall)
        else -> Action(ActionType.CHECK)
    }
}

class AllInRunoutTest {

    private suspend fun runPreflopAllIn(): List<TableSnapshot> {
        val seen = mutableListOf<TableSnapshot>()
        val seats = listOf(Seat(0, "A", 100, Shover()), Seat(1, "B", 100, Shover()))
        Table(
            seats, 5, 10, Random(1),
            StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
            observer = { seen += it },
        ).playHand()
        return seen
    }

    @Test
    fun `a preflop all-in never reports a full board while still labelled preflop`() = runTest {
        val seen = runPreflopAllIn()
        for (snap in seen) {
            if (snap.board.size == 5) {
                assertEquals(
                    Street.RIVER, snap.street,
                    "a five-card board must be labelled RIVER, was ${snap.street}",
                )
            }
            if (snap.street == Street.PREFLOP) {
                assertEquals(0, snap.board.size, "PREFLOP must never carry board cards")
            }
        }
    }

    @Test
    fun `the runout is published street by street so the UI can animate it`() = runTest {
        val seen = runPreflopAllIn()
        val boardSizes = seen.map { it.board.size }.distinct()
        assertTrue(
            boardSizes.containsAll(listOf(0, 3, 4, 5)),
            "expected the board to appear 0 -> 3 -> 4 -> 5, saw $boardSizes",
        )

        val streets = seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE }.map { it.street }
        assertEquals(
            listOf(Street.FLOP, Street.TURN, Street.RIVER), streets,
            "each street of the runout should be published in order",
        )
    }

    @Test
    fun `the terminal snapshot of an all-in is a river showdown`() = runTest {
        val seen = runPreflopAllIn()
        val last = seen.last()
        assertEquals(Street.RIVER, last.street)
        assertEquals(5, last.board.size)
        assertEquals(TableSnapshot.Phase.SHOWDOWN, last.phase)
        assertTrue(last.pot > 0, "the terminal snapshot must still show the contested pot")
    }

    @Test
    fun `runout snapshots do not carry stale betting`() = runTest {
        val seen = runPreflopAllIn()
        for (snap in seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE }) {
            assertEquals(0, snap.currentBet, "no bet is outstanding during a runout")
            assertTrue(
                snap.seats.all { it.committedThisRound == 0 },
                "chips should already be swept into the pot",
            )
        }
    }
}
