import Foundation
import PokerCore
import PokerCoach

/// The player's weaknesses, ranked by what they have actually cost.
///
/// Built from the written-down hands rather than from the coach's memory, for two reasons. The
/// coach's profile starts empty every time the app opens, so a scorecard read off it tells someone
/// with a year of hands behind them that they have no leaks. And the history can be pointed at one
/// sitting or at all of them, which is the question worth asking — "is the thing I have been working
/// on actually better this evening?" — and memory cannot answer it.
///
/// Four rules run through the whole of this type, and they are all the same rule: say only what the
/// hands support.
///
/// - A mistake made twice is not a habit. Nothing is called a leak until there have been enough
///   chances to make it, and until then the count is shown with a note saying exactly that.
/// - A cost that cannot be worked out from what was recorded is reported as unknown, never as
///   zero. Nought big blinds reads as "this is free", which is a different claim entirely.
/// - How often a habit happens is counted over every hand in view, arranged ones included. What it
///   costs is counted only over the hands that were dealt rather than arranged. The two answers
///   differ because the questions do: being put in a situation on purpose does not change how often
///   you get it right when you are in it, and it does change what the evening won or lost. An
///   assessment sits the player in the big blind and on a short stack far more often than a real
///   table does, so pricing a habit per hundred hands over those hands states a fact about ordinary
///   play that was measured on anything but. Where every hand in view was arranged, the money is
///   reported as not known — which is the same refusal as the one above.
/// - A player with nothing much wrong is told so. The coach does not manufacture a list to look
///   busy; that is the fastest way to stop being believed.
public struct LeakScorecard: Sendable, Equatable {

    /// How sure the scorecard is about one leak.
    public enum Standing: String, Sendable, Equatable {
        /// Enough chances, and it happens more often than it should. A habit.
        case costing
        /// Heading the wrong way, but on too few chances to say so yet.
        case watching
        /// Either it does not happen often enough to matter, or it has never been measured.
        case fine
    }

    /// Where a leak mostly happens, when it mostly happens somewhere.
    public struct SeatSpot: Sendable, Equatable {
        /// The seat as it was written down — "CO". Kept because that is what is on disk.
        public let position: String
        public let tableSize: Int
        public let times: Int

        private var seat: Position? { Position(rawValue: position) }
        /// "seat 8 of 9" — the number leads, as it does everywhere else.
        public var seatPhrase: String { seat?.seatPhrase(inTableOf: tableSize) ?? position }
        public var fullName: String { seat?.fullName ?? position }
        public var number: Int? { seat?.number(inTableOf: tableSize) }
        /// "seat 8 of 9 (cutoff)".
        public var described: String { seat?.described(inTableOf: tableSize) ?? position }
    }

    /// One leak, measured.
    public struct Entry: Sendable, Equatable, Identifiable {
        public let leak: Leak
        public var id: String { leak.id }
        /// How many times the player was in a spot where this mistake was available. Counted over
        /// every hand in view, arranged ones included.
        public let chances: Int
        /// How many of those went wrong, on the same count.
        public let times: Int
        /// How many of those happened in a hand that was arranged rather than dealt. They are in
        /// `times` and out of the money, and the row says so rather than leaving the two numbers to
        /// disagree in silence.
        public let arrangedTimes: Int
        /// How many of the dealt instances the coach was able to put a price on.
        public let pricedTimes: Int
        /// What it has cost, in big blinds, over the hands that were dealt rather than arranged —
        /// nil where nothing recorded lets that be worked out, or where there are no such hands.
        /// Never zero standing in for "not known".
        public let costInBB: Double?
        /// The same cost spread over the dealt hands in view, which is how a poker player reads any
        /// number like this. Nil for the same reasons.
        public let costPer100: Double?
        /// The hands the money above is spread over: the ones in view that were dealt rather than
        /// arranged.
        public let costHands: Int
        /// The hands in view that were arranged — an assessment. They are counted towards how often
        /// this happens and kept out of what it costs.
        public let arrangedHands: Int
        public let standing: Standing
        /// Where it mostly happens, if it mostly happens in one seat rather than everywhere.
        public let worstSeat: SeatSpot?
        /// Whether this one has been drilled, and what is owed on it. Nil where it never has been.
        public let drill: DrillBook.Standing?

        /// What the assessment named, where it named this one. Nil otherwise.
        ///
        /// The app used to carry two bars for the same question and let them contradict each other
        /// on one screen. The assessment names a habit when even the cautious end of what its
        /// chances can show is still worse than a steady player, which can happen off eight
        /// chances; this card names one when there have been as many chances as the catalogue says
        /// it takes. So a fresh install produced a report reading "One habit was already clear
        /// enough to be worth naming" beside a card reading "Nothing confirmed yet", and a row
        /// whose own note said nothing is called a habit until there have been twenty-five. Two
        /// cards, visible at once, and the wording of one denied the other.
        ///
        /// The report is right and this card was reading the wrong hands: a forty-hand assessment
        /// is evidence gathered on purpose, in the situation, and a measured interval that clears
        /// the bar is a stronger statement than a raw count. So it carries the row and prints its
        /// own numbers, exactly as a failed check does below.
        public let assessment: AssessmentReport.Named?

        /// The check that put this row back on the list, where the hands in view have nothing of
        /// their own to say about it.
        ///
        /// A reopened habit is forced onto the costing list below, and rightly: a check is evidence
        /// in the same situation, gathered on purpose. But the count beside it came from the hands
        /// in view, and drills are read all-time while the card follows the scope bar — so setting
        /// the scope to one sitting produced a row under the heading "what is costing you" reading
        /// "This has not come up yet" and "Only 0 chances so far", with an empty bar. The app
        /// telling somebody a habit is costing them money and, one line down, that it has never
        /// seen it. Where the hands in view cannot carry the row, the check's own numbers do, and
        /// the sentence says which they are.
        public var reopenedCheck: DrillBook.Standing? {
            guard let drill, drill.isReopened, drill.chances > 0, !enoughChances else { return nil }
            return drill
        }

        /// The assessment's finding, where it is the thing carrying this row.
        ///
        /// Same rule as the check above: only where the hands in view have not had enough chances
        /// to speak for themselves. Once they have, they are the better evidence and they win —
        /// four hundred hands of ordinary poker outrank forty arranged ones, in either direction.
        public var namedByAssessment: AssessmentReport.Named? {
            guard let assessment, assessment.certain, reopenedCheck == nil, !enoughChances else { return nil }
            return assessment
        }

        /// How often it goes wrong: out of the hands in view, or out of the evidence carrying the
        /// row where the hands in view cannot.
        ///
        /// The check's rate is the one the check was judged on — the one with the forgiven slip in
        /// it. Printing the raw rate here while the pass or fail turned on the smoothed one is the
        /// same split the drill report had removed from it: it put "2 of 12 went wrong — 17% of the
        /// time. A steady player is nearer 18%" under the heading "what is costing you", and drew
        /// the bar short of the mark on a row that had just failed. One number, or the screen
        /// argues with itself.
        public var rate: Double {
            if let check = reopenedCheck {
                return DrillRule.rate(chances: check.chances, mistakes: check.mistakes)
            }
            if let assessment = namedByAssessment { return assessment.rate }
            return chances > 0 ? Double(times) / Double(chances) : 0
        }
        public var percent: Int { Int((rate * 100).rounded()) }
        public var acceptablePercent: Int { Int((leak.acceptableRate * 100).rounded()) }
        /// Whether there have been enough chances for the rate to mean anything.
        public var enoughChances: Bool { chances >= leak.minimumSample }
        public var costIsKnown: Bool { costInBB != nil }

        /// "14 times out of 62 chances — 23% of the time."
        public var frequencyLine: String {
            if let check = reopenedCheck {
                return "The check put this back on your list: \(check.mistakes) of "
                    + "\(check.chances) went wrong. One slip is always added to that count before "
                    + "it is judged, which makes it \(percent)%, against the "
                    + "\(acceptablePercent)% this has to be under. The hands you are looking at "
                    + "here have not been in this situation often enough to add anything."
            }
            if let assessment = namedByAssessment {
                return "The assessment put this on your list: \(assessment.times) of "
                    + "\(assessment.chances) went wrong, and even the cautious end of what "
                    + "\(assessment.chances) chances can show is at least \(assessment.lowPercent)%, "
                    + "against the \(acceptablePercent)% this has to be under. The hands you are "
                    + "looking at here have not been in this situation often enough to add anything."
            }
            guard chances > 0 else { return "This has not come up yet." }
            return "\(times) \(times == 1 ? "time" : "times") out of \(chances) "
                + "\(chances == 1 ? "chance" : "chances") — \(percent)% of the time. "
                + "Anything over \(acceptablePercent)% is where this stops being ordinary and "
                + "starts being a habit."
        }

        /// What it has cost, or an admission that nobody knows.
        ///
        /// "About 8.0 for every 100 hands you play" is a claim about ordinary poker, so it is
        /// worked out over ordinary hands. On a fresh install the first thing anybody does is a
        /// forty-hand assessment, and that sentence used to be printed off nothing but arranged
        /// hands — the seat and the stacks chosen, the big blind three times as often as a real
        /// table deals it. The count above is fair on those hands and the money never was.
        public var costLine: String {
            guard let costInBB, let costPer100 else {
                if costHands == 0, arrangedHands > 0 {
                    return "Every hand you are looking at was arranged rather than dealt, so what "
                        + "this costs in ordinary play is something these hands cannot say. How "
                        + "often it happens, above, is the part they can measure."
                }
                return "What this has cost cannot be worked out from what was written down, so it "
                    + "is not being guessed at. The count above is the part that is measured."
            }
            let money = String(format: "It has cost you %.1f big blinds so far — about %.1f for "
                               + "every 100 hands you play.", costInBB, costPer100)
            guard arrangedHands > 0 else { return money }
            let dealt = costHands == 1
                ? "the one hand here that was dealt rather than arranged"
                : "the \(costHands) hands here that were dealt rather than arranged"
            let arranged = arrangedHands == 1
                ? "The one arranged hand counts towards how often this happens and stays out of "
                    + "the money."
                : "The \(arrangedHands) arranged ones count towards how often this happens and "
                    + "stay out of the money."
            return money + " That is counted over \(dealt). " + arranged
        }

        /// Where only some of the instances could be priced, said out loud.
        ///
        /// The money on a row is the sum of the ones that could be priced and nothing else. Beside
        /// "14 times out of 62 chances" a reader will divide the cost by 14, and where only four
        /// of the fourteen carried a price that is wrong by a factor of three. Erring low is not
        /// the same as being clear, so the row says which number the money is out of.
        public var pricedNote: String? {
            guard costInBB != nil, pricedTimes < times else { return nil }
            var line = "That cost is \(pricedTimes) of the \(times)."
            if arrangedTimes > 0 {
                line += " \(arrangedTimes) of them happened in arranged hands, which are counted "
                    + "here and kept out of the money."
            }
            let unpriced = times - pricedTimes - arrangedTimes
            if unpriced > 0 {
                line += " Nothing written down lets the other \(unpriced) be priced, so "
                    + "\(unpriced == 1 ? "it is" : "they are") counted and left uncosted rather "
                    + "than guessed at."
            }
            return line
        }

        /// The small-sample warning, or nothing at all where there is nothing to warn about.
        ///
        /// A row carried by a check does not get one. It would be measuring the wrong thing: the
        /// chances that put it back on the list are the check's twelve, not the nought this scope
        /// happens to hold, and "nothing is called a habit until there have been 25" is not what
        /// happened to this one.
        public var sampleNote: String? {
            guard reopenedCheck == nil, namedByAssessment == nil else { return nil }
            if times < 3 && times > 0 {
                return "Seen \(times) \(times == 1 ? "time" : "times"). Twice is an accident; it "
                    + "takes more than that before anyone should call it a habit."
            }
            guard !enoughChances else { return nil }
            return "Only \(chances) \(chances == 1 ? "chance" : "chances") so far. Nothing is "
                + "called a habit here until there have been \(leak.minimumSample), so treat this "
                + "as something to keep an eye on rather than a fact about your game."
        }

        /// "Mostly from seat 2 of 9 (the big blind) — 9 of the 14." Nil where it is spread about.
        public var seatLine: String? {
            guard let worstSeat else { return nil }
            return "Mostly from \(worstSeat.described) — \(worstSeat.times) of the \(times)."
        }
    }

    /// The moment the drill dates on this scorecard were worked out against.
    public let asOf: Date

    /// How many hands the scorecard was built from.
    public let hands: Int
    /// Of those, how many were dealt rather than arranged. This is the denominator under every
    /// money figure on the card; `hands` is the denominator under every rate.
    public let dealtHands: Int
    /// Of those, how many were arranged — an assessment. Counted towards the rates, kept out of
    /// the money.
    public let arrangedHands: Int
    /// Hands that were handed in and dropped on the way: drill and check hands, which this card
    /// never counts. Kept so the card can tell "nothing has been played here" apart from "what was
    /// played here was practice", which are different answers and used to share a sentence.
    public let drillHands: Int
    /// Every leak that has come up at least once, worst first.
    public let entries: [Entry]
    /// Every leak in the catalogue, in catalogue order, measured or not — the full list, for
    /// someone who wants to see what they are doing well too.
    public let allEntries: [Entry]

    /// The same counts in the shape the coach keeps them in, so a rating can be worked out from
    /// saved hands rather than only from the current sitting. The coach's own memory starts empty
    /// every launch; this does not.
    public let profile: LeakProfile

    /// What these hands say the player is worth, on the same 0-100 scale the tables are set to.
    /// Read it with `hands` in mind — under a couple of hundred it is pulled hard towards the
    /// middle on purpose, because that is all the hands support.
    public var rating: SkillRating { rating(for: LeakCatalogue.format) }

    /// The same number on a named scale — the ladder asks for the scale of the game the counted
    /// hands were played at, which is not always the game the player is looking at now.
    public func rating(for format: CoachFormat) -> SkillRating {
        SkillRating(costPer100: profile.costPer100Hands, hands: profile.handsPlayed,
                    leakPenalty: profile.leakPenalty, format: format)
    }

    /// Real habits, worst first.
    public var costing: [Entry] { entries.filter { $0.standing == .costing } }
    /// Heading the wrong way on too few chances to be sure.
    public var watching: [Entry] { entries.filter { $0.standing == .watching } }

    /// Poker takes a lot of hands before anything means much. Under this, the individual verdicts
    /// are worth more than any rate on this screen. Matches the bar the rest of the app uses.
    public var enoughHands: Bool { hands >= 200 }

    /// What the confirmed habits are costing together, per 100 hands of ordinary poker. Nil if none
    /// of them could be priced — including when every hand in view was arranged, which is the state
    /// a fresh install is in the moment the assessment ends.
    public var costPer100: Double? {
        let priced = costing.compactMap(\.costPer100)
        return priced.isEmpty ? nil : priced.reduce(0, +)
    }

    /// The one sentence at the top of the screen. Silence is a real answer here: a player with
    /// nothing much wrong gets told that plainly rather than handed a manufactured list.
    public var summary: String {
        if hands == 0 {
            // "No hands here yet" under a scope bar reading "45 hands" is the app calling its own
            // heading a liar. Every hand in a drill sitting is dropped on the way in — that is the
            // rule that stops anybody practising a habit off their own record — so a card built
            // from one is empty for a reason worth saying out loud.
            guard drillHands == 0 else {
                let played = drillHands == 1
                    ? "The one hand in what you are looking at was"
                    : "All \(drillHands) hands in what you are looking at were"
                return "\(played) practice at one situation. Drill "
                    + "hands are deliberately kept off this list: doing something forty times over "
                    + "while you watch for it says nothing about how you play when you are not. "
                    + "What counts is the check a few days later."
            }
            return "No hands here yet. Play a few and this fills in on its own."
        }
        if costing.isEmpty {
            let measured = entries.contains { $0.times > 0 }
            if !measured {
                return "Nothing has gone wrong often enough to measure in these \(hands) hands. "
                    + "Keep playing — this needs a few hundred before it can tell you much."
            }
            return "Nothing is costing you much yet. Mistakes have been made, but none of them "
                + "often enough in the same spot to be a habit worth working on."
        }
        let count = costing.count == 1 ? "One habit is" : "\(costing.count) habits are"
        guard let costPer100 else {
            guard dealtHands == 0, arrangedHands > 0 else {
                return "\(count) showing up often enough to be worth working on, worst first."
            }
            return "\(count) showing up often enough to be worth working on, worst first. Every "
                + "hand here was arranged rather than dealt, so what they are costing you at an "
                + "ordinary table is left blank instead of being worked out from hands that were "
                + "set up for you."
        }
        return String(format: "%@ costing you about %.1f big blinds every 100 hands, worst first.",
                      count, costPer100)
    }

    /// The caveat under the summary, where the sample is too small to be believed on its own.
    public var sampleNote: String? {
        guard hands > 0, !enoughHands else { return nil }
        return "This is \(hands) hands. Under a couple of hundred, treat everything below as a "
            + "first impression rather than a finding."
    }

    /// Habits that were drilled, cleared, and then came back when they were checked. They are on
    /// the list again, and the row says exactly that rather than starting the count from nothing.
    public var reopened: [Entry] { entries.filter { $0.drill?.isReopened == true } }

    public static let empty = LeakScorecard(of: [])

    // MARK: - Building it

    private struct Tally {
        var chances = 0
        var times = 0
        /// Instances that happened in an arranged hand. Part of `times`, and out of the money.
        var arrangedTimes = 0
        var pricedTimes = 0
        /// Every priced instance, arranged ones included. This one feeds the rating and nothing a
        /// player reads in big blinds — see `profile` at the bottom of the initialiser.
        var cost = 0.0
        /// The money the row prints: the priced instances that happened in hands which were dealt
        /// rather than arranged.
        var dealtCost = 0.0
        /// Which seat each instance happened in, so the row can say where to look.
        var seats: [String: [Int]] = [:]
    }

    /// Builds the scorecard from hands that were written down.
    ///
    /// Drill hands are dropped on the way in, and that is a judgement rather than an oversight. A
    /// drill deals the same situation forty times in a row to somebody who knows it is coming, so
    /// letting those decisions into the count would let anybody drill a habit off their own record
    /// without playing a single hand of poker differently. What a drill produces is reported by the
    /// drill, and the check that runs days later — when the player has stopped watching for it — is
    /// the part that is allowed to put a habit back on this list.
    /// - Parameter assessment: what the last finished assessment named, so this card and that
    ///   report cannot be read side by side saying opposite things. Nil where there has not been
    ///   one, and ignored on any row the hands in view can already speak for.
    public init(of allRecords: [HandRecord], drills: DrillBook = .empty,
                assessment: AssessmentReport? = nil) {
        let assessed = Dictionary(uniqueKeysWithValues: (assessment?.named ?? []).map { ($0.leak.id, $0) })
        asOf = drills.now
        let records = allRecords.filter { !$0.isDrill && !$0.isRetest }
        hands = records.count
        dealtHands = records.filter { !$0.isArranged }.count
        arrangedHands = records.count - dealtHands
        drillHands = allRecords.count - records.count

        var tallies: [String: Tally] = [:]
        for record in records {
            let dealt = !record.isArranged
            for verdict in record.verdicts {
                guard let spot = verdict.spotKind else { continue }
                // Every leak that lives in this spot had its chance to happen here. That is the
                // denominator, and it is why the coach's quiet verdicts are written down too: a
                // rate needs the decisions that went right as much as the ones that did not.
                for leak in LeakCatalogue.all where leak.spot == spot {
                    tallies[leak.id, default: Tally()].chances += 1
                }
                guard verdict.countsTowardsLeak,
                      let error = verdict.errorKind,
                      let leak = LeakCatalogue.matching(spot: spot, error: error) else { continue }
                var tally = tallies[leak.id, default: Tally()]
                tally.times += 1
                if !dealt { tally.arrangedTimes += 1 }
                if verdict.costInBB > 0 {
                    tally.cost += verdict.costInBB
                    // The money on the row is ordinary poker only. An arranged hand put the player
                    // in this spot on purpose, so what it cost them says more about the arrangement
                    // than about their game.
                    if dealt {
                        tally.pricedTimes += 1
                        tally.dealtCost += verdict.costInBB
                    }
                }
                if !record.position.isEmpty {
                    tally.seats[record.position, default: []].append(record.seatsAtTable)
                }
                tallies[leak.id] = tally
            }
        }

        let handCount = records.count
        let dealtCount = dealtHands
        // Only the habits the app is allowed to say somebody has get a row. A habit whose rate
        // cannot separate a player who does the thing every time from one who never does is not a
        // rate about the player — the sweep is in `LeakCatalogue` — so it is not put in front of
        // them under any heading, including the ones that say "not proved". What it costs is still
        // in `tallies` and still counts towards the rating below.
        allEntries = LeakCatalogue.nameable.map { leak in
            let tally = tallies[leak.id] ?? Tally()
            let rate = tally.chances > 0 ? Double(tally.times) / Double(tally.chances) : 0
            let tooOften = rate > leak.acceptableRate
            let drill = drills.standing(of: leak.id)
            var standing: Standing
            if tally.chances >= leak.minimumSample, tally.times >= 3, tooOften {
                standing = .costing
            } else if tally.times >= 2, tooOften {
                standing = .watching
            } else {
                standing = .fine
            }
            // A habit that was drilled, cleared, and then came back under a check is a habit
            // again, whatever the hands since happen to say. The check is evidence in the same
            // situation, and reopening something already measured well enough to be drilled needs
            // less of it than naming it from nothing did.
            if drill?.isReopened == true { standing = .costing }
            // And so does a habit the assessment named, for the same reason: it is evidence
            // gathered on purpose in the situation, and it cleared a bar this card has no way of
            // clearing off forty hands. Only where the hands in view have too few chances of their
            // own — past that they are the better evidence and they decide.
            let named = assessed[leak.id]
            if let named, named.certain, tally.chances < leak.minimumSample { standing = .costing }
            // `pricedTimes` only counts dealt hands, so a cost here always has dealt hands under
            // it and the per-100 never divides by nothing.
            let cost: Double? = tally.pricedTimes > 0 ? tally.dealtCost : nil
            return Entry(leak: leak,
                         chances: tally.chances,
                         times: tally.times,
                         arrangedTimes: tally.arrangedTimes,
                         pricedTimes: tally.pricedTimes,
                         costInBB: cost,
                         costPer100: cost.map { dealtCount > 0 ? $0 / Double(dealtCount) * 100 : 0 },
                         costHands: dealtCount,
                         arrangedHands: handCount - dealtCount,
                         standing: standing,
                         worstSeat: LeakScorecard.worstSeat(tally),
                         drill: drill,
                         assessment: named)
        }

        // A habit that has been drilled belongs on the screen even where the hands in view never
        // put the player in that situation — the drill is the thing that has something to say.
        entries = allEntries.filter { $0.chances > 0 || $0.drill != nil || $0.assessment != nil }
            .sorted(by: LeakScorecard.worseFirst)

        // The same counts, in the shape the coach keeps them, so the rating has one definition
        // rather than two. Only the priced instances go into the total: an unknown cost stays
        // unknown here too rather than being quietly counted as nought.
        // This total is every priced instance over every hand, arranged ones included, which is on
        // purpose and is the one place that differs from the money the rows print. The rating is a
        // 0-100 scale rather than a claim about what an evening costs, both halves of the division
        // are counted over the same hands, and the README argues that hands the player was put in
        // still say how well they played them. What the rows print is big blinds, labelled as what
        // ordinary play costs, so that one is worked out over ordinary hands.
        // Catalogue order, not dictionary order. Adding doubles is not associative, so summing
        // while walking a dictionary gives a total that changes in the last decimal place between
        // launches of the same build on the same hands — Swift shuffles that order every time the
        // program starts. It is a trivial amount of money and it feeds the rating the ladder is
        // hung on, which makes it exactly the class of thing this project does not allow: a number
        // that answers differently on each run cannot be the number a promotion turns on.
        var rebuilt: [String: LeakProfile.Record] = [:]
        var total = 0.0
        for leak in LeakCatalogue.all {
            guard let tally = tallies[leak.id] else { continue }
            rebuilt[leak.id] = LeakProfile.Record(opportunities: tally.chances, mistakes: tally.times,
                                                  costInBB: tally.cost)
            total += tally.cost
        }
        profile = LeakProfile(handsPlayed: handCount,
                              decisionsJudged: records.reduce(0) { $0 + $1.verdicts.count },
                              totalCostInBB: total,
                              records: rebuilt)
    }

    /// Worst first — but "worst" has to answer to the sample before it answers to the money.
    ///
    /// A confirmed habit always outranks one that is only being watched, however expensive the
    /// watched one looks: ranking a leak seen twice above one seen forty times is exactly how a
    /// screen like this starts lying. Inside a group the money decides, and a leak whose cost is
    /// unknown sits below the ones that could be priced rather than being given a nought and
    /// ranked as though it were harmless.
    private static func worseFirst(_ a: Entry, _ b: Entry) -> Bool {
        func rank(_ entry: Entry) -> Int {
            switch entry.standing {
            case .costing: return entry.costIsKnown ? 0 : 1
            case .watching: return 2
            case .fine: return 3
            }
        }
        let (left, right) = (rank(a), rank(b))
        if left != right { return left < right }
        if let x = a.costInBB, let y = b.costInBB, x != y { return x > y }
        if a.times != b.times { return a.times > b.times }
        return a.leak.id < b.leak.id
    }

    /// The seat a leak mostly happens in — reported only when it really is mostly one seat, and
    /// only off enough instances to be worth saying. Anything less is a pattern in noise.
    private static func worstSeat(_ tally: Tally) -> SeatSpot? {
        guard tally.times >= 3 else { return nil }
        guard let (position, sizes) = tally.seats.max(by: { a, b in
            a.value.count != b.value.count ? a.value.count < b.value.count : a.key > b.key
        }) else { return nil }
        // A clear majority — three in five — rather than merely the largest share. An even split
        // between two seats is not "mostly from" either of them.
        guard sizes.count * 5 >= tally.times * 3 else { return nil }
        // A tournament table shrinks as people bust, so the same seat name is a different number at
        // a different size. Report the size these instances mostly happened at; ties go to the
        // smaller table, as they do on the My game screen.
        var counts: [Int: Int] = [:]
        for size in sizes { counts[size, default: 0] += 1 }
        let commonest = counts.max { a, b in
            a.value != b.value ? a.value < b.value : a.key > b.key
        }?.key ?? 9
        return SeatSpot(position: position, tableSize: commonest, times: sizes.count)
    }
}
