package com.jsjdesigns.poker.game

import com.jsjdesigns.poker.core.StackedDeck
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

private class Shoves : 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)
    }
}

/**
 * How snapshots reach the UI matters as much as what is in them.
 *
 * An all-in runout emits flop, turn and river within microseconds. A consumer
 * that keeps only the latest value will show the player an empty board and then a
 * complete one, losing the runout entirely.
 */
class SnapshotDeliveryTest {

    private fun table(observer: suspend (TableSnapshot) -> Unit, seats: List<Seat>) = Table(
        seats = seats,
        smallBlind = 5,
        bigBlind = 10,
        random = Random(1),
        deck = StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
        observer = observer,
    )

    @Test
    fun `a channel preserves every runout frame`() = runTest {
        val received = mutableListOf<TableSnapshot>()
        val channel = Channel<TableSnapshot>(capacity = 32)

        val consumer = launch {
            for (frame in channel) received += frame
        }

        val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
        table({ channel.send(it) }, seats).playHand()
        channel.close()
        consumer.join()

        val boardSizes = received.map { it.board.size }.distinct().sorted()
        assertEquals(
            listOf(0, 3, 4, 5), boardSizes,
            "every stage of the board must survive delivery",
        )
    }

    /**
     * Documents *why* the channel is required: the same run through a conflating
     * StateFlow drops intermediate frames. This is a characterisation test — if it
     * ever starts preserving them, the reasoning above can be revisited.
     */
    @Test
    fun `a conflating StateFlow loses runout frames`() = runTest {
        val flow = MutableStateFlow<TableSnapshot?>(null)
        val observed = mutableListOf<TableSnapshot>()

        val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
        // A collector that is not actively suspended on every emission — exactly the
        // situation a recomposing UI is in — sees only whatever the latest value is.
        table({ flow.value = it; observed += flow.value!! }, seats).playHand()

        // The final value is all a late subscriber would ever see.
        val lateSubscriberSees = flow.value
        assertEquals(5, lateSubscriberSees?.board?.size, "only the river survives")
        assertTrue(
            observed.map { it.board.size }.distinct().size > 1,
            "the engine did emit intermediate frames; conflation is what loses them",
        )
    }

    /**
     * A genuinely slow consumer, on a RENDEZVOUS channel — what the app uses.
     * The engine must never be more than one frame ahead of what has been shown.
     */
    @Test
    fun `a rendezvous channel keeps the engine within one frame of a slow consumer`() = runTest {
        val received = mutableListOf<TableSnapshot>()
        var sent = 0
        var maxLead = 0
        val channel = Channel<TableSnapshot>(capacity = Channel.RENDEZVOUS)

        val consumer = launch {
            for (frame in channel) {
                received += frame
                delay(50)   // pretend to animate
            }
        }

        val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
        table(
            observer = {
                sent++
                channel.send(it)
                maxLead = maxOf(maxLead, sent - received.size)
            },
            seats = seats,
        ).playHand()
        channel.close()
        consumer.join()

        assertEquals(
            listOf(0, 3, 4, 5), received.map { it.board.size }.distinct().sorted(),
            "no frame may be dropped no matter how slow the consumer",
        )
        assertTrue(
            maxLead <= 1,
            "engine ran $maxLead frames ahead of the display; rendezvous should cap it at 1",
        )
    }

    /**
     * Why the buffer was removed: with depth, the engine races ahead and the
     * action on offer can belong to a later moment than the table on screen.
     */
    @Test
    fun `a deep buffer lets the engine run far ahead of the display`() = runTest {
        val received = mutableListOf<TableSnapshot>()
        var sent = 0
        var maxLead = 0
        val channel = Channel<TableSnapshot>(capacity = 32)

        val consumer = launch {
            for (frame in channel) {
                received += frame
                delay(50)
            }
        }

        val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
        table(
            observer = {
                sent++
                channel.send(it)
                maxLead = maxOf(maxLead, sent - received.size)
            },
            seats = seats,
        ).playHand()
        channel.close()
        consumer.join()

        assertTrue(
            maxLead > 1,
            "a 32-deep buffer should let the engine outrun the display, saw lead of $maxLead",
        )
    }
}
