import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit

/// The seat mode: which seat the player loses most from, and the drill that sits them there.
///
/// Ken, 2026-09-11: "a mode that diagnoses which position they play the worst in and why and let
/// them drill while playing only that position." The rule the whole thing rests on is the same as
/// every other bar here: a flawless player's seats differ by chance, so a seat is named only past
/// the point where the two populations separate, and that point was measured rather than chosen.

private let epoch = Date(timeIntervalSince1970: 1_790_000_000)

/// A dealt hand from one seat with one decision the coach looked at, counted as a mistake or not.
private func seatHand(_ number: Int, seat: Position, mistake: Bool, cost: Double = 2.0,
                      spot: SpotKind = .openingChance, error: ErrorKind = .playedTooManyHands,
                      kind: String? = nil, drill: String? = nil, session: String = "s1") -> HandRecord {
    let verdict = VerdictRecord(street: "preflop", spot: spot.name,
                                leak: mistake ? error.name : "",
                                severity: mistake ? 3 : 0, costInBB: mistake ? cost : 0,
                                headline: "h", detail: "d", taken: "raise", best: "fold",
                                spoke: mistake, confidence: 0.9)
    return HandRecord(sessionID: session, sessionStarted: epoch,
                      playedAt: epoch.addingTimeInterval(Double(number) * 60),
                      handNumber: number, mode: "Tournament", bigBlind: 100,
                      position: seat.rawValue, holeCards: "Ah Kd", board: "",
                      netChips: mistake ? -200 : 50, wentToShowdown: false, sawFlop: false,
                      putMoneyInPreflop: true, raisedPreflop: mistake, verdicts: [verdict],
                      tableSize: 9, kind: kind, drill: drill)
}

/// `hands` hands from `seat`, `wrong` of them counted against the player.
private func seatRun(_ seat: Position, hands: Int, wrong: Int, from: Int = 1,
                     kind: String? = nil, drill: String? = nil) -> [HandRecord] {
    (0..<hands).map { i in seatHand(from + i, seat: seat, mistake: i < wrong, kind: kind, drill: drill) }
}

/// A full table of dealt hands where every seat runs at `rate`, except `badSeat` at `badRate`.
private func table(rate: Double, badSeat: Position? = nil, badRate: Double = 0,
                   handsPerSeat: Int) -> [HandRecord] {
    var out: [HandRecord] = []
    var number = 1
    for seat in Position.names(forPlayerCount: 9) {
        let r = seat == badSeat ? badRate : rate
        out += seatRun(seat, hands: handsPerSeat, wrong: Int((Double(handsPerSeat) * r).rounded()), from: number)
        number += handsPerSeat
    }
    return out
}

func seatModeTests(_ t: TestRun) {
    t.suite("which seat you lose most from") { t in

        t.test("a seat far worse than the rest is named, with its habit leading") {
            let hands = table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200)
            let diagnosis = SeatDiagnosis(of: hands)
            t.equal(diagnosis.worst?.position, "UTG+1", "expected seat 4 of 9 to be named")
            t.equal(diagnosis.worst?.leading.first?.leak.id, "too-many-hands")
            t.expect(diagnosis.headline?.contains("seat 4 of 9") == true, "\(diagnosis.headline ?? "nil")")
            t.expect(diagnosis.headline?.contains("playing too many hands") == true, "\(diagnosis.headline ?? "nil")")
            t.equal(diagnosis.reason, nil)
        }

        t.test("seats that differ by chance are not told apart") {
            // Nine seats all within a few points of each other: the control's band.
            var hands = table(rate: 0.10, handsPerSeat: 200)
            // Nudge one seat up by less than the margin.
            hands += seatRun(.cutoff, hands: 40, wrong: 12, from: 5_000)
            let diagnosis = SeatDiagnosis(of: hands)
            t.equal(diagnosis.worst, nil, "named \(diagnosis.worst?.position ?? "") on a gap inside the control's band")
            t.expect(diagnosis.reason?.contains("chance") == true, "\(diagnosis.reason ?? "nil")")
        }

        t.test("too few decisions at any seat is said as such, with the number") {
            let hands = table(rate: 0.10, badSeat: .button, badRate: 0.5, handsPerSeat: 30)
            let diagnosis = SeatDiagnosis(of: hands)
            t.equal(diagnosis.worst, nil, "named a seat off 30 hands")
            t.expect(diagnosis.reason?.contains("Not enough hands") == true, "\(diagnosis.reason ?? "nil")")
            t.expect(diagnosis.reason?.contains("more at") == true, "\(diagnosis.reason ?? "nil")")
        }

        t.test("arranged hands say nothing about a seat") {
            // A big blind drill would make the big blind look like the worst seat in the world.
            var hands = table(rate: 0.10, handsPerSeat: 200)
            hands += seatRun(.bigBlind, hands: 300, wrong: 200, from: 9_000, kind: HandRecord.drillKind, drill: "bb-overfold")
            hands += seatRun(.bigBlind, hands: 40, wrong: 30, from: 9_500, kind: HandRecord.assessmentKind)
            let diagnosis = SeatDiagnosis(of: hands)
            t.equal(diagnosis.worst, nil)
            t.equal(diagnosis.report(for: .bigBlind)?.hands, 200, "arranged hands were counted at the seat")
        }

        t.test("a seat is ranked by how often its decisions go wrong, not by one expensive verdict") {
            var hands = table(rate: 0.10, handsPerSeat: 200)
            // One monstrous verdict at the lojack: 300 big blinds in one decision.
            hands += [seatHand(20_000, seat: .lojack, mistake: true, cost: 300)]
            let diagnosis = SeatDiagnosis(of: hands)
            t.equal(diagnosis.worst, nil, "one priced verdict made a seat the worst")
            // And the cost the report shows is capped at what one decision may cost.
            let lojack = diagnosis.report(for: .lojack)!
            t.lessThan(lojack.costInBB, 200 * 0.1 * 2.0 + DecisionEvaluator.mostOneDecisionCosts + 0.01)
        }

        t.test("the bars are where the sweep put them") {
            // The comment on `minimumHands` is the record; this pins the numbers the record
            // supports so nobody moves one without re-sweeping.
            t.equal(SeatDiagnosis.minimumDecisions, 150)
            t.equal(SeatDiagnosis.minimumHands, 60)
            t.close(SeatDiagnosis.requiredMargin, 0.15, 1e-9)
            t.greaterThan(SeatDiagnosis.bigBlindAcceptableRate, SeatDiagnosis.acceptableRate)
        }

        t.test("at the app's own table a flawless player is never named and the planted seat is") {
            // The cheap version of `pokerlab seat-sweep`: two seeds, 2,250 hands. The full run —
            // 8 seeds × 6,300, both rungs — is recorded on `SeatDiagnosis.minimumHands`: the
            // control 0/60 reads, seat 4 named 6/8 at 1,350 hands and 8/8 from 2,250.
            for seed in [UInt64(11), 988] {
                let control = runPlantedTournament(.control, hands: 2_250, seed: seed)
                let clean = SeatDiagnosis(of: control.history.hands(in: .allTime))
                t.equal(clean.worst, nil, "seed \(seed): the control was told it loses most from \(clean.worst?.seatPhrase ?? "")")

                let planted = runPlantedTournament(.badInSeat(.underTheGunPlus1), hands: 2_250, seed: seed)
                let found = SeatDiagnosis(of: planted.history.hands(in: .allTime))
                t.equal(found.worst?.position, "UTG+1", "seed \(seed): the planted seat was not found (\(found.reason ?? "named \(found.worst?.position ?? "")"))")
                t.equal(found.worst?.leading.first?.leak.id, "too-many-hands", "seed \(seed)")
            }
        }
    }

    t.suite("drilling a seat") { t in

        t.test("every hand of a seat drill is dealt from that seat") {
            for seat in [Position.underTheGunPlus1, .lojack, .bigBlind] {
                let session = TableSession(seed: 31, history: .inMemory())
                session.startSeatDrill(seat)
                driveDrill(session, player: .control, seed: 5, stoppingAfter: 20)
                let played = session.history.hands(in: .allTime)
                t.greaterThan(played.count, 15, "\(seat): the drill did not deal")
                t.expect(played.allSatisfy { $0.position == seat.rawValue },
                         "\(seat): dealt from \(Set(played.map(\.position)))")
                t.expect(played.allSatisfy { $0.isDrill }, "drill hands were not written down as drill hands")
                t.expect(played.allSatisfy { $0.drill == "seat:" + seat.rawValue },
                         "the hands do not say which seat they drilled: \(Set(played.compactMap(\.drill)))")
            }
        }

        t.test("a seat drill counts every decision from the seat, and clears on the seat's bar") {
            let target = DrillTarget(seat: .cutoff, tableSize: 9)
            t.equal(target.id, "seat:CO")
            t.equal(target.name, "Seat 8 of 9")
            t.close(target.acceptableRate, SeatDiagnosis.acceptableRate, 1e-9)
            // 60 decisions, 5 wrong: (5+1)/(60+2) = 9.7%, under the 15% bar, at the floor.
            var run = DrillRun(target: target)
            for record in seatRun(.cutoff, hands: 60, wrong: 5, kind: HandRecord.drillKind, drill: target.id) {
                run.observe(record)
            }
            t.equal(run.chances, 60)
            t.equal(run.mistakes, 5)
            t.expect(run.cleared, "did not clear at the floor under the bar")
            // 59 decisions with the same record does not: the floor is the floor.
            var short = DrillRun(target: target)
            for record in seatRun(.cutoff, hands: 59, wrong: 5, kind: HandRecord.drillKind, drill: target.id) {
                short.observe(record)
            }
            t.expect(!short.cleared, "cleared under the floor")
            // 60 decisions, 12 wrong: (12+1)/62 = 21% — over the bar.
            var bad = DrillRun(target: target)
            for record in seatRun(.cutoff, hands: 60, wrong: 12, kind: HandRecord.drillKind, drill: target.id) {
                bad.observe(record)
            }
            t.expect(!bad.cleared, "cleared over the bar")
            t.equal(bad.acceptablePercent, 15)
        }

        t.test("the big blind is judged on its own, higher bar") {
            let target = DrillTarget(seat: .bigBlind, tableSize: 9)
            t.close(target.acceptableRate, SeatDiagnosis.bigBlindAcceptableRate, 1e-9)
        }

        t.test("a seat drill is written down, read back, scheduled and checked like any other") {
            let target = DrillTarget(seat: .hijack, tableSize: 9)
            let cleared = seatRun(.hijack, hands: 60, wrong: 4, kind: HandRecord.drillKind, drill: target.id)
            let book = DrillBook(of: cleared, now: epoch.addingTimeInterval(60 * 60))
            let standing = book.standing(ofSeat: .hijack)
            t.equal(standing?.stage, .holding, "a cleared seat drill is not held")
            t.equal(standing?.target.name, "Seat 7 of 9")
            t.expect(book.seatDrills.count == 1, "the seat drill is not listed among the seat drills")
            // Five days on, the check is due; a check that holds keeps it held.
            let later = DrillBook(of: cleared, now: epoch.addingTimeInterval(6 * 24 * 60 * 60))
            t.equal(later.standing(ofSeat: .hijack)?.stage, .due)
            let check = seatRun(.hijack, hands: 12, wrong: 0, from: 200, kind: HandRecord.retestKind, drill: target.id)
                .map { r in
                    var r = r; r.sessionID = "check"; r.sessionStarted = epoch.addingTimeInterval(6 * 24 * 60 * 60); return r
                }
            let checked = DrillBook(of: cleared + check, now: epoch.addingTimeInterval(7 * 24 * 60 * 60))
            t.equal(checked.standing(ofSeat: .hijack)?.stage, .holding, "a passed check did not hold the seat")
            t.equal(checked.standing(ofSeat: .hijack)?.checksPassed, 1)
        }

        t.test("an old-format drill hand and a habit drill still read back beside a seat drill") {
            let habit = LeakCatalogue.named("bb-overfold")!
            let mixed = drilled(habit, hands: 12, wrong: 1, session: "old")
                + seatRun(.button, hands: 12, wrong: 1, from: 100, kind: HandRecord.drillKind, drill: "seat:BTN")
            let book = DrillBook(of: mixed, now: epoch)
            t.expect(book.standing(of: habit.id) != nil, "the habit drill vanished")
            t.expect(book.standing(ofSeat: .button) != nil, "the seat drill vanished")
            // A seat name this build does not know is dropped, not filed against another seat.
            let unknown = seatRun(.button, hands: 12, wrong: 1, from: 300, kind: HandRecord.drillKind, drill: "seat:NOPE")
            t.equal(DrillBook(of: unknown, now: epoch).all.count, 0)
        }

        t.test("the drill's own words say it is a seat") {
            let run = DrillRun(target: DrillTarget(seat: .underTheGunPlus1, tableSize: 9))
            t.expect(run.purposeLine.contains("seat 4 of 9"), run.purposeLine)
            t.expect(!run.purposeLine.contains("habit"), run.purposeLine)
        }
    }

    t.suite("the seat mode on the screens") { t in

        t.test("a subscriber may drill any seat; a free player only the seat their habit leads at") {
            MainActor.assumeIsolated {
                let session = TableSession(seed: 44, history: .inMemory())
                session.pacedByCaller = true
                // A game where seat 4 is the bad seat and the free habit is the one it leads at.
                for record in table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200) {
                    session.history.append(record)
                }
                let state = AppState(session: session)
                state.dismissSheet()
                let tally = TrialTally(store: InMemoryTally(), key: "seat.test")
                state.charge(for: tally, through: .stub(subscribed: false, price: "$9.99"))

                t.equal(state.seatDiagnosis.worst?.position, "UTG+1")
                t.equal(state.freeView.visibleLeakID, "too-many-hands",
                        "the free habit should be the most common one, which every seat here has")
                t.expect(state.mayDrillSeat(.underTheGunPlus1), "the free player may not drill the seat their habit leads at")
                // Every seat here leads with the same habit, so every seat is open to this free
                // player — which is right: the free tier includes that one drill, wherever it is.
                state.startOrResumeSeatDrill(.underTheGunPlus1)
                t.equal(state.openSheet, "drill-brief-drill-seat:UTG+1", "tapping the seat did not open its brief")
                state.dismissSheet()

                // A seat whose leading habit is not the free one shows the price.
                for record in seatRun(.button, hands: 40, wrong: 20, from: 7_000).map { r -> HandRecord in
                    var r = r
                    r.verdicts = [VerdictRecord(street: "river", spot: SpotKind.facingRiverBet.name,
                                                leak: ErrorKind.calledTooWide.name, severity: 3, costInBB: 3,
                                                headline: "h", detail: "d", taken: "call", best: "fold",
                                                spoke: true, confidence: 0.9)]
                    return r
                } { session.history.append(record) }
                t.equal(state.seatDiagnosis.report(for: .button)?.leading.first?.leak.id, "river-hero-call")
                t.expect(!state.mayDrillSeat(.button), "a free player was let into a seat drill at a habit they are not shown")
                state.startOrResumeSeatDrill(.button)
                t.equal(state.openSheet, "paywall-true", "a locked seat drill did not show the price")
                state.dismissSheet()

                let paid = AppState(session: session)
                paid.dismissSheet()
                paid.charge(for: TrialTally(store: InMemoryTally(), key: "seat.test.paid"),
                            through: .stub(subscribed: true, price: "$9.99"))
                t.expect(paid.mayDrillSeat(.button), "a subscriber was refused a seat drill")
            }
        }

        t.test("every sentence the seat mode says is plain English") {
            let hands = table(rate: 0.10, badSeat: .underTheGunPlus1, badRate: 0.40, handsPerSeat: 200)
            let diagnosis = SeatDiagnosis(of: hands)
            var corpus: [String] = [diagnosis.headline ?? "", diagnosis.evidenceLine ?? ""]
            corpus += [SeatDiagnosis(of: []).reason ?? "", SeatDiagnosis(of: table(rate: 0.1, handsPerSeat: 200)).reason ?? ""]
            let run = DrillRun(target: DrillTarget(seat: .underTheGunPlus1, tableSize: 9))
            corpus += [run.purposeLine, run.targetLine, run.outcomeLine, run.outcomeTitle]
            corpus += [Copy.whereYouLoseMost, Copy.whereYouLoseMostSubtitle, Copy.seatDrillHint,
                       Copy.sameSeat, Copy.sameSeatBlurb(seat: "seat 4 of 9", lean: "with a mix of players"),
                       Copy.seatCounted, Copy.seatCountedBlurb, Copy.seatDrillsFooter,
                       Copy.seatBannerLine("Seat 4 of 9", watching: "playing too many hands")]
            for line in corpus where !line.isEmpty {
                for word in Glossary.jargonWatchlist {
                    t.expect(!line.lowercased().contains(" \(word) ") && !line.lowercased().hasPrefix("\(word) "),
                             "\"\(word)\" in: \(line)")
                }
            }
        }
    }
}
