package com.jsjdesigns.poker.bot

import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.Street
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull

data class PersonaCueId(
    val handNumber: Int,
    /** One-based, monotonic within a hand. */
    val actionNumber: Int,
)

/**
 * The complete language-model input boundary.
 *
 * Every value is public and describes an action the engine has already accepted.
 * There are deliberately no cards, equity, legal-action choices, mutable seats,
 * or means to return a poker action.
 */
data class PersonaCue(
    val id: PersonaCueId,
    val street: Street,
    val speakerSeat: Int,
    val speakerName: String,
    val persona: String,
    val actionType: ActionType,
    val amount: Int,
    val pot: Int,
    val isAllIn: Boolean,
    val activePlayers: Int,
) {
    init {
        require(id.handNumber > 0)
        require(id.actionNumber > 0)
        require(speakerSeat >= 0)
        require(speakerName.isNotBlank())
        require(amount >= 0)
        require(pot >= 0)
        require(activePlayers > 0)
    }
}

data class TableTalkLine(
    val cueId: PersonaCueId,
    val street: Street,
    val speakerSeat: Int,
    val speakerName: String,
    val text: String,
)

fun interface PersonaNarrator {
    /** Returns language only; null means this narrator elects not to speak. */
    suspend fun narrate(cue: PersonaCue): String?
}

/**
 * Decides when language is appropriate, bounds latency/output, and supplies a
 * deterministic offline fallback. It never participates in [MathBot.act].
 */
class PersonaArbiter(
    private val primary: PersonaNarrator,
    private val fallback: PersonaNarrator = OfflinePersonaNarrator(),
    private val timeoutMillis: Long = 1_200,
    private val maxCharacters: Int = 96,
) {
    init {
        require(timeoutMillis > 0)
        require(maxCharacters > 0)
    }

    suspend fun lineFor(cue: PersonaCue): TableTalkLine? {
        if (!shouldRequestPersonaLine(cue)) return null

        val text = when (val result = request(primary, cue)) {
            is NarrationResult.Completed -> {
                // Null is a deliberate choice not to speak; blank/invalid output
                // falls back because it cannot be rendered honestly.
                if (result.text == null) return null
                sanitise(result.text) ?: fallbackText(cue)
            }
            NarrationResult.Failed -> fallbackText(cue)
        } ?: return null
        return TableTalkLine(
            cueId = cue.id,
            street = cue.street,
            speakerSeat = cue.speakerSeat,
            speakerName = cue.speakerName,
            text = text,
        )
    }

    private suspend fun fallbackText(cue: PersonaCue): String? =
        when (val result = request(fallback, cue)) {
            is NarrationResult.Completed -> sanitise(result.text)
            NarrationResult.Failed -> null
        }

    private suspend fun request(
        narrator: PersonaNarrator,
        cue: PersonaCue,
    ): NarrationResult {
        return try {
            withTimeoutOrNull(timeoutMillis) {
                // Wrap null so an intentional choice not to speak is distinct
                // from this timeout returning null.
                NarrationResult.Completed(narrator.narrate(cue))
            } ?: NarrationResult.Failed
        } catch (cancelled: CancellationException) {
            throw cancelled
        } catch (_: RuntimeException) {
            NarrationResult.Failed
        }
    }

    private fun sanitise(raw: String?): String? {
        val singleLine = raw
            ?.replace(WHITESPACE, " ")
            ?.trim()
            ?.take(maxCharacters)
            ?.trim()
        return singleLine?.takeIf(String::isNotEmpty)
    }

    private companion object {
        val WHITESPACE = Regex("\\s+")
    }

    private sealed interface NarrationResult {
        data class Completed(val text: String?) : NarrationResult
        data object Failed : NarrationResult
    }
}

/**
 * Stable rate gate: lively after aggression and all-ins, restrained otherwise.
 * Re-reading the same frame can never change whether somebody speaks.
 */
internal fun shouldRequestPersonaLine(cue: PersonaCue): Boolean {
    if (cue.isAllIn) return true
    val chance = when (cue.actionType) {
        ActionType.BET, ActionType.RAISE -> 55
        ActionType.CALL -> 20
        ActionType.FOLD -> 12
        ActionType.CHECK -> 8
    }
    var hash = 17
    hash = hash * 31 + cue.id.handNumber
    hash = hash * 31 + cue.id.actionNumber
    hash = hash * 31 + cue.speakerSeat
    hash = hash * 31 + cue.actionType.ordinal
    return (hash and Int.MAX_VALUE) % 100 < chance
}

/**
 * Network-free voice used today and whenever a future narrator times out.
 *
 * Lines react only to public action shape and never claim private hand strength.
 */
class OfflinePersonaNarrator : PersonaNarrator {
    override suspend fun narrate(cue: PersonaCue): String {
        val lines = linesFor(cue)
        var hash = cue.speakerName.hashCode()
        hash = hash * 31 + cue.persona.hashCode()
        hash = hash * 31 + cue.id.handNumber
        hash = hash * 31 + cue.id.actionNumber
        return lines[(hash and Int.MAX_VALUE) % lines.size]
    }

    private fun linesFor(cue: PersonaCue): List<String> {
        val action = when (cue.actionType) {
            ActionType.BET, ActionType.RAISE -> "pressure"
            ActionType.CALL -> "call"
            ActionType.CHECK -> "check"
            ActionType.FOLD -> "fold"
        }
        return VOICES[cue.speakerName]?.get(action)
            ?: GENERIC.getValue(action)
    }

    private companion object {
        val GENERIC = mapOf(
            "pressure" to listOf("Let's make this interesting.", "Your move."),
            "call" to listOf("I'll take another look.", "Still here."),
            "check" to listOf("Go ahead.", "Let's see what develops."),
            "fold" to listOf("Not this one.", "You can have it."),
        )

        val VOICES = mapOf(
            "Ada" to mapOf(
                "pressure" to listOf("Pressure clarifies things.", "Your decision."),
                "call" to listOf("Continue.", "Noted."),
                "check" to listOf("Proceed.", "For now."),
                "fold" to listOf("No value in this one.", "Next hand."),
            ),
            "Bruno" to mapOf(
                "pressure" to listOf("Let's turn the dial.", "Comfortable yet?"),
                "call" to listOf("I'm not going anywhere.", "Keep talking."),
                "check" to listOf("Show me something.", "After you."),
                "fold" to listOf("Enjoy the small one.", "Take it."),
            ),
            "Cleo" to mapOf(
                "pressure" to listOf("A little louder this time.", "Let's add some weight."),
                "call" to listOf("I'll stay.", "One more card."),
                "check" to listOf("Quiet is useful.", "I'm listening."),
                "fold" to listOf("Nothing to prove.", "I'll wait."),
            ),
            "Dex" to mapOf(
                "pressure" to listOf("Maybe this gets us somewhere.", "Worth a try."),
                "call" to listOf("Worth a look.", "I have to see it."),
                "check" to listOf("Free card? Sure.", "Let's keep it friendly."),
                "fold" to listOf("Okay, okay.", "Not worth the ticket."),
            ),
            "Enzo" to mapOf(
                "pressure" to listOf("Now we're playing!", "More chips, more truth!"),
                "call" to listOf("Of course I'm in.", "You can't lose me that easily."),
                "check" to listOf("A tactical pause!", "Suspense is part of the game."),
                "fold" to listOf("Strategic retreat!", "I was being merciful."),
            ),
        )
    }
}
