import Foundation

public enum PlayerAction: Sendable, Equatable, CustomStringConvertible {
    case fold
    case check
    case call
    /// Opening wager, given as the total this seat will have in for the round.
    case bet(to: Int)
    /// Increase over an existing wager, given as the total this seat will have in for the round.
    case raise(to: Int)

    public var description: String {
        switch self {
        case .fold: return "fold"
        case .check: return "check"
        case .call: return "call"
        case .bet(let to): return "bet \(to)"
        case .raise(let to): return "raise to \(to)"
        }
    }
}

/// What the seat to act is allowed to do right now.
public struct LegalActions: Sendable, Equatable {
    public let seat: Int
    public let canFold: Bool
    public let canCheck: Bool
    public let canCall: Bool
    /// Chips that must be added to call. Zero when checking is free.
    public let callCost: Int
    /// True when calling would put the seat all in.
    public let callIsAllIn: Bool
    public let canBet: Bool          // no wager yet this round
    public let canRaise: Bool        // a wager exists and this seat may increase it
    public let minRaiseTo: Int
    /// The largest legal bet or raise, as the total this seat would have in for the round.
    ///
    /// No-limit: the whole stack. Pot-limit: the pot after the call, which is usually smaller
    /// than the stack. Fixed-limit: the one size a bet has, so it equals `minRaiseTo`.
    public let maxRaiseTo: Int
    /// Everything this seat has: what it has already put in this round plus its stack.
    public let allInTo: Int
    /// Fixed-limit only: the round has had its bet and three raises, so nobody may raise again.
    public let capped: Bool

    public var canBetOrRaise: Bool { canBet || canRaise }
    /// True when the most this seat may bet is everything it has.
    public var maxIsAllIn: Bool { maxRaiseTo >= allInTo }
    /// True when there is exactly one legal size — fixed-limit, or a stack too short to choose.
    public var sizeIsFixed: Bool { minRaiseTo == maxRaiseTo }

    public init(seat: Int, canFold: Bool, canCheck: Bool, canCall: Bool, callCost: Int,
                callIsAllIn: Bool, canBet: Bool, canRaise: Bool, minRaiseTo: Int, maxRaiseTo: Int,
                allInTo: Int? = nil, capped: Bool = false) {
        self.seat = seat
        self.canFold = canFold
        self.canCheck = canCheck
        self.canCall = canCall
        self.callCost = callCost
        self.callIsAllIn = callIsAllIn
        self.canBet = canBet
        self.canRaise = canRaise
        self.minRaiseTo = minRaiseTo
        self.maxRaiseTo = maxRaiseTo
        self.allInTo = allInTo ?? maxRaiseTo
        self.capped = capped
    }
}

public struct ActionRecord: Sendable, Equatable {
    public let seat: Int
    public let street: Street
    public let action: PlayerAction
    /// Chips this action moved from the stack to the pot.
    public let chipsAdded: Int
    public let potAfter: Int
    public let isForced: Bool        // blinds and antes
}

public struct HandResult: Sendable {
    public let pots: [Pot]
    public let awards: [PotAward]
    public let wentToShowdown: Bool
    /// Net chips won or lost for the hand, keyed by seat.
    public let net: [Int: Int]
    public let finalStacks: [Int: Int]
    public let board: [Card]
}

/// Runs one hand of hold'em — no-limit, pot-limit or fixed-limit, as `HandConfig.structure`
/// says: blinds, betting, side pots, showdown.
///
/// The engine is the single source of truth for what is legal. Bots, the coach and the UI all
/// read `legalActions()` and call `apply(_:)`; none of them re-implement the rules.
public final class HandEngine {

    public let config: HandConfig
    public private(set) var seats: [SeatState]
    public private(set) var street: Street = .preflop
    public private(set) var board: [Card] = []
    public private(set) var actions: [ActionRecord] = []
    public private(set) var result: HandResult?

    /// Highest total any seat has committed this round.
    public private(set) var currentBet = 0
    /// Size of the last full raise; the next legal raise must be at least this much more.
    public private(set) var minRaiseIncrement = 0
    /// Bet level set by the last *full* raise. An all-in that falls short of a full raise does not
    /// move this, which is what stops it from reopening the betting.
    private var lastFullRaiseTo = 0
    /// Fixed-limit: bets and full raises made this round, counting the big blind as the first.
    /// A bet and three raises is the cap, except heads-up.
    private var betsThisRound = 0
    private var deck: Deck
    private var actorSeat: Int?

    public var seatCount: Int { seats.count }
    public var buttonSeat: Int { config.buttonSeat }
    public var pot: Int { seats.reduce(0) { $0 + $1.committedThisHand + $1.antePaid } }
    /// Antes: in the pot, but nobody's wager.
    public var deadChips: Int { seats.reduce(0) { $0 + $1.antePaid } }
    public var isComplete: Bool { result != nil }
    /// The seat that must act, or nil if the hand is between streets or over.
    public var actor: Int? { actorSeat }

    public init(seats initialSeats: [SeatState], config: HandConfig) {
        self.config = config
        self.seats = initialSeats
        var rng = SeededRandom(seed: config.seed)
        self.deck = Deck(rng: &rng)
        begin()
    }

    /// Builds a hand with a known deck order, for tests and for replaying a recorded hand.
    public init(seats initialSeats: [SeatState], config: HandConfig, stackedDeck: [Card]) {
        self.config = config
        self.seats = initialSeats
        self.deck = Deck(stacked: stackedDeck)
        begin()
    }

    // MARK: - Setup

    private func begin() {
        // Seat numbers double as array indexes throughout the engine.
        precondition(seats.enumerated().allSatisfy { $0.offset == $0.element.seat }, "seats must be numbered 0..<n in order")
        for i in seats.indices where seats[i].stack <= 0 {
            seats[i].status = .sittingOut
        }
        precondition(livePlayers.count >= 2, "a hand needs at least two players with chips")

        postAntes()
        postBlinds()
        dealHoleCards()

        minRaiseIncrement = config.level.bigBlind
        lastFullRaiseTo = config.level.bigBlind
        // A big blind posted short — a 30-chip stack at 25/50 — does not lower the price for
        // everyone else. The bet level stays at the full big blind: the others call the whole
        // blind, the short seat plays for the part it could post, and the first raise is still
        // to two big blinds rather than to some odd number the short stack happened to set.
        let posted = seats.map(\.committedThisRound).max() ?? 0
        currentBet = bigBlindSeat != nil ? max(posted, config.level.bigBlind) : posted
        // Fixed-limit counts the blind as the round's first bet.
        betsThisRound = bigBlindSeat != nil ? 1 : 0

        actorSeat = firstToActPreflop()
        // Antes or blinds can put everyone all in before a card is turned.
        if actorSeat == nil || playersAbleToAct.count < 2 { finishBettingEarly() }
    }

    private var livePlayers: [SeatState] { seats.filter { $0.status != .sittingOut } }
    private var playersInHand: [SeatState] { seats.filter { $0.isInHand } }
    private var playersAbleToAct: [SeatState] { seats.filter { $0.canAct } }

    private func postAntes() {
        switch config.level.ante {
        case .none:
            return
        case .perPlayer(let amount):
            for seat in livePlayers { payAnte(seat: seat.seat, amount: amount) }
        case .bigBlindAnte(let amount):
            guard let bb = bigBlindSeat else { return }
            payAnte(seat: bb, amount: amount)
        }
    }

    /// An ante is not a bet. It never counts toward the round's betting level, is never matched,
    /// and cannot create a side pot — it is simply dead money in the middle.
    private func payAnte(seat: Int, amount: Int) {
        let paid = min(amount, seats[seat].stack)
        guard paid > 0 else { return }
        seats[seat].stack -= paid
        seats[seat].antePaid += paid
        if seats[seat].stack == 0 { seats[seat].status = .allIn }
        actions.append(ActionRecord(
            seat: seat, street: .preflop, action: .bet(to: paid),
            chipsAdded: paid, potAfter: pot, isForced: true
        ))
    }

    private func postBlinds() {
        if let sb = smallBlindSeat {
            commit(seat: sb, chips: min(config.level.smallBlind, seats[sb].stack), forced: true)
        }
        if let bb = bigBlindSeat {
            commit(seat: bb, chips: min(config.level.bigBlind, seats[bb].stack), forced: true)
        }
    }

    private func dealHoleCards() {
        // Two rounds of one card each, starting left of the button, the way a dealer does it.
        for _ in 0..<2 {
            for seat in orderedSeats(startingAfter: config.buttonSeat) where seats[seat].status != .sittingOut {
                seats[seat].hole.append(deck.deal())
            }
        }
    }

    // MARK: - Positions

    public var smallBlindSeat: Int? {
        let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
        guard !live.isEmpty else { return nil }
        // Heads-up, the button posts the small blind.
        return live.count == 2 ? config.buttonSeat : live[0]
    }

    public var bigBlindSeat: Int? {
        let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
        guard live.count >= 2 else { return nil }
        return live.count == 2 ? live.first { $0 != config.buttonSeat } : live[1]
    }

    /// Names every live seat by its position at the table.
    public func positions() -> [Int: Position] {
        let live = orderedSeats(startingAfter: config.buttonSeat).filter { seats[$0].status != .sittingOut }
        let names = Position.names(forPlayerCount: live.count)
        var map: [Int: Position] = [:]
        if live.count == 2 {
            // live[0] is left of the button, which heads-up is the big blind.
            map[live[0]] = .bigBlind
            map[config.buttonSeat] = .button
            return map
        }
        for (seat, name) in zip(live, names) { map[seat] = name }
        return map
    }

    /// Seat indexes in clockwise order beginning with the seat after `seat`.
    private func orderedSeats(startingAfter seat: Int) -> [Int] {
        (1...seats.count).map { (seat + $0) % seats.count }
    }

    private func firstToActPreflop() -> Int? {
        guard let bb = bigBlindSeat else { return nil }
        return orderedSeats(startingAfter: bb).first { seats[$0].canAct }
    }

    private func firstToActPostflop() -> Int? {
        orderedSeats(startingAfter: config.buttonSeat).first { seats[$0].canAct }
    }

    // MARK: - Legal actions

    public func legalActions() -> LegalActions? {
        guard let seat = actorSeat, !isComplete else { return nil }
        let s = seats[seat]
        let owed = currentBet - s.committedThisRound
        let callCost = min(owed, s.stack)
        let allInTo = s.committedThisRound + s.stack

        let maxRaiseTo: Int
        let minRaiseTo: Int
        var capped = false
        switch config.structure {
        case .noLimit:
            maxRaiseTo = allInTo
            // Minimum legal raise, capped by the stack so a short all-in is always offered.
            let rawMin = currentBet == 0 ? minRaiseIncrement : currentBet + minRaiseIncrement
            minRaiseTo = min(max(rawMin, currentBet + 1), maxRaiseTo)
        case .potLimit:
            // The most a player may raise BY is the pot as it would stand after their call —
            // everything in the middle, including antes and this round's bets, plus the call
            // itself. As a raise-to that is the current bet plus that pot: at 25/50 the first
            // player may make it 175, and if they do, the next may make it 600.
            maxRaiseTo = min(allInTo, currentBet + pot + owed)
            let rawMin = currentBet == 0 ? minRaiseIncrement : currentBet + minRaiseIncrement
            minRaiseTo = min(max(rawMin, currentBet + 1), maxRaiseTo)
        case .fixedLimit:
            // One size: the next full bet level. After a short all-in that did not make a full
            // raise the level is still the one above the last full bet; after one that did, it
            // is the next multiple of the bet above it.
            let unit = fixedBet
            let nextLevel = currentBet == 0
                ? unit
                : max(lastFullRaiseTo + unit, ((currentBet + unit - 1) / unit) * unit)
            maxRaiseTo = min(allInTo, nextLevel)
            minRaiseTo = maxRaiseTo
            // The cap lifts when only two players can still act — the usual house rule counts a
            // seat that is all in as out of the betting, not as a third player.
            capped = betsThisRound >= BettingStructure.fixedLimitCap && playersAbleToAct.count > 2
        }

        // An undersized all-in doesn't reopen the betting: a seat that already acted at the
        // current full-raise level may only call or fold.
        let reopened = s.lastActedAtBet == nil || s.lastActedAtBet! < lastFullRaiseTo
        let hasChipsToRaise = maxRaiseTo > currentBet
        let others = seats.filter { $0.seat != seat && $0.canAct }.count > 0

        let canBet = currentBet == 0 && hasChipsToRaise && others && !capped
        let canRaise = currentBet > 0 && hasChipsToRaise && reopened && others && !capped

        return LegalActions(
            seat: seat,
            canFold: true,
            canCheck: owed == 0,
            canCall: owed > 0,
            callCost: callCost,
            callIsAllIn: owed >= s.stack,
            canBet: canBet,
            canRaise: canRaise,
            minRaiseTo: minRaiseTo,
            maxRaiseTo: maxRaiseTo,
            allInTo: allInTo,
            capped: capped
        )
    }

    /// The size of a fixed-limit bet on the current street; the big blind elsewhere, where it
    /// only ever serves as the minimum increment.
    private var fixedBet: Int {
        config.structure.fixedBet(bigBlind: config.level.bigBlind, street: street) ?? config.level.bigBlind
    }

    // MARK: - Applying actions

    @discardableResult
    public func apply(_ action: PlayerAction) throws -> ActionRecord {
        guard !isComplete else { throw HandError.handComplete }
        guard let seat = actorSeat, let legal = legalActions() else { throw HandError.handComplete }

        var chipsAdded = 0

        switch action {
        case .fold:
            seats[seat].status = .folded

        case .check:
            guard legal.canCheck else { throw HandError.illegal("there is a bet to answer") }

        case .call:
            guard legal.canCall else { throw HandError.illegal("there is nothing to call") }
            chipsAdded = legal.callCost
            commit(seat: seat, chips: chipsAdded, forced: false)

        case .bet(let to):
            guard legal.canBet else { throw HandError.illegal("cannot open a bet here") }
            guard to >= legal.minRaiseTo || to == legal.maxRaiseTo else {
                throw HandError.illegal("bet must be at least \(legal.minRaiseTo)")
            }
            guard to <= legal.maxRaiseTo else { throw HandError.illegal(tooBig("bet", to, legal)) }
            chipsAdded = to - seats[seat].committedThisRound
            registerAggression(to: to)          // must read the old bet level, so this comes first
            commit(seat: seat, chips: chipsAdded, forced: false)

        case .raise(let to):
            guard legal.canRaise else {
                throw HandError.illegal(legal.capped ? "the betting is capped this round" : "cannot raise here")
            }
            guard to >= legal.minRaiseTo || to == legal.maxRaiseTo else {
                throw HandError.illegal("raise must be to at least \(legal.minRaiseTo)")
            }
            guard to <= legal.maxRaiseTo else { throw HandError.illegal(tooBig("raise", to, legal)) }
            chipsAdded = to - seats[seat].committedThisRound
            registerAggression(to: to)          // must read the old bet level, so this comes first
            commit(seat: seat, chips: chipsAdded, forced: false)
        }

        seats[seat].hasActedThisRound = true
        seats[seat].lastActedAtBet = currentBet

        let record = ActionRecord(
            seat: seat,
            street: street,
            action: action,
            chipsAdded: chipsAdded,
            potAfter: pot,
            isForced: false
        )
        actions.append(record)

        advance()
        return record
    }

    /// Why a bet or raise was refused, in the words of the structure that refused it.
    private func tooBig(_ what: String, _ to: Int, _ legal: LegalActions) -> String {
        if to > legal.allInTo { return "\(what) exceeds the stack" }
        switch config.structure {
        case .noLimit: return "\(what) exceeds the stack"
        case .potLimit: return "\(what) exceeds the pot limit"
        case .fixedLimit: return "\(what) must be exactly \(legal.maxRaiseTo) at fixed-limit"
        }
    }

    /// Records a bet or raise and works out whether it was a full raise.
    private func registerAggression(to: Int) {
        let increment = to - currentBet
        switch config.structure {
        case .fixedLimit:
            // The increment never grows: a full bet is a full bet. What counts towards the cap
            // is reaching the next level; a short all-in that falls between levels does not.
            if increment >= minRaiseIncrement || to >= lastFullRaiseTo + fixedBet {
                lastFullRaiseTo = to
                betsThisRound += 1
            }
        case .noLimit, .potLimit:
            if increment >= minRaiseIncrement {
                minRaiseIncrement = increment
                lastFullRaiseTo = to
            }
        }
        // A short all-in still sets the price; it just doesn't reopen raising.
        currentBet = max(currentBet, to)
    }

    private func commit(seat: Int, chips: Int, forced: Bool) {
        guard chips > 0 else {
            if seats[seat].stack == 0 && seats[seat].status == .active { seats[seat].status = .allIn }
            return
        }
        let amount = min(chips, seats[seat].stack)
        seats[seat].stack -= amount
        seats[seat].committedThisRound += amount
        seats[seat].committedThisHand += amount
        if seats[seat].stack == 0 { seats[seat].status = .allIn }
        if forced {
            actions.append(ActionRecord(
                seat: seat, street: street, action: .bet(to: seats[seat].committedThisRound),
                chipsAdded: amount, potAfter: pot, isForced: true
            ))
        }
    }

    // MARK: - Flow

    private func advance() {
        // Everyone else folded.
        if playersInHand.count <= 1 {
            actorSeat = nil
            finish(showdown: false)
            return
        }

        if let next = nextActor() {
            actorSeat = next
            return
        }

        // Betting round is over.
        actorSeat = nil
        if playersAbleToAct.count < 2 {
            finishBettingEarly()
        } else if street == .river {
            finish(showdown: true)
        } else {
            openNextStreet()
        }
    }

    /// The next seat owing an action this round, or nil when the round is settled.
    private func nextActor() -> Int? {
        let from = actorSeat ?? config.buttonSeat
        for seat in orderedSeats(startingAfter: from) {
            let s = seats[seat]
            guard s.canAct else { continue }
            if s.committedThisRound < currentBet { return seat }
            if !s.hasActedThisRound { return seat }        // the big blind's option, or a check-around
        }
        return nil
    }

    private func openNextStreet() {
        guard let next = Street(rawValue: street.rawValue + 1), next != .showdown else {
            finish(showdown: true)
            return
        }
        street = next
        deck.burn()
        board.append(contentsOf: deck.deal(next.cardsDealt))

        for i in seats.indices {
            seats[i].committedThisRound = 0
            seats[i].hasActedThisRound = false
            seats[i].lastActedAtBet = nil
        }
        currentBet = 0
        minRaiseIncrement = fixedBet
        lastFullRaiseTo = 0
        betsThisRound = 0

        if playersAbleToAct.count < 2 {
            finishBettingEarly()
            return
        }
        actorSeat = firstToActPostflop()
        if actorSeat == nil { finishBettingEarly() }
    }

    /// Fewer than two players can still bet, so deal the rest of the board and go to showdown.
    private func finishBettingEarly() {
        actorSeat = nil
        if playersInHand.count <= 1 {
            finish(showdown: false)
            return
        }
        if case .allInEquity(let trials) = config.settlement, board.count < 5 {
            settleByEquity(trials: trials)
            return
        }
        while board.count < 5 {
            guard let next = Street(rawValue: street.rawValue + 1), next != .showdown else { break }
            street = next
            deck.burn()
            board.append(contentsOf: deck.deal(next.cardsDealt))
        }
        finish(showdown: true)
    }

    // MARK: - Settling up

    /// Hands back the part of the last bet nobody called. Without this, a seat that bets 500 into
    /// a 100-chip stack would lose 400 chips it was never actually risking.
    ///
    /// This works on the current betting round only. Earlier rounds are always fully matched, and
    /// measuring across the whole hand would let dead money — an ante, or a folded player's
    /// chips — disguise part of the overbet.
    private func returnUncalledBet() {
        let byRound = seats.filter { $0.committedThisRound > 0 }.map(\.committedThisRound).sorted(by: >)
        guard let highest = byRound.first else { return }
        let secondHighest = byRound.count > 1 ? byRound[1] : 0
        guard highest > secondHighest else { return }
        guard let index = seats.firstIndex(where: { $0.committedThisRound == highest }) else { return }
        let refund = highest - secondHighest
        seats[index].committedThisRound -= refund
        seats[index].committedThisHand -= refund
        seats[index].stack += refund
        if seats[index].status == .allIn && seats[index].stack > 0 { seats[index].status = .active }
    }

    /// Pays out every pot by each contender's chance of winning it, leaving the board where it is.
    /// Chips stay integers: fractional shares are floored and the remainder handed out clockwise
    /// from the button, so nothing is created or destroyed.
    private func settleByEquity(trials: Int) {
        returnUncalledBet()
        let pots = PotBuilder.build(from: seats, dead: deadChips)
        var awards: [PotAward] = []
        var rng = SeededRandom(seed: config.seed &* 6_364_136_223_846_793_005 &+ 1)

        for (index, pot) in pots.enumerated() {
            let contenders = pot.eligible.filter { seats[$0].isInHand && seats[$0].hole.count == 2 }
            guard !contenders.isEmpty else { continue }
            if contenders.count == 1 {
                awards.append(PotAward(potIndex: index, seat: contenders[0], amount: pot.amount, hand: nil))
                continue
            }

            var shares: [Double] = []
            for seat in contenders {
                let others = contenders.filter { $0 != seat }.map { seats[$0].hole }
                let equity = EquityCalculator.equity(
                    hero: seats[seat].hole, opponents: others, board: board, trials: trials, rng: &rng
                ).equity
                shares.append(equity)
            }
            let total = shares.reduce(0, +)
            guard total > 0 else { continue }

            var paid = 0
            var amounts: [Int] = []
            for share in shares {
                let amount = Int(Double(pot.amount) * share / total)
                amounts.append(amount)
                paid += amount
            }
            // Hand the rounding remainder out clockwise from the button.
            var remainder = pot.amount - paid
            let order = contenders.indices.sorted {
                PotBuilder.seatsAfterButton(contenders[$0], button: config.buttonSeat, count: seats.count)
                    < PotBuilder.seatsAfterButton(contenders[$1], button: config.buttonSeat, count: seats.count)
            }
            for i in order where remainder > 0 {
                amounts[i] += 1
                remainder -= 1
            }
            for (i, seat) in contenders.enumerated() where amounts[i] > 0 {
                awards.append(PotAward(potIndex: index, seat: seat, amount: amounts[i], hand: nil))
            }
        }

        for award in awards { seats[award.seat].stack += award.amount }

        var net: [Int: Int] = [:]
        for seat in seats {
            let won = awards.filter { $0.seat == seat.seat }.reduce(0) { $0 + $1.amount }
            net[seat.seat] = won - seat.committedThisHand - seat.antePaid
        }

        result = HandResult(
            pots: pots, awards: awards, wentToShowdown: true, net: net,
            finalStacks: seats.reduce(into: [Int: Int]()) { $0[$1.seat] = $1.stack },
            board: board
        )
    }

    private func finish(showdown: Bool) {
        returnUncalledBet()

        let pots = PotBuilder.build(from: seats, dead: deadChips)
        let awards = PotBuilder.award(
            pots: pots,
            seats: seats,
            board: board,
            buttonSeat: config.buttonSeat,
            seatCount: seats.count
        )

        for award in awards {
            seats[award.seat].stack += award.amount
        }

        var net: [Int: Int] = [:]
        for seat in seats {
            // A refunded bet was never at risk, so this measures only what stayed in the pot.
            let won = awards.filter { $0.seat == seat.seat }.reduce(0) { $0 + $1.amount }
            net[seat.seat] = won - seat.committedThisHand - seat.antePaid
        }

        result = HandResult(
            pots: pots,
            awards: awards,
            wentToShowdown: showdown && playersInHand.count > 1,
            net: net,
            finalStacks: seats.reduce(into: [Int: Int]()) { $0[$1.seat] = $1.stack },
            board: board
        )
    }
}
