import PokerCore
import PokerAI
import TestKit

func preflopTableTests(_ t: TestRun) {
    t.suite("preflop hand strength") { t in

        t.test("the table covers all 169 starting hands") {
            t.equal(HandClass.all.count, 169)
            t.equal(Set(HandClass.all).count, 169, "no duplicates")
            t.equal(HandClass.all.reduce(0) { $0 + $1.combos }, 1326, "every two-card deal is accounted for")
            for hand in HandClass.all {
                t.expect(PreflopTable.percentile[hand] != nil, "\(hand) is missing from the table")
            }
        }

        t.test("hand shorthand round-trips") {
            t.equal(HandClass("AKs")?.description, "AKs")
            t.equal(HandClass("77")?.description, "77")
            t.equal(HandClass("T9o")?.description, "T9o")
            t.equal(HandClass([Card].parse("Kd Ad")).description, "AKs")
            t.equal(HandClass([Card].parse("2c 2h")).description, "22")
            t.expect(HandClass("AAs") == nil, "a pair cannot be suited")
        }

        t.test("combinations match the counts") {
            t.equal(HandClass("AA")!.combinations().count, 6)
            t.equal(HandClass("AKs")!.combinations().count, 4)
            t.equal(HandClass("AKo")!.combinations().count, 12)
            for hand in HandClass.all {
                t.equal(hand.combinations().count, hand.combos, "\(hand)")
                for combo in hand.combinations() { t.equal(HandClass(combo), hand, "\(hand) round-trip") }
            }
        }

        t.test("equity matches known values") {
            t.close(PreflopTable.equity(HandClass("AA")!, opponents: 1), 0.85, 0.02)
            t.close(PreflopTable.equity(HandClass("72o")!, opponents: 1), 0.35, 0.03)
            t.close(PreflopTable.equity(HandClass("AA")!, opponents: 5), 0.49, 0.03, "aces against a full field")
            t.lessThan(PreflopTable.equity(HandClass("AA")!, opponents: 5),
                       PreflopTable.equity(HandClass("AA")!, opponents: 1))
        }

        t.test("the ordering looks like a real range chart") {
            let pct = { (s: String) in PreflopTable.percentile(HandClass(s)!) }
            t.lessThan(pct("AA"), 0.01, "aces are the very top")
            t.greaterThan(pct("32o"), 0.95, "three-deuce offsuit is right at the bottom")
            t.greaterThan(pct("72o"), 0.90, "seven-deuce is down there too")
            t.lessThan(pct("AKs"), pct("AKo"), "suited beats offsuit")
            t.lessThan(pct("QQ"), pct("AKo"), "queens beat ace-king")
            t.lessThan(pct("76s"), pct("K3o"), "a suited connector plays better than a weak king")
            t.lessThan(pct("JTs"), pct("J4o"), "connected beats a dangler")
            t.lessThan(pct("55"), pct("A9o"))
            for (a, b) in [("AA", "KK"), ("KK", "QQ"), ("TT", "88"), ("AKs", "AQs"), ("AQs", "AQo")] {
                t.lessThan(pct(a), pct(b), "\(a) should rank above \(b)")
            }
        }

        t.test("ranges are cut by combinations, not by hand names") {
            // The 13 pairs are 8% of the 169 names but only 6% of actual deals. Counting names
            // would quietly make every range in the app too tight.
            let tight = PreflopTable.range(top: 0.10)
            let combos = tight.reduce(0) { $0 + $1.combos }
            t.close(Double(combos) / 1326.0, 0.10, 0.02, "a 10% range really is a tenth of deals")
            t.expect(tight.contains(HandClass("AA")!))
            t.expect(!tight.contains(HandClass("72o")!))

            for fraction in [0.05, 0.15, 0.25, 0.40, 0.70] {
                let range = PreflopTable.range(top: fraction)
                let share = Double(range.reduce(0) { $0 + $1.combos }) / 1326.0
                t.close(share, fraction, 0.03, "a \(Int(fraction * 100))% range")
            }
        }

        t.test("wider ranges contain narrower ones") {
            let narrow = PreflopTable.range(top: 0.10)
            let wide = PreflopTable.range(top: 0.30)
            t.expect(narrow.isSubset(of: wide), "a 30% range must contain the whole 10% range")
        }
    }
}

func handReaderTests(_ t: TestRun) {
    t.suite("reading a hand") { t in

        func read(_ hole: String, _ board: String, opponents: Int = 1) -> HandAssessment {
            HandReader.assess(hole: [Card].parse(hole), board: [Card].parse(board), opponents: opponents)
        }

        t.test("made hands are named relative to the board") {
            t.equal(read("Ac Ad", "Kh 8s 3c").category, .overPair)
            t.equal(read("8c 8d", "Kh 9s 3c").category, .underPair)
            t.equal(read("Ac Kd", "Kh 8s 3c").category, .topPair)
            t.equal(read("Kc 4d", "Kh 8s 3c").category, .topPairWeakKicker)
            t.equal(read("8c 4d", "Kh 8s 3c").category, .middlePair)
            t.equal(read("3c 4d", "Kh 8s 3c").category, .bottomPair)
            t.equal(read("Kc 8d", "Kh 8s 3c").category, .twoPair)
            t.equal(read("3c 3d", "Kh 8s 3s").category, .set)
            t.equal(read("Kc 4d", "Kh Ks 3c").category, .trips)
            t.equal(read("Jc Td", "Kh Qs 3c 9d").category, .straight)
            t.equal(read("Ac 4c", "Kc 8c 3c").category, .flush)
            t.equal(read("Kc Kd", "Kh 8s 8c").category, .fullHouse)
            t.equal(read("Kc Kd", "Kh Ks 8c").category, .monster)
            t.equal(read("Ac 4d", "Kh 8s 3c").category, .aceHigh)
            t.equal(read("7c 4d", "Kh 8s 3c").category, .nothing)
        }

        t.test("draws are recognised") {
            t.equal(read("Ac 4c", "Kc 8c 3d").draw, .flushDraw)
            t.equal(read("Jc Td", "9h 8s 3c").draw, .openEnded)
            t.equal(read("Jc Td", "9h 7s 3c").draw, .gutshot)
            t.equal(read("Ac Kd", "9h 7s 3c").draw, .overcards)
            let combo = read("Jc Tc", "9c 8s 3c")
            t.equal(combo.draw, .combo, "a flush draw plus a straight draw")
            t.equal(combo.outs, 15, "nine clubs plus the queens and sevens that are not clubs")
        }

        t.test("out counts are right") {
            t.equal(read("Ac 4c", "Kc 8c 3d").outs, 9, "nine cards complete a flush")
            t.equal(read("Jc Td", "9h 8s 3c").outs, 8, "eight cards complete an open-ender")
            t.equal(read("Jc Td", "9h 7s 3c").outs, 4, "four cards complete a gutshot")
            t.equal(read("Ac Kd", "9h 7s 3c").outs, 6, "six cards pair an overcard")
        }

        t.test("cards that pair the board are not outs") {
            // The obvious shortcut — count anything that improves your hand category — counts
            // every card that pairs the board, which improves everybody's hand equally. A coach
            // that tells the player they had fifteen outs when they had nine is worse than silent.
            let flushDraw = read("Ac 4c", "Kc 8c 3d")
            t.equal(flushDraw.outs, 9, "not the kings, eights and threes that pair the board")
            // A straight the board would make on its own belongs to everyone, not to this hand.
            let boardStraight = read("Ac 2d", "9h 8s 7c")
            t.equal(boardStraight.draw, .none, "a six or ten makes the board's straight, not ours")
        }

        t.test("nothing is drawing on the river") {
            let river = read("Ac 4c", "Kc 8c 3d 2h 9s")
            t.equal(river.draw, .none)
            t.equal(river.outs, 0)
            t.equal(river.drawEquity, 0.0)
        }

        t.test("a dangerous board devalues a good hand") {
            let safe = read("Ac Kd", "Kh 8s 3c")               // top pair, rainbow rags
            let scary = read("Ac Kd", "Kh 9h 8h")              // top pair, flush out there
            t.lessThan(scary.showdownValue, safe.showdownValue, "top pair is worth less on a flush board")
            t.expect(scary.texture.flushPossible)
            t.expect(!safe.texture.flushPossible)
        }

        t.test("more opponents means less is good enough") {
            let heads = read("Ac Kd", "Kh 8s 3c", opponents: 1)
            let field = read("Ac Kd", "Kh 8s 3c", opponents: 5)
            t.lessThan(field.showdownValue, heads.showdownValue)
            t.greaterThan(heads.showdownValue, 0.5, "top pair is usually good heads-up")
            t.lessThan(field.showdownValue, 0.35, "top pair is rarely good against five")
        }

        t.test("stronger hands score higher, always") {
            let ladder = [
                read("7c 4d", "Kh 8s 3c"),      // nothing
                read("3c 4d", "Kh 8s 3c"),      // bottom pair
                read("8c 4d", "Kh 8s 3c"),      // middle pair
                read("Kc 4d", "Kh 8s 3c"),      // top pair, weak kicker
                read("Ac Kd", "Kh 8s 3c"),      // top pair
                read("Kc 8d", "Kh 8s 3c"),      // two pair
                read("3c 3d", "Kh 8s 3c"),      // a set
            ]
            for (a, b) in zip(ladder, ladder.dropFirst()) {
                t.lessThan(a.showdownValue, b.showdownValue, "\(a.summary) should be worth less than \(b.summary)")
            }
        }

        t.test("a draw adds value without pretending to be a made hand") {
            let draw = read("Ac 4c", "Kc 8c 3d")
            t.greaterThan(draw.total, draw.showdownValue, "the draw is worth something")
            t.greaterThan(draw.drawEquity, 0.25, "nine outs twice is a real chunk of equity")
            t.lessThan(draw.showdownValue, 0.30, "but ace high is not currently a hand")
        }
    }
}

func personalityTests(_ t: TestRun) {
    t.suite("personalities") { t in

        t.test("styles differ in the ways their names claim") {
            let nit = Personality.nit.parameters(skill: 30)
            let lag = Personality.looseAggressive.parameters(skill: 60)
            let station = Personality.callingStation.parameters(skill: 20)
            let maniac = Personality.maniac.parameters(skill: 25)

            t.lessThan(nit.openRange, lag.openRange, "a nit plays fewer hands than a loose aggressive player")
            t.greaterThan(station.callSlack, nit.callSlack, "a station calls with far less than it should")
            t.lessThan(station.aggression, lag.aggression, "a station does not raise")
            t.greaterThan(maniac.bluffFrequency, nit.bluffFrequency, "a maniac bluffs, a nit does not")
            t.greaterThan(station.limpTendency, lag.limpTendency, "passive players limp")
        }

        t.test("losing styles only exist at low skill") {
            t.lessThan(Personality.callingStation.skillRange.upperBound, 50)
            t.lessThan(Personality.loosePassive.skillRange.upperBound, 50)
            t.greaterThan(Personality.trickyRegular.skillRange.lowerBound, 50)
            t.greaterThan(Personality.solidRegular.skillRange.upperBound, 90)
        }

        t.test("skill closes the leaks without erasing the style") {
            for personality in Personality.allCases {
                let low = personality.parameters(skill: personality.skillRange.lowerBound)
                let high = personality.parameters(skill: personality.skillRange.upperBound)
                t.expect(high.sizingNoise <= low.sizingNoise + 0.0001, "\(personality.name) should size better with skill")
                t.expect(abs(high.callSlack) <= abs(low.callSlack) + 0.0001, "\(personality.name) should call closer to correct")
                t.expect(high.adaptation >= low.adaptation - 0.0001, "\(personality.name) should notice more with skill")
            }
            // A station at the top of its band still calls too much — it is a station.
            t.greaterThan(Personality.callingStation.parameters(skill: 35).callSlack, 0.1)
        }

        t.test("skill outside a style's band is clamped, not extrapolated") {
            let a = Personality.nit.parameters(skill: 200)
            let b = Personality.nit.parameters(skill: Personality.nit.skillRange.upperBound)
            t.equal(a.openRange, b.openRange)
        }

        t.test("position widens ranges the way it should") {
            let m = PositionAdjustment.openMultiplier
            t.lessThan(m(.underTheGun), m(.middle))
            t.lessThan(m(.middle), m(.cutoff))
            t.lessThan(m(.cutoff), m(.button))
            t.greaterThan(m(.button), 1.5, "the button plays far more hands than anyone")
        }
    }

    t.suite("table composition") { t in

        t.test("easy tables are full of recreational players, hard tables are not") {
            func mix(_ difficulty: Int) -> [Personality: Int] {
                var rng = SeededRandom(seed: 99)
                var counts: [Personality: Int] = [:]
                for _ in 0..<40 {
                    for seat in TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng) {
                        counts[seat.personality, default: 0] += 1
                    }
                }
                return counts
            }
            let easy = mix(15), hard = mix(85)
            let recreational: [Personality] = [.callingStation, .loosePassive, .maniac]
            let regulars: [Personality] = [.solidRegular, .trickyRegular, .looseAggressive]

            let easyRec = recreational.reduce(0) { $0 + (easy[$1] ?? 0) }
            let hardRec = recreational.reduce(0) { $0 + (hard[$1] ?? 0) }
            let easyReg = regulars.reduce(0) { $0 + (easy[$1] ?? 0) }
            let hardReg = regulars.reduce(0) { $0 + (hard[$1] ?? 0) }

            t.greaterThan(easyRec, hardRec, "an easy table has more recreational players")
            t.greaterThan(hardReg, easyReg, "a hard table has more regulars")
            t.equal(hardRec, 0, "calling stations do not sit at the top tables")
        }

        t.test("average skill rises with difficulty") {
            func averageSkill(_ difficulty: Int) -> Double {
                var rng = SeededRandom(seed: 7)
                var total = 0, count = 0
                for _ in 0..<40 {
                    for seat in TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng) {
                        total += seat.skill; count += 1
                    }
                }
                return Double(total) / Double(count)
            }
            var previous = -1.0
            for difficulty in [10, 30, 50, 70, 90] {
                let average = averageSkill(difficulty)
                t.greaterThan(average, previous, "difficulty \(difficulty) should be tougher than the level below")
                previous = average
            }
        }

        t.test("every seat gets a valid opponent") {
            var rng = SeededRandom(seed: 3)
            for difficulty in stride(from: 0, through: 100, by: 5) {
                let lineup = TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng)
                t.equal(lineup.count, 8, "difficulty \(difficulty)")
                for seat in lineup {
                    t.expect(seat.personality.skillRange.contains(seat.skill),
                             "\(seat.personality.name) at skill \(seat.skill) is outside its band")
                }
            }
        }
    }
}
