package com.jsjdesigns.poker

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.CoachingReview
import com.jsjdesigns.poker.bot.DecisionCoach
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.DecisionOffer
import com.jsjdesigns.poker.game.HumanAgent
import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Table
import com.jsjdesigns.poker.game.TableSnapshot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random

const val HERO_SEAT = 0

data class UiState(
    /** Null until the player has explicitly started a cash-game session. */
    val gameConfig: CashGameConfig? = null,
    val snapshot: TableSnapshot? = null,
    val handsPlayed: Int = 0,
    val handSummary: HandSummary? = null,
    val nextHandRequested: Boolean = false,
    val heroNeedsRebuy: Boolean = false,
    val coachReview: CoachingReview? = null,
    /** Most recent engine-owned human decision identity observed on screen. */
    val latestHeroDecisionToken: Long? = null,
) {
    /**
     * The decision to show, or null.
     *
     * An offer is only live when the table on screen is the one it belongs to.
     * The engine can be a frame ahead of the animation, so a raw offer could
     * otherwise be rendered against a stale board — or worse, against a different
     * hand entirely.
     */
    fun liveOffer(offer: DecisionOffer?): DecisionOffer? {
        val snap = snapshot ?: return null
        if (offer == null) return null
        // Match on the engine's decision token, not hand+street. A player can face
        // two decisions on one street (bet, get raised, act again), so hand+street
        // is ambiguous and would briefly pair a new offer with a stale board.
        return offer.takeIf { snap.toActToken == it.token }
    }

    /**
     * A result belongs on screen only beside the terminal frame for that hand.
     * This keeps a delayed result or a new deal from being paired with the wrong
     * board in exactly the same way [liveOffer] protects decisions.
     */
    fun visibleHandSummary(): HandSummary? {
        val snap = snapshot ?: return null
        val terminal = snap.phase == TableSnapshot.Phase.SHOWDOWN ||
            snap.phase == TableSnapshot.Phase.COMPLETE
        return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber }
    }

    /**
     * An asynchronous review is accepted only while its exact decision remains
     * the latest human decision in the displayed hand.
     */
    fun acceptsCoachReview(review: CoachingReview): Boolean =
        gameConfig?.coachEnabled == true &&
            latestHeroDecisionToken == review.decisionToken &&
            snapshot?.handNumber == review.handNumber

    fun visibleCoachReview(): CoachingReview? =
        coachReview.takeIf { it != null && acceptsCoachReview(it) }
}

/**
 * Drives a continuous cash game and publishes it to Compose.
 *
 * **Frames are queued, not conflated.** The engine can emit the flop, turn and
 * river of an all-in runout within microseconds of each other. Pushing those
 * straight into a `StateFlow` would collapse them — `StateFlow` keeps only the
 * latest value — and the board would appear to jump from empty to complete.
 * Instead the engine's suspending observer sends into a [Channel], a consumer
 * paces each frame, and only then is `StateFlow` updated. Because the observer
 * suspends when the channel is full, the engine cannot outrun the animation:
 * backpressure does the pacing for us.
 */
class PokerViewModel : ViewModel() {

    private val human = HumanAgent()
    private val coach = DecisionCoach()
    private val nextHandGate = NextHandGate()
    // RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of
    // frames ahead of the animation, so the board on screen and the action being
    // offered could belong to different moments — even different hands. With no
    // buffer the engine is at most one frame ahead of what the player can see.
    private val frames = Channel<TableSnapshot>(capacity = Channel.RENDEZVOUS)

    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()

    /** What the player is being asked to decide, or null when it isn't their turn. */
    val offer = human.offer

    private var gameStarted = false
    private lateinit var seats: List<Seat>
    private lateinit var table: Table

    init {
        viewModelScope.launch { consumeFrames() }
    }

    /**
     * Creates the table exactly once, after the player chooses to take a seat.
     * A rapid double tap cannot launch two engines against the same HumanAgent.
     */
    fun startGame(playerName: String, coachEnabled: Boolean) {
        if (gameStarted) return
        gameStarted = true

        val config = cashGameConfig(playerName, coachEnabled)
        val roster = listOf(
            BotProfile(config.playerName, SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
            BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
            BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."),
            BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."),
            BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."),
            BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
        )
        val deckRandom = Random(System.nanoTime())
        seats = roster.mapIndexed { i, profile ->
            val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
            Seat(i, profile.name, config.buyIn, agent)
        }
        table = Table(
            seats = seats,
            smallBlind = config.smallBlind,
            bigBlind = config.bigBlind,
            random = deckRandom,
            observer = { frames.send(it) },
        )

        _state.update { UiState(gameConfig = config) }
        viewModelScope.launch(Dispatchers.Default) { playContinuously(config) }
    }

    private suspend fun consumeFrames() {
        for (frame in frames) {
            _state.update { current ->
                val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber
                val heroDecisionToken =
                    frame.toActToken.takeIf { frame.toAct == HERO_SEAT }
                val isNewHeroDecision =
                    heroDecisionToken != null &&
                        heroDecisionToken != current.latestHeroDecisionToken
                current.copy(
                    snapshot = frame.maskedFor(HERO_SEAT),
                    // Keep the result visible after the tap until the new deal
                    // actually reaches the screen. This avoids flashing a stale
                    // terminal board with a generic "Waiting…" message.
                    handSummary = current.handSummary.takeIf { sameCompletedHand },
                    nextHandRequested = current.nextHandRequested && sameCompletedHand,
                    heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand,
                    coachReview = current.coachReview.takeIf {
                        it?.handNumber == frame.handNumber && !isNewHeroDecision
                    },
                    latestHeroDecisionToken =
                        heroDecisionToken ?: current.latestHeroDecisionToken,
                )
            }
            delay(pacingMillis(frame))
        }
    }

    /**
     * How long to hold a frame on screen. Zero when the player is on the clock —
     * never make someone wait to act — and longest for cards landing and for the
     * showdown, which are the moments worth watching.
     */
    private fun pacingMillis(frame: TableSnapshot): Long = when (frame.phase) {
        TableSnapshot.Phase.DEALT -> 400
        TableSnapshot.Phase.BETTING ->
            if (frame.toAct == HERO_SEAT) 0 else if (frame.toAct != null) 250 else 450
        TableSnapshot.Phase.STREET_COMPLETE -> 700
        TableSnapshot.Phase.SHOWDOWN -> 2200
        TableSnapshot.Phase.COMPLETE -> 1200
    }

    private suspend fun playContinuously(config: CashGameConfig) {
        while (true) {
            // Opponents rebuy automatically. The human can re-enter only through
            // the explicit result-screen action below.
            val botTopUps = automaticTopUpSeatIndices(
                stacks = seats.map(Seat::stack),
                heroSeat = HERO_SEAT,
                bigBlind = config.bigBlind,
            )
            for (seatIndex in botTopUps) seats[seatIndex].stack = config.buyIn
            table.advanceButton()
            runCatching { table.playHand() }
                .onSuccess { result ->
                    val summary = handSummaryFor(
                        handNumber = table.handNumber,
                        result = result,
                        seatNames = seats.map(Seat::name),
                    )
                    val heroNeedsRebuy = seats[HERO_SEAT].stack < config.bigBlind
                    _state.update {
                        it.copy(
                            handsPlayed = it.handsPlayed + 1,
                            handSummary = summary,
                            nextHandRequested = false,
                            heroNeedsRebuy = heroNeedsRebuy,
                        )
                    }
                    // Do not let the next deal erase the outcome before the
                    // player has read it and chosen to continue.
                    nextHandGate.awaitRequest(summary.handNumber)
                    if (heroNeedsRebuy) {
                        // This runs on the engine coroutine after the explicit
                        // "Reload & deal" request, never as a silent table mutation.
                        seats[HERO_SEAT].stack = config.buyIn
                    }
                }
                .onFailure { return }   // scope cancelled: the screen went away
        }
    }

    /**
     * Advances only the completed hand currently on screen.
     *
     * The numbered gate makes a double tap harmless, while the state check drops
     * a stale callback after a configuration change or new deal.
     */
    fun nextHand(completedHand: Int) {
        val current = _state.value
        val summary = current.visibleHandSummary() ?: return
        if (summary.handNumber != completedHand || current.nextHandRequested) return
        _state.update { it.copy(nextHandRequested = true) }
        nextHandGate.request(completedHand)
    }

    /**
     * Submits against the token the button was rendered from, so a stale or
     * doubled tap is dropped rather than applied to whatever comes next.
     */
    fun submit(token: Long, action: Action) {
        val decision = offer.value?.takeIf { it.token == token }
        viewModelScope.launch {
            val accepted = human.submit(token, action)
            val config = _state.value.gameConfig
            if (!accepted || decision == null || config?.coachEnabled != true) return@launch

            val review = withContext(Dispatchers.Default) {
                coach.review(decision, action)
            }
            _state.update { current ->
                if (current.acceptsCoachReview(review)) {
                    current.copy(coachReview = review)
                } else {
                    current
                }
            }
        }
    }

    fun fold(token: Long) = submit(token, Action(ActionType.FOLD))

    fun checkOrCall(token: Long) {
        val o = offer.value ?: return
        if (o.token != token) return
        // offer.callAmount is the one clamped value the label also renders.
        submit(token, if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.callAmount))
    }

    fun raiseTo(token: Long, amount: Int) = submit(token, Action(ActionType.RAISE, amount))

    override fun onCleared() {
        // No explicit human.cancel() here: viewModelScope is already cancelled by
        // this point, so a launch would never run. Cancelling the scope propagates
        // into HumanAgent.act()'s await, whose finally clears the pending state.
        frames.close()
        super.onCleared()
    }
}
