import PokerCore
import PokerAI
import TestKit

func botPlayTests(_ t: TestRun) {
    t.suite("bots at the table") { t in

        t.test("bots never produce an illegal action") {
            // The engine throws on anything illegal, so this plays a large number of varied hands
            // and asserts none of them wedged or threw.
            var rng = SeededRandom(seed: 606)
            var handsPlayed = 0
            var showdowns = 0

            for round in 0..<400 {
                let count = 2 + Int(rng.next() % 7)
                var lineupRng = SeededRandom(seed: rng.next())
                let lineup = TableComposition.lineup(difficulty: Int(rng.next() % 101), opponents: count, rng: &lineupRng)
                let bots = lineup.enumerated().map {
                    Bot(id: "S\($0.offset)", personality: $0.element.personality, skill: $0.element.skill, seed: rng.next())
                }
                let stacks = (0..<count).map { _ in 300 + Int(rng.next() % 20_000) }
                let starting = stacks.reduce(0, +)
                let seats = stacks.enumerated().map { SeatState(seat: $0.offset, playerID: "S\($0.offset)", stack: $0.element) }
                let level = BlindLevel(smallBlind: 50, bigBlind: 100,
                                       ante: round % 3 == 0 ? .bigBlindAnte(100) : .none)
                let config = HandConfig(level: level, buttonSeat: Int(rng.next() % UInt64(count)), seed: rng.next())
                let engine = HandEngine(seats: seats, config: config)
                let table = BotTable(bots: bots, seed: rng.next())
                table.play(engine)

                t.expect(engine.isComplete, "hand \(round) did not finish")
                t.equal(engine.seats.reduce(0) { $0 + $1.stack }, starting, "chips changed on hand \(round)")
                handsPlayed += 1
                if engine.result?.wentToShowdown == true { showdowns += 1 }
            }
            t.equal(handsPlayed, 400)
            t.greaterThan(showdowns, 40, "bots should reach showdown sometimes")
        }

        t.test("a bot cannot see anyone else's cards") {
            let seats = makeSeats([10_000, 10_000, 10_000])
            let engine = HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: 0, seed: 5))
            let seat = engine.actor!
            let view = engine.view(for: seat)!
            t.equal(view.holeCards.count, 2)
            for other in view.seats where other.seat != seat {
                t.expect(other.hole == nil, "seat \(other.seat)'s cards leaked into the view")
            }
            t.expect(view.seats.first { $0.seat == seat }?.hole != nil, "a player can see its own cards")
        }

        t.test("the view reports the table honestly") {
            let seats = makeSeats([10_000, 4_000, 10_000])
            let engine = HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: 0, seed: 5))
            let view = engine.currentView!
            t.equal(view.pot, 150)
            t.equal(view.effectiveStack, 10_000, "under the gun is covered by the big stacks behind")
            t.equal(view.opponentCount, 2)
            t.equal(view.playersDealtIn, 3)
            t.expect(view.isUnopenedPot)
            t.equal(view.preflopRaiseCount, 0)
            t.close(view.effectiveStackInBB, 100, 0.01)

            try! engine.apply(.raise(to: 300))
            let next = engine.currentView!
            t.equal(next.preflopRaiseCount, 1)
            t.equal(next.lastAggressor, 0)
            t.expect(!next.isUnopenedPot)
            t.equal(next.toCall, 250, "the small blind already has 50 in")
            t.close(next.potOdds, 250.0 / 700.0, 0.001)
        }

        t.test("styles show up in the statistics") {
            // Play each style at a full table and check its measured tendencies match its label.
            func measure(_ personality: Personality, skill: Int) -> PlayerStats {
                var bots = [Bot(id: "hero", personality: personality, skill: skill, seed: 11)]
                for i in 1..<6 {
                    bots.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 55, seed: UInt64(i) &* 313))
                }
                let table = BotTable(bots: bots, seed: 77)
                var rng = SeededRandom(seed: 424)
                for hand in 0..<400 {
                    let seats = bots.enumerated().map { SeatState(seat: $0.offset, playerID: $0.element.id, stack: 10_000) }
                    let config = HandConfig(level: noAnte, buttonSeat: hand % 6, seed: rng.next())
                    table.play(HandEngine(seats: seats, config: config))
                }
                return table.models["hero"]
            }

            let nit = measure(.nit, skill: 30)
            let station = measure(.callingStation, skill: 20)
            let lag = measure(.looseAggressive, skill: 60)

            t.lessThan(nit.vpip ?? 1, 0.25, "a nit should play well under a quarter of hands")
            t.greaterThan(station.vpip ?? 0, 0.45, "a station should play most hands")
            t.greaterThan(lag.vpip ?? 0, nit.vpip ?? 1, "a loose player plays more hands than a nit")
            t.greaterThan(lag.pfr ?? 0, station.pfr ?? 1, "an aggressive player raises more than a station")
            t.lessThan(station.foldToBet ?? 1, 0.45, "a station does not fold")
            t.greaterThan(nit.foldToBet ?? 0, station.foldToBet ?? 1, "a nit folds more than a station")
        }

        t.test("aggression is measurable on the players who have it") {
            // The maniac's aggression statistic once could not be computed at all, because the
            // sample gate counted only passive actions and a maniac barely makes any. The one
            // statistic that identifies the most aggressive player at the table was permanently
            // unavailable for exactly that player.
            var bots = [Bot(id: "maniac", personality: .maniac, skill: 15, seed: 3)]
            for i in 1..<6 { bots.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 60, seed: UInt64(i) &* 71)) }
            let table = BotTable(bots: bots, seed: 5)
            var rng = SeededRandom(seed: 9)
            for hand in 0..<300 {
                let seats = bots.enumerated().map { SeatState(seat: $0.offset, playerID: $0.element.id, stack: 10_000) }
                table.play(HandEngine(seats: seats, config: HandConfig(level: noAnte, buttonSeat: hand % 6, seed: rng.next())))
            }
            let maniac = table.models["maniac"]
            t.expect(maniac.aggressionFactor != nil, "a maniac's aggression must be measurable")
            t.greaterThan(maniac.aggressionFactor ?? 0, 1.3, "and it should be high")
            t.expect(maniac.isHyperAggressive, "a maniac should be recognised as one")
            t.expect(!table.models["V1"].isHyperAggressive, "a solid regular should not be")
        }
    }
}

func shootoutTests(_ t: TestRun) {
    t.suite("the opponents at a fixed-limit table") { t in
        // The cash app offers fixed-limit, where a bet is one size and the edges are smaller by
        // nature. Strategy tuning for it is Phase 3; this only asks that the bots play it without
        // wedging and that a good player still beats a bad table there. Measured 2026-09-11 with
        // `pokerlab shootout --structure fixedLimit --hands 6000`: solid regular (90) against a
        // table of calling stations +18 bb/100, against loose passives +13, against maniacs +17.
        t.test("a solid regular beats a table of calling stations at fixed-limit") {
            var total = 0.0
            for seed in [5, 6, 7] as [UInt64] {
                let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 31)
                let r = Shootout.ring(hero, versus: .callingStation, villainSkill: 15, seats: 6,
                                      hands: 600, seed: seed, structure: .fixedLimit)
                total += r.bigBlindsPer100
            }
            t.greaterThan(total / 3, 5, "three seeds of 600 hands average \(total / 3) bb/100")
        }
    }

    t.suite("does skill actually mean anything") { t in

        // If a higher skill number does not win money, "the table gets harder as you improve" is a
        // label on a number that changes nothing, and every phase built on top inherits that.
        //
        // These use duplicated matches: every hand is dealt to both sides, so the cards cancel out
        // and what is left is how the hands were played. Without that, the noise from the deck is
        // larger than any real edge for tens of thousands of hands.

        t.test("a competent player beats every recreational style at a full table") {
            // Six-handed, which is the format the app is being built for. The margins here are
            // large and hold at every seed — this is the claim the difficulty ladder rests on.
            let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 5)
            let cases: [(Personality, Int, Double)] = [
                (.callingStation, 15, 30.0),
                (.loosePassive, 15, 15.0),
                (.maniac, 15, 30.0),
                (.nit, 25, 0.0),
            ]
            for (style, skill, floor) in cases {
                let r = Shootout.ring(hero, versus: style, villainSkill: skill, seats: 6, hands: 300, seed: 57)
                t.greaterThan(r.bigBlindsPer100, floor,
                              "a solid regular should beat a table of \(style.name.lowercased()) (got \(String(format: "%.1f", r.bigBlindsPer100)) bb/100)")
            }
        }

        t.test("a better player beats a worse one heads-up") {
            let strong = Bot(id: "strong", personality: .solidRegular, skill: 95, seed: 1)
            let weak = Bot(id: "weak", personality: .callingStation, skill: 5, seed: 2)
            let result = Shootout.duplicate(strong, weak, hands: 800, seed: 31)
            t.greaterThan(result.bigBlindsPer100, 20.0,
                          "a strong regular should crush a calling station (got \(String(format: "%.1f", result.bigBlindsPer100)) bb/100)")
        }

        t.test("skill pays within a style") {
            // Measured across styles rather than one at a time. A single matchup at a testable
            // number of hands cannot resolve an edge of a few big blinds per hundred — the sum
            // across independent matchups can, and it is the honest way to state the claim:
            // being better at the same style wins money, by an amount that is small compared to
            // the difference between styles.
            var total = 0.0
            var wins = 0
            let styles: [Personality] = [.callingStation, .nit, .loosePassive, .tightAggressive, .solidRegular, .looseAggressive]
            for personality in styles {
                let band = personality.skillRange
                let expert = Bot(id: "hero", personality: personality, skill: band.upperBound, seed: 11)
                let r = Shootout.ring(expert, versus: personality, villainSkill: band.lowerBound,
                                      seats: 6, hands: 250, seed: 57)
                total += r.bigBlindsPer100
                if r.bigBlindsPer100 > 0 { wins += 1 }
            }
            t.greaterThan(total, 20.0, "skill should be worth real money across styles (got \(String(format: "%.1f", total)) bb/100 in total)")
            t.greaterThan(wins, 3, "and should win in most of them individually (\(wins) of \(styles.count))")
        }

        t.test("misreading your own hand is what costs the money") {
            // The backbone of the skill dial. Every other parameter describes a style; this one
            // describes being worse at poker. Two players identical but for how accurately they
            // judge their hand — the accurate one must win.
            //
            // Measured over several seeds, and enough hands to mean something. This was a single
            // 700-hand match, which turned out to swing between 1 and 26 bb/100 depending only on
            // the seed — it passed on luck. Duplication removes the luck in the cards; it does not
            // remove the luck in a few hundred decisions.
            //
            // The sample and the bars below were both measured rather than picked. A sweep of 24
            // seeds at 2,500 hands ran from -2.6 to +38.4 bb/100 — one seed in twenty-four had the
            // accurate reader losing — so the old bar of "every seed above nought" was not merely
            // loose, it was a coin toss away from failing on a build that had nothing wrong with
            // it. The same 24 seeds at 6,000 hands ran from 12.8 to 37.7 with a mean of 23.8, which
            // is the 20-odd bb/100 the README records. So: six seeds at 6,000 hands, a per-seed bar
            // at 5.0 — under half the worst of the 24 measured — and a mean bar at 15.0, which is
            // three and a half standard errors under the measured mean. A regression that took two
            // thirds off the skill dial lands well below both; ordinary seed luck does not.
            var edges: [Double] = []
            for seed in [8, 44, 512, 3, 77, 1234] as [UInt64] {
                var accurate = Bot(id: "accurate", personality: .solidRegular, skill: 70, seed: 21)
                accurate.parameters.readError = 0.0
                var blurry = Bot(id: "blurry", personality: .solidRegular, skill: 70, seed: 22)
                blurry.parameters.readError = 0.30
                edges.append(Shootout.duplicate(accurate, blurry, hands: 6_000, seed: seed).bigBlindsPer100)
            }
            let printed = edges.map { String(format: "%.1f", $0) }.joined(separator: ", ")
            let mean = edges.reduce(0, +) / Double(edges.count)

            for edge in edges {
                t.greaterThan(edge, 5.0,
                              "reading hands accurately must beat reading them badly (got \(printed) bb/100)")
            }
            t.greaterThan(mean, 15.0,
                          "and by an amount worth having (mean of \(printed) bb/100)")
            // Printed on the way past, so the number the whole skill dial rests on is visible
            // without anybody having to make the test fail to see it.
            print("       readError is worth " + String(format: "%.1f", mean)
                  + " bb/100 (six seeds: \(printed))")
        }

        t.test("the result is not an artefact of who sat where") {
            // Swapping the arguments must flip the sign, or the harness is measuring seats.
            let strong = Bot(id: "strong", personality: .solidRegular, skill: 90, seed: 5)
            let weak = Bot(id: "weak", personality: .loosePassive, skill: 10, seed: 6)
            let forward = Shootout.duplicate(strong, weak, hands: 500, seed: 88)
            let reversed = Shootout.duplicate(weak, strong, hands: 500, seed: 88)
            t.greaterThan(forward.bigBlindsPer100, 0)
            t.lessThan(reversed.bigBlindsPer100, 0)
        }

        t.test("duplicate matching cancels the cards") {
            // Two identical bots must measure at exactly zero. The seat's random seed is reset
            // each hand and follows the seat rather than the player, so the second pass is an
            // exact mirror of the first. If this ever drifts, every other number here is luck.
            let a = Bot(id: "a", personality: .solidRegular, skill: 70, seed: 41)
            let b = Bot(id: "b", personality: .solidRegular, skill: 70, seed: 41)
            let duplicated = Shootout.duplicate(a, b, hands: 400, seed: 13)
            t.equal(duplicated.netChips, 0, "identical bots must break exactly even, not approximately")
        }

        t.test("a soft table pays better than a tough one") {
            let hero = Bot(id: "hero", personality: .solidRegular, skill: 90, seed: 9)
            let easy = Shootout.ring(hero, versus: .callingStation, villainSkill: 10, seats: 6, hands: 250, seed: 12)
            let hard = Shootout.ring(hero, versus: .trickyRegular, villainSkill: 100, seats: 6, hands: 250, seed: 12)
            t.greaterThan(easy.bigBlindsPer100, hard.bigBlindsPer100 + 20,
                          "beating a soft table should pay far better than beating a tough one")
            t.greaterThan(easy.bigBlindsPer100, 0, "a soft table should be beatable")
        }

        t.test("the ladder has a top") {
            // The promotion mechanic depends on there being a difficulty at which a given level of
            // play stops winning. Otherwise the ladder never ends.
            let hero = Bot(id: "hero", personality: .tightAggressive, skill: 40, seed: 33)
            let soft = Shootout.ring(hero, versus: .loosePassive, villainSkill: 15, seats: 6, hands: 250, seed: 44)
            let tough = Shootout.ring(hero, versus: .trickyRegular, villainSkill: 100, seats: 6, hands: 250, seed: 44)
            t.greaterThan(soft.bigBlindsPer100, 10, "a mediocre player should beat a bad table")
            t.lessThan(tough.bigBlindsPer100, soft.bigBlindsPer100 - 15, "and do much worse against a good one")
        }

        t.test("results are reproducible") {
            let a = Bot(id: "a", personality: .tightAggressive, skill: 60, seed: 21)
            let b = Bot(id: "b", personality: .nit, skill: 25, seed: 22)
            t.equal(Shootout.duplicate(a, b, hands: 200, seed: 4242).netChips,
                    Shootout.duplicate(a, b, hands: 200, seed: 4242).netChips,
                    "the same seed must give the same match")
        }
    }
}
