import Foundation
import PokerCore
import PokerCoach

/// Which seat the player loses most from, and why.
///
/// 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. We do that for BB and the button, but I would
/// like to also have a mode where if the user plays badly in 4th position, we can identify that
/// and help them fix it."
///
/// Built from the dealt hands only — the ones where the seat was the deal's choice. An assessment
/// or a drill sits the player in one seat on purpose, so counting those would make every seat
/// look like the seat the app happened to arrange most. What is counted per seat is what the
/// coach priced there: how many decisions it counted against the player (the same bar as the
/// scorecard) and what those decisions cost, per hundred hands at the seat.
///
/// The refusal that makes this worth anything is the same one as everywhere else: a flawless
/// player's seats differ by chance, and naming the worst of nine noisy numbers is naming noise.
/// So "the worst seat" is only ever said when the seat has enough hands behind it AND it is worse
/// than the next seat by more than a flawless player's seats ever differ at that sample. Both
/// numbers come from a sweep — see `minimumHands` and `requiredMargin`.
public struct SeatDiagnosis: Sendable, Equatable {

    /// One seat, measured.
    public struct SeatReport: Sendable, Equatable, Identifiable {
        /// The seat as it was written down — "UTG+1". Kept because that is what is on disk.
        public let position: String
        /// The table these hands were mostly played at, which is what the number is said against.
        public let tableSize: Int
        public let hands: Int
        /// Decisions the coach looked at in this seat.
        public let decisions: Int
        /// Decisions it counted against the player — the scorecard's bar, not the table's.
        public let mistakes: Int
        /// What those decisions cost, in big blinds.
        public let costInBB: Double
        /// The habits behind it, commonest first, with how many times each was counted here.
        public let leading: [(leak: Leak, times: Int)]

        public var id: String { position }
        public var costPer100: Double { hands > 0 ? costInBB / Double(hands) * 100 : 0 }
        /// Counted mistakes per decision — the rate a seat drill is judged on.
        public var mistakeRate: Double { decisions > 0 ? Double(mistakes) / Double(decisions) : 0 }

        public var seat: Position? { Position(rawValue: position) }
        /// "seat 4 of 9".
        public var seatPhrase: String { seat?.seatPhrase(inTableOf: tableSize) ?? position }
        /// "seat 4 of 9 (under the gun + 1)".
        public var described: String { seat?.described(inTableOf: tableSize) ?? position }
        public var number: Int? { seat?.number(inTableOf: tableSize) }

        public static func == (a: SeatReport, b: SeatReport) -> Bool {
            a.position == b.position && a.tableSize == b.tableSize && a.hands == b.hands
                && a.decisions == b.decisions && a.mistakes == b.mistakes && a.costInBB == b.costInBB
                && a.leading.map(\.leak.id) == b.leading.map(\.leak.id)
                && a.leading.map(\.times) == b.leading.map(\.times)
        }
    }

    // MARK: - The bars

    /// The fewest hands a seat needs before it is compared with the others at all.
    ///
    /// The real sample is decisions, not hands (`minimumDecisions`): a seat the deal puts you in
    /// often but that you fold from every time has hands and nothing to judge, and the early
    /// seats of a tournament vanish as the table shrinks, so seat 4 of 9 collects a fifth of the
    /// hands the button does while producing more decisions per hand. This floor only keeps a
    /// seat off the comparison while it is a dozen hands old.
    ///
    /// Where the numbers come from — `pokerlab seat-sweep`, 2026-09-13, the app's own nine-handed
    /// tournament table:
    ///
    ///   control, 8 seeds × 6,300 hands, bottom rung: the flawless player's counted mistake rate
    ///   per seat runs 8–13% everywhere except the big blind, which runs 18–25% (defending the
    ///   blind is where a sound player's decisions are counted most). The gap between its worst
    ///   and second-worst seat is 2–5 points once every seat has 150 decisions, and the widest
    ///   it ever reaches on the way there is 14.0 points (the big blind at 287 decisions, one
    ///   seed). Top rung, 4 seeds: widest 9.2. Named: 0 of 60 seed×checkpoint reads.
    ///
    ///   a sound player who opens 85% of hands from seat 4 of 9 only (`PlantedPlayer.badInSeat`),
    ///   8 seeds × 6,300: that seat runs 38–44% against 8–13% elsewhere, a gap of 19–26 points.
    ///   Named 6/8 seeds by 1,350 hands (about 150 decisions at the seat), 8/8 from 2,250 on,
    ///   with "playing too many hands" leading every time.
    ///
    ///   the same flaw planted in the lojack (seat 6) is NOT separable this way: a tournament
    ///   table is nine-handed only briefly, so the lojack gets ~100 hands in 3,600, and opening
    ///   wide there is only mildly wrong (gap 8–12 points). Recorded rather than fixed — a
    ///   seat that barely exists at a shrinking table is not one the diagnosis can speak about.
    ///
    /// Cost per hundred was the first ranking and was dropped: one fold to a re-raise priced at
    /// fifty big blinds made a seat with 106 hands read 127 bb/100 against 10–40 elsewhere, and
    /// the control was named for it. The rate is what a habit is.
    public static let minimumHands = 60

    /// Decisions the coach looked at there before the seat's rate is compared: the control's big
    /// blind still swung 14 points at 287 decisions; the planted seat is found at about 150.
    public static let minimumDecisions = 150

    /// How far the worst seat's mistake rate has to be above the second-worst's before it is worth
    /// saying — a fraction of decisions, so 0.15 is fifteen points. Sits between the control's
    /// widest excursion (14.0) and the planted seat's narrowest gap (19). See `minimumHands`.
    public static let requiredMargin = 0.15

    /// Fewest decisions a seat drill can clear on. A drill sits the player in the seat every
    /// hand, so decisions come far faster than at a table; the floor is the point at which the
    /// control's seat rates have stopped swinging (see above), not a round number.
    public static let drillFloorDecisions = 60

    /// The counted mistake rate a seat drill has to be at or under to clear: the top of the
    /// control's band for every seat but the big blind (`bigBlindAcceptableRate`).
    public static let acceptableRate = 0.15
    public static let bigBlindAcceptableRate = 0.27
    public static func acceptableRate(for seat: Position) -> Double {
        seat == .bigBlind ? bigBlindAcceptableRate : acceptableRate
    }

    // MARK: - The reading

    /// Every seat played from, in the order they sit round the table.
    public let seats: [SeatReport]
    /// The seat the player loses most from — or nil while the hands cannot honestly say.
    public let worst: SeatReport?
    /// Why there is no worst seat yet, for the screen. Nil when there is one.
    public let reason: String?

    public init(of records: [HandRecord]) {
        // The deal's choice of seat only. Arranged hands say which seat the app chose.
        let dealt = records.filter { !$0.isArranged }
        let order = Position.allCases.map(\.rawValue)
        let catalogue = LeakCatalogue.nameable

        seats = order.compactMap { name in
            let group = dealt.filter { $0.position == name }
            guard !group.isEmpty else { return nil }
            let seat = Position(rawValue: name)
            var sizes: [Int: Int] = [:]
            for record in group { sizes[record.seatsAtTable, default: 0] += 1 }
            let commonest = sizes.max { a, b in
                a.value != b.value ? a.value < b.value : a.key > b.key
            }?.key ?? 9
            let size = seat?.number(inTableOf: commonest) != nil ? commonest : 9

            var decisions = 0, mistakes = 0
            var cost = 0.0
            var byLeak: [String: Int] = [:]
            for record in group {
                for verdict in record.verdicts {
                    decisions += 1
                    guard verdict.countsTowardsLeak else { continue }
                    mistakes += 1
                    // One decision the coach priced at a stack is a price, not a habit: capped
                    // here as the cash rating caps it, so a seat with few hands cannot be made
                    // the worst by a single verdict. The rate below is not touched by it.
                    cost += min(verdict.costInBB, DecisionEvaluator.mostOneDecisionCosts)
                    if let spot = verdict.spotKind, let error = verdict.errorKind,
                       let leak = LeakCatalogue.matching(spot: spot, error: error),
                       catalogue.contains(where: { $0.id == leak.id }) {
                        byLeak[leak.id, default: 0] += 1
                    }
                }
            }
            let leading = byLeak
                .compactMap { id, times in LeakCatalogue.named(id).map { ($0, times) } }
                .sorted { a, b in a.1 != b.1 ? a.1 > b.1 : a.0.id < b.0.id }
                .prefix(3)
                .map { (leak: $0.0, times: $0.1) }

            return SeatReport(position: name, tableSize: size, hands: group.count,
                              decisions: decisions, mistakes: mistakes, costInBB: cost,
                              leading: Array(leading))
        }

        let (worst, reason) = SeatDiagnosis.pickWorst(from: seats)
        self.worst = worst
        self.reason = reason
    }

    /// The seat where the most decisions go wrong, among those with enough hands, if it is far
    /// enough ahead of the next one to be more than chance.
    ///
    /// Ranked by the counted mistake rate rather than by cost, deliberately. Cost per hundred is
    /// what the seat is *said* to lose, but in a tournament a single fold to a re-raise can be
    /// priced at fifty big blinds, and in the control sweep one such verdict made a seat with 106
    /// hands read 127 bb/100 against 10–40 everywhere else — a flawless player named for a
    /// price tail. The rate is what a habit is: how often a decision there goes wrong.
    static func pickWorst(from seats: [SeatReport]) -> (SeatReport?, String?) {
        let eligible = seats.filter { $0.hands >= minimumHands && $0.decisions >= minimumDecisions }
        guard let top = eligible.max(by: { $0.mistakeRate < $1.mistakeRate }) else {
            let closest = seats.max { $0.hands < $1.hands }
            let more = closest.map { max(0, minimumHands - $0.hands) } ?? minimumHands
            return (nil, SeatDiagnosis.notEnoughLine(more: more, seat: closest))
        }
        // Nothing to compare against a single seat, and nothing to say about a seat where the
        // coach counted nothing.
        guard top.mistakes > 0 else {
            return (nil, "Nothing the coach counted against you from any seat with \(minimumHands) hands yet.")
        }
        let others = seats.filter {
            $0.position != top.position && $0.hands >= minimumHands / 2 && $0.decisions >= minimumDecisions / 2
        }
        let runnerUp = others.map(\.mistakeRate).max() ?? 0
        guard top.mistakeRate - runnerUp >= requiredMargin else {
            return (nil, "No one seat stands out yet: how often a decision goes wrong is within "
                + "\(Int((requiredMargin * 100).rounded())) points from seat to seat, which is how a "
                + "sound player's seats differ by chance. More hands will separate them if one "
                + "is really worse.")
        }
        return (top, nil)
    }

    static func notEnoughLine(more: Int, seat: SeatReport?) -> String {
        guard let seat, more > 0 else {
            return "Not enough hands in any one seat yet — it takes \(minimumHands) at a seat before "
                + "its number means anything."
        }
        return "Not enough hands in any one seat yet — \(more) more at \(seat.seatPhrase), your "
            + "most-played seat, before its number means anything."
    }

    // MARK: - Saying it

    /// "You lose most from seat 4 of 9 (under the gun + 1): about 31 big blinds for every 100
    /// hands there, mostly from playing too many hands and folding to a raise too much."
    public var headline: String? {
        guard let worst else { return nil }
        let per100 = Int(worst.costPer100.rounded())
        var line = "You lose most from \(worst.described): about \(per100) big blinds for every "
            + "100 hands there"
        let names = worst.leading.prefix(2).map { $0.leak.name.lowercasedFirst }
        switch names.count {
        case 0: line += "."
        case 1: line += ", mostly from \(names[0])."
        default: line += ", mostly from \(names[0]) and \(names[1])."
        }
        return line
    }

    /// The sentence under the headline: the numbers it rests on.
    public var evidenceLine: String? {
        guard let worst else { return nil }
        let hands = worst.hands == 1 ? "1 hand" : "\(worst.hands) hands"
        let nextRate = seats.filter { $0.position != worst.position }.map(\.mistakeRate).max() ?? 0
        return "\(hands) there, \(worst.mistakes) of \(worst.decisions) decisions counted "
            + "against you — \(Int((worst.mistakeRate * 100).rounded())) in every hundred, against "
            + "\(Int((nextRate * 100).rounded())) at your next-worst seat."
    }

    /// The report for a seat, by the name it was written down under.
    public func report(for position: Position) -> SeatReport? {
        seats.first { $0.position == position.rawValue }
    }
}

public extension String {
    /// "Folding the big blind too often" → "folding the big blind too often", for mid-sentence use.
    var lowercasedFirst: String {
        guard let first = first else { return self }
        return first.lowercased() + dropFirst()
    }
}
