import Foundation
import Observation
#if os(Android)
import SkipFuse
#endif
import SwiftUI
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerStore

public enum Screen: String, CaseIterable, Identifiable {
    case home = "Home"
    case table = "Table"
    case practice = "Drills"
    case leaks = "My game"
    case history = "Hands"
    case ladder = "Ladder"
    case settings = "Settings"

    public var id: String { rawValue }

    /// The tab bar splits a phone screen evenly between these, so the longer names get shorter
    /// ones there. Seven is the most that fit, and only because none of the short names runs past
    /// six characters — an eighth, or a longer name than "Ladder", starts truncating at the largest
    /// type size and belongs behind "More" instead.
    ///
    /// Drills earned a place on the bar rather than a link inside Settings because it is something
    /// somebody does most sittings, and the first version buried it two taps deep behind a habit
    /// the coach had to confirm first.
    public var shortName: String {
        switch self {
        case .leaks: return "Game"
        case .history: return "Hands"
        case .settings: return "More"
        default: return rawValue
        }
    }

    var icon: String {
        switch self {
        case .home: return "house"
        case .table: return "suit.spade"
        case .practice: return "target"
        case .leaks: return "chart.bar.doc.horizontal"
        case .history: return "clock.arrow.circlepath"
        case .ladder: return "chart.line.uptrend.xyaxis"
        case .settings: return "gearshape"
        }
    }
}

/// What is on screen.
///
/// The game itself lives in `TableSession`, which knows nothing about SwiftUI. This type only
/// decides which screen is showing and tells the views when something changed — deliberately, so
/// the iPhone version can put a different skin on the same session.
@Observable
@MainActor
public final class AppState {
    /// Which product this is. Chosen by the app shell; everything variant-specific reads it.
    public let variant: AppVariant

    public init(variant: AppVariant = .pokerTutor) {
        // First, before any key or file is read: the keys and the folder come off it.
        AppVariant.current = variant
        self.variant = variant
        // The screenshot script's two played scenes. A fixed seed, so the shots can be remade
        // identically; see `DemoScene`. Nothing here runs for a person.
        if let scene = DemoScene.requested {
            // An in-memory history, so the scene is the one the suite plays — a history on disk
            // would change the table's cast, and the cards after it, with every hand ever added.
            let staged = TableSession(seed: DemoScene.seed, history: .inMemory(), variant: variant)
            staged.pacedByCaller = true
            _session = staged
            arrangeDemo(scene)
            return
        }
        _session = AppState.ownSession(variant)
        // The coach's game follows the remembered structure from the first screen, not from the
        // first hand: a fixed-limit player reads the limit course before sitting down.
        _session.cashSetup = cashSetup
        // The script opens the app on a named screen and cannot tap the walk-through away.
        let scripted = ProcessInfo.processInfo.environment["POKERTUTOR_SCREEN"] != nil
        if !scripted, !UserDefaults.standard.bool(forKey: AppState.tutorialKey) { sheet = .tutorial }
    }

    /// The same app around a session somebody else made, with the noise turned off.
    ///
    /// This is what the suite drives. Everything the window does to a table — walking away from it,
    /// coming back to it, what a finished hand is allowed to open — lives in this class and used to
    /// be reachable only by playing with a mouse, which is how a table that kept dealing to an
    /// empty chair survived four rounds of review. There is no clock in here: `stepOpponent` is
    /// the dealer's own work with the waiting taken out.
    public init(session: TableSession) {
        self.variant = session.variant
        self._session = session
        session.cashSetup = cashSetup
        silent = true
        if !UserDefaults.standard.bool(forKey: AppState.tutorialKey) { sheet = .tutorial }
    }

    private static func ownSession(_ variant: AppVariant) -> TableSession {
        let session = TableSession(seed: AppState.freshSeed(), history: HistoryStore(), variant: variant)
        session.pacedByCaller = true          // the opponents are dealt out one at a time, below
        return session
    }

    /// A new deck for a new run of the app.
    ///
    /// Ken, 2026-08-24: "I dont like that it seems like the same hand is often shown when I start
    /// playing. Are the hands randomized in the tournement?" They were not. `TableSession` takes a
    /// seed with a fixed default, which is exactly right for the suite and the command line —
    /// every measurement in this project depends on the same seed dealing the same cards — and the
    /// app had simply never passed one of its own. So every launch dealt the same tournament: the
    /// same hole cards, the same board, and the same eight opponents in the same seats.
    ///
    /// Only the app is random. The default stays where it is, because a shootout that cannot be
    /// repeated measures nothing.
    public static func freshSeed() -> UInt64 { UInt64.random(in: 1...UInt64.max) }

    /// Whether to make a noise. A headless app has no speakers to answer for, and the setting a
    /// person chose in Settings is not something a test run should be allowed to change.
    private var silent = false

    // MARK: - Paying for it

    /// Apple's half of the subscription. Nil in the suite and on the command line, which have no
    /// App Store to talk to.
    ///
    /// `@Published` is not enough on its own — this is a class of its own, and SwiftUI needs to
    /// hear about its changes rather than about it being replaced. `observe(_:)` forwards them.
    public private(set) var subscription: SubscriptionStore?

    /// How many hands have been played against the free allowance. Nil when nobody is being charged
    /// for anything: the suite, and the command line.
    ///
    /// ⚠️ A nil tally means every hand is free. That is correct for a test and wrong for a phone,
    /// so the app shells set both of these at launch and a test asserts they do.
    public private(set) var tally: TrialTally?

    /// Turn on the free allowance and the App Store.
    ///
    /// Called by the app shells at launch and by nothing else. The suite and the command line
    /// deliberately never call it: they play tens of thousands of hands, have no App Store to talk
    /// to, and must not meet a paywall.
    public func startCharging() {
        guard tally == nil else { return }   // `.task` can run twice; a second store would not
        let store = SubscriptionStore()
        charge(for: TrialTally(), through: store)
        Task { await store.start() }
    }

    /// Hand the app the two halves of the subscription. Split out from `startCharging` so a test
    /// can pass a tally over an in-memory store.
    public func charge(for tally: TrialTally, through subscription: SubscriptionStore) {
        self.tally = tally
        self.subscription = subscription
        // The session counts the hands, because it owns the one place a hand is written down.
        // Setting it here rather than at the call site is what stops it being forgotten.
        session.tally = tally
        // A renewal, a refund or a purchase finishing on another device all land on the store
        // rather than here. Without this the paywall would stay up after somebody had paid.
        subscription.didChange = { [weak self] in self?.dismissPaywallIfPaid() }
    }

    /// How many hands have been counted against the allowance.
    public var handsPlayed: Int { tally?.handsPlayed ?? 0 }

    // MARK: - The cash table

    /// How the cash table is set up. Remembered between launches under this product's keys, so
    /// somebody who plays 50/100 fixed-limit finds 50/100 fixed-limit waiting next time.
    public var cashSetup: CashSetup = AppState.rememberedCashSetup() {
        didSet {
            AppState.remember(cashSetup)
            // The session's coach follows the choice at once (a fixed-limit player reads the
            // limit course before sitting down), not only when a sitting starts.
            session.cashSetup = cashSetup
        }
    }

    static func rememberedCashSetup(rung: LadderTier? = nil) -> CashSetup {
        var setup = CashSetup.standard
        // Until the player picks, the stakes are the rung the ladder has them at.
        if let stakes = rung?.stakes { setup.stakes = stakes }
        let defaults = UserDefaults.standard
        let bb = defaults.integer(forKey: AppVariant.current.key("cash.bigBlind"))
        if let stakes = CashSetup.stakesOnOffer.first(where: { $0.bigBlind == bb }) { setup.stakes = stakes }
        if let raw = defaults.string(forKey: AppVariant.current.key("cash.structure")),
           let structure = BettingStructure(rawValue: raw) { setup.structure = structure }
        return setup
    }

    static func remember(_ setup: CashSetup) {
        let defaults = UserDefaults.standard
        defaults.set(setup.stakes.bigBlind, forKey: AppVariant.current.key("cash.bigBlind"))
        defaults.set(setup.structure.rawValue, forKey: AppVariant.current.key("cash.structure"))
    }

    /// Sits down at the cash table as it is set up.
    public func startCash() {
        session.cashSetup = cashSetup
        start(.cash)
    }

    /// What the last cash sitting came to, once the player has cashed out.
    public var lastCashResult: SessionResult? { session.lastCashResult }
    /// How betting is limited at the table on screen.
    public var bettingStructure: BettingStructure { session.bettingStructure }
    /// How often the player has bought back in this sitting.
    public var heroRebuys: Int { session.rebuys[session.heroID] ?? 0 }

    /// Whether Apple says this is a paying customer.
    public var isSubscribed: Bool { subscription?.isSubscribed ?? false }

    /// What the player is entitled to right now.
    ///
    /// With no tally there is nobody to charge, so everything is open. That is the suite and the
    /// command line, both of which play tens of thousands of hands and must not hit a paywall.
    public var entitlement: Entitlement {
        guard let tally else { return .subscribed }
        return tally.entitlement(subscribed: subscription?.isSubscribed ?? false)
    }

    /// Whether a hand may be dealt.
    ///
    /// While Apple has not answered yet, the answer is yes. Before the first answer "not
    /// subscribed" means "not known", and stopping a paying customer at a paywall on a slow
    /// connection is worse than letting an unpaid hand through.
    public var mayPlay: Bool {
        if let subscription, !subscription.hasCheckedWithApple { return true }
        return entitlement.mayPlay
    }

    /// Whether to mention the allowance on Home. Never while a hand can still be dealt for free
    /// without the end being in sight.
    public var shouldWarnAboutAllowance: Bool {
        guard tally != nil else { return false }
        // A hundred free hands is short enough that the count is worth seeing throughout; it is
        // also where a free player finds the price without going looking.
        if case .trial = entitlement { return true }
        return false
    }

    /// How many free hands are left, for the notice on Home.
    public var handsLeft: Int {
        if case .trial(let left) = entitlement { return left }
        return 0
    }

    /// Show the price because somebody asked to see it.
    public func showPaywall() { sheet = .paywall(dismissable: true) }

    /// Take the paywall down the moment Apple says they have paid — including when the payment
    /// went through somewhere else entirely, or an Ask to Buy was approved hours later.
    private func dismissPaywallIfPaid() {
        guard case .paywall = sheet, entitlement.mayPlay else { return }
        sheet = nil
    }

    /// Everything that can open over the top of a screen.
    ///
    /// One enum and one presenter, deliberately. A view has a single sheet: raising a second flag
    /// while lowering the first — which is what handing the review over to the drill report used to
    /// do — is the standard way to get the second sheet silently never appearing. Here the handover
    /// is one assignment, so there is nothing to race.
    enum Sheet: Identifiable {
        case tutorial
        case review
        case assessmentBrief
        case assessmentReport
        case drillBrief(DrillTarget, retest: Bool)
        case drillReport(DrillRun)
        /// The price. `dismissable` is false when the free hands are gone — there is no table
        /// behind it to go back to — and true when somebody opened it themselves.
        case paywall(dismissable: Bool)

        var id: String {
            switch self {
            case .paywall(let dismissable): return "paywall-\(dismissable)"
            case .tutorial: return "tutorial"
            case .review: return "review"
            case .assessmentBrief: return "assessment-brief"
            case .assessmentReport: return "assessment-report"
            case .drillBrief(let target, let retest):
                return "drill-brief-\(retest ? "check" : "drill")-\(target.id)"
            case .drillReport(let run): return "drill-report-\(run.target.id)"
            }
        }
    }

    /// Which screen is showing.
    ///
    /// Assigning this is what stops and starts the table, and that is the whole of the fix for a
    /// game that used to carry on without anybody watching it. The sidebar and the tab bar only set
    /// this, so a player who called a bet and then went to look at their stats left eight opponents
    /// still acting on a timer: the hand finished off screen, the review opened over My game, and
    /// the next hand was dealt at a table nobody was at.
    ///
    /// Going anywhere else parks the table exactly where it stands. Coming back to it picks the
    /// same hand up. Nothing is thrown away — a tournament somebody is forty hands into is not
    /// something to discard because they glanced at the Hands list — and nothing is dealt to an
    /// empty chair. Leaving properly is still leaving: the Leave button calls `leaveTable`, which
    /// puts the sitting down.
    /// Where you were when you last closed the app.
    ///
    /// The table is deliberately never restored: opening to a felt you are not sitting at would be
    /// a lie about the state of a tournament. Everything else is worth coming back to — somebody
    /// working through the drills should not be returned to the front door every launch.
    public var screen: Screen = AppState.rememberedScreen {
        didSet {
            guard screen != oldValue else { return }
            if screen == .table { resumeTable() } else { pauseTable() }
            AppState.remember(screen)
        }
    }

    static var screenKey: String { AppVariant.current.key("screen") }

    static var rememberedScreen: Screen {
        // An override for the screenshot script, which has to open the app on a given screen and
        // cannot tap. It goes through the environment rather than through the saved setting because
        // the simulator caches an app's preferences and quietly discards a write made from outside
        // — which looked exactly like this code being broken, for an afternoon.
        if let named = ProcessInfo.processInfo.environment["POKERTUTOR_SCREEN"],
           let screen = Screen(rawValue: named) { return screen }
        guard let raw = UserDefaults.standard.string(forKey: screenKey),
              let screen = Screen(rawValue: raw), screen != .table else { return .home }
        return screen
    }

    static func remember(_ screen: Screen) {
        guard screen != .table else { return }
        UserDefaults.standard.set(screen.rawValue, forKey: screenKey)
    }
    /// What is open over the screen, if anything.
    var sheet: Sheet?
    /// The same thing as a name, for anybody outside this module — which means the suite. The sheet
    /// itself stays internal: what a test needs to know is which one is up, and "none" is the
    /// answer most of these tests are about.
    public var openSheet: String? { sheet?.id }
    /// Bumped when the colours change, to redraw a view tree that reads them statically.
    /// A card being held up to be read. Nil almost always.
    public private(set) var magnified: Card?

    /// Touch a card to see it properly; touch again to put it back.
    public func magnify(_ card: Card) {
        withAnimation(.easeOut(duration: 0.14)) {
            magnified = magnified == card ? nil : card
        }
    }

    public func putCardBack() {
        withAnimation(.easeOut(duration: 0.14)) { magnified = nil }
    }

    private(set) var palette = Theme.palette
    /// The same trick for the size of the type, which is read statically for the same reason.
    private(set) var textScale = Theme.textScale

    private(set) var cardColours = Theme.cardColours

    private static var tutorialKey: String { AppVariant.current.key("tutorialSeen") }

    /// The session is a plain class that SwiftUI cannot watch. Every screen reaches it through
    /// this property, which reads `generation` on the way — so bumping that counter (`redraw()`)
    /// redraws every screen that read anything from the session. That is the same "something
    /// moved" signal `ObservableObject` used to give for free, kept on purpose: the alternative
    /// is teaching the session about SwiftUI, and it is the one part of the app that must not know.
    public var session: TableSession { _ = generation; return _session }
    private let _session: TableSession
    private var generation = 0

    /// Tell the screens the session moved.
    private func redraw() { generation &+= 1 }

    /// Which hands the Hands and My game screens are looking at.
    var scope: HistoryFilter = .allTime

    var history: HistoryStore { session.history }

    /// The hands in scope, newest last. Recomputed rather than cached: it is one pass over a few
    /// thousand rows at the very most, and a stale analysis would be worse than a slow one.
    var scopedHands: [HandRecord] {
        switch scope {
        case .thisSession: return history.hands(in: .session(session.sessionID))
        case .allTime: return history.hands(in: .allTime)
        case .past(let id): return history.hands(in: .session(id))
        }
    }

    var analysis: GameAnalysis { GameAnalysis(of: scopedHands) }
    /// The leaks, ranked by what they have cost. Built from the hands in view rather than from the
    /// coach's memory: memory starts empty every time the app opens, and cannot be pointed at one
    /// sitting.
    ///
    /// The drills are always read from every hand ever played, whatever the scope bar says. Where a
    /// habit was drilled is a fact about the player rather than about the sitting being looked at,
    /// and a re-test that vanished because the scope was set to this evening would be a re-test
    /// nobody ever did.
    ///
    /// The last finished assessment goes in for the same reason the drills do, and it is the fix
    /// for the app having told somebody two opposite things at once: the report card on Home said
    /// one habit was clear enough to name while this card, one button away, said nothing was
    /// confirmed and that nothing is called a habit until there have been twenty-five chances.
    var scorecard: LeakScorecard {
        LeakScorecard(of: scopedHands, drills: drills, assessment: finishedAssessment)
    }
    /// The same card over everything ever played, whatever the scope bar on My game is set to.
    ///
    /// Home has no scope bar and is not a scoped screen, so anything it shows has to be a fact
    /// about the player rather than about whichever sitting somebody was last looking at. Without
    /// this, setting the scope to this evening on My game made the drill card disappear from Home
    /// on the next launch, with nothing on Home to explain why.
    public var allTimeScorecard: LeakScorecard {
        LeakScorecard(of: history.hands(in: .allTime), drills: drills, assessment: finishedAssessment)
    }

    /// What a player who has not paid is shown: one habit in full, the rest blurred, one drill.
    ///
    /// Read over everything ever played, never over the scope bar on My game: the one habit a free
    /// player is shown is a fact about their game, and it must not change because they looked at
    /// one evening's hands. A subscriber, the suite and the command line (no tally) see everything.
    public var freeView: FreeView {
        guard tally != nil else { return .open }
        return FreeView(of: allTimeScorecard, entitlement: entitlement)
    }

    /// Starts the drill, or shows the price where the free tier does not include this habit.
    /// Every route into a drill — the scorecard row, the drills list, a lesson's practice button,
    /// the card on Home — comes through here, so no screen can forget.
    func drillIfAllowed(_ leak: Leak, _ start: () -> Void) {
        guard freeView.mayDrill(leakID: leak.id) else { return showPaywall() }
        start()
    }

    /// Which seat the player loses most from, off everything ever played. Shown to everyone —
    /// the by-seat table it is read from already is.
    public var seatDiagnosis: SeatDiagnosis { SeatDiagnosis(of: history.hands(in: .allTime)) }

    /// Whether the player may drill a seat. A subscriber always; a free player only where the
    /// habit the seat is worst at is the one habit they are shown — a seat drill is a drill at
    /// whatever goes wrong there, and the free tier includes one drill.
    public func mayDrillSeat(_ position: Position) -> Bool {
        let free = freeView
        guard free.isRestricted else { return true }
        guard let leading = seatDiagnosis.report(for: position)?.leading.first?.leak else { return false }
        return free.mayDrill(leakID: leading.id)
    }

    /// The seat drill's target as it would be dealt — the seat, at the table size it was played
    /// at, leaning towards the habit it is worst at.
    public func seatTarget(_ position: Position) -> DrillTarget {
        let report = seatDiagnosis.report(for: position)
        return DrillTarget(seat: position, tableSize: report?.tableSize ?? TableSession.seatsAtTable,
                           leaning: report?.leading.first?.leak)
    }

    /// What picking a seat should do: carry a half-finished seat drill on, run its check, or
    /// offer a fresh one. The price where the free tier does not include it.
    public func startOrResumeSeatDrill(_ position: Position) {
        guard mayDrillSeat(position) else { return showPaywall() }
        let target = seatTarget(position)
        if let standing = drills.standing(of: target.id) {
            if standing.stage == .running, standing.sessionID != nil { return resumeDrill(standing) }
            if standing.stage == .due { return offerDrill(target, retest: true) }
        }
        offerDrill(target)
    }
    /// The sitting on the table right now, as it was written down, with the arranged hands left
    /// out.
    ///
    /// Home's "this session" panel is built from this rather than from `Coach.profile`, which is
    /// not the sitting at all: the coach's memory is everything since the app opened and is cleared
    /// by nothing short of resetting the history, so sitting a tournament, then an assessment, then
    /// a drill added all three together and priced them per hundred hands as one evening's poker.
    /// The arranged hands come out here for the same reason they come out everywhere else — a drill
    /// is practice at one situation with the player watching for it, and the app promises in as
    /// many words that those hands touch neither the win rate nor the leaks.
    var sittingHands: [HandRecord] {
        history.hands(in: .session(session.sessionID)).filter { !$0.isArranged }
    }
    var sittingAnalysis: GameAnalysis { GameAnalysis(of: sittingHands) }
    /// The habits this sitting confirmed, off the same hands the numbers beside them come from.
    var sittingLeaks: LeakScorecard { LeakScorecard(of: sittingHands) }

    /// Every habit that has been drilled, and what is owed on each.
    var drills: DrillBook { session.drills }
    var pastSessions: [SessionSummary] {
        history.sessions().filter { $0.id != session.sessionID }
    }

    var scopeName: String {
        switch scope {
        case .thisSession: return "This session"
        case .allTime: return "All time"
        case .past(let id):
            guard let summary = history.sessions().first(where: { $0.id == id }) else { return "A session" }
            return HistoryFilter.title(for: summary)
        }
    }

    // There is deliberately no way to the coach's own memory from here. It is the within-hand
    // judging state: it starts empty every launch, it is emptied by nothing short of resetting the
    // history, and it counts arranged hands. Every screen reads the written-down hands instead, and
    // the two times a screen reached for that memory — for the rating, and for "this session" — it
    // reported something the app had promised in writing that it would not.

    /// What the player is worth, off the hands on disk. There is one rating in the app and this is
    /// where it comes from: `Coach.profile` is the within-hand judging state and starts empty every
    /// launch, so a rating read off it tells somebody with a year of hands behind them that they
    /// have never played. It also counted drill hands, which would have let anyone raise their own
    /// rating by practising rather than by playing differently.
    public var rating: SkillRating { allTimeScorecard.rating }

    /// The hands the rating counts and the hands it leaves out, so Home can add its three numbers
    /// up in words. `LeakScorecard` keeps drill and re-check hands out of the rating on purpose —
    /// practice at one situation with you watching for it is not how you play when it turns up
    /// unannounced — and the same rule is applied here rather than copied.
    public var playedHands: Int { history.hands.filter { !$0.isDrill && !$0.isRetest }.count }
    public var practiceHands: Int { history.hands.count - playedHands }

    /// True from the moment a sitting runs out of hands until the report it opened is put away, so
    /// the report can say the sitting is over rather than just appearing over Home.
    public private(set) var assessmentJustFinished = false
    /// The habit worth working on next, from the same hands for the same reason.
    ///
    /// For a free player it is the one habit they are shown — the most common — rather than the
    /// most expensive, so Home and the scorecard name the same thing.
    public var focus: Leak? {
        let card = allTimeScorecard
        let free = freeView
        if free.isRestricted {
            guard let id = free.visibleLeakID, free.basis == .named else { return nil }
            return card.costing.first { $0.id == id }?.leak
        }
        return card.costing.first?.leak
    }
    /// Where the player is on the ladder. Read off the saved hands, so it is the same after a
    /// restart as it was before one.
    var ladder: LadderStanding { session.ladder }

    /// How long each opponent takes. Long enough to read who did what, short enough not to wait on.
    private let opponentPace = Duration.milliseconds(620)
    private var dealer: Task<Void, Never>?
    /// A clean hand gets a thumbs up on the felt and nothing else — see `reviewShouldInterrupt`.
    public private(set) var thumbsUp = false

    // MARK: - Playing

    public func start(_ mode: GameMode) {
        // Every way onto a table comes through here — Home, the ladder, a drill, the assessment —
        // which is why the check is here and not on each of the buttons.
        guard mayPlay else {
            sheet = .paywall(dismissable: false)
            return
        }
        dealer?.cancel()
        magnified = nil
        thumbsUp = false
        sheet = nil
        changing { session.start(mode) }
        screen = .table
        runOpponents()
    }

    public func leaveTable() {
        dealer?.cancel()
        magnified = nil
        thumbsUp = false
        changing { session.leave() }
        sheet = nil
        screen = .home
    }

    public func act(_ action: PlayerAction) {
        let applied = session.lastActions[session.heroSeat]
        changing { session.act(action) }
        // Only make a noise if the action was actually accepted.
        if let now = session.lastActions[session.heroSeat], now != applied {
            play(for: now)
        }
        // Folded? The hand is over for them. Run the rest out at once and get to the feedback
        // rather than making them watch a pot they are not in.
        if session.isPlaying, !session.heroInHand, !handComplete {
            changing { session.finishHandNow() }
            return
        }
        runOpponents()
    }

    public func continueAfterReview() {
        sheet = nil
        // A card held up belongs to the hand it came from. Carrying it into the next one shows
        // somebody a card that is no longer on the table — the same fault as a definition about
        // the last hand still sitting above the felt during this one.
        magnified = nil
        thumbsUp = false
        // The allowance runs out mid-sitting, because hands are counted as they finish. The hand
        // just played is honoured — it was already dealt — and the next one is where the table
        // stops. Stopping any earlier would take away a hand somebody was in the middle of.
        guard mayPlay else {
            sheet = .paywall(dismissable: false)
            return
        }
        changing { session.continueAfterReview() }
        if session.mode == .drill || session.mode == .retest, !session.isPlaying {
            // The drill has run out of hands, one way or the other. Off the felt and into the
            // report — including when the report is "this did not clear", which is the one it must
            // never quietly skip. One assignment rather than a dismissal and a presentation, which
            // is why the report cannot be lost on the way.
            screen = .leaks
            sheet = session.drill.map(Sheet.drillReport)
            return
        }
        if session.mode == .assessment, !session.isPlaying {
            // The sitting has run out of hands. Off the felt and into the report, which is the end
            // the whole thing was announced as having.
            screen = .home
            assessmentJustFinished = true
            sheet = .assessmentReport
            return
        }
        runOpponents()
    }

    // MARK: - The assessment

    var assessment: AssessmentRun? { session.assessment }
    /// A sitting that was started and walked away from, if there is one to offer.
    var resumableAssessment: SessionSummary? { session.unfinishedAssessment() }
    /// The sitting whose report is worth opening: the one that just ended, or the last one that ran
    /// to the end. The fallback matters — leaving the table puts the run down, and the report has to
    /// still be readable afterwards.
    ///
    /// A sitting still being played is deliberately not it. `session.assessmentReport` answers for
    /// as long as there is a run at all, finished or not, so Home used to show one assessment's
    /// headline — "40 hands. 2 habits were already clear enough to be worth naming" — beside a
    /// button that opened a different one, "You stopped 7 hands into 40". A card and its own button
    /// have to be about the same sitting. What the sitting on the table has so far is on the table,
    /// where the player left it.
    public var assessmentReport: AssessmentReport? {
        if assessmentInProgress { return session.lastFinishedAssessment() }
        return session.assessmentReport ?? session.lastFinishedAssessment()
    }
    public var finishedAssessment: AssessmentReport? { session.lastFinishedAssessment() }

    /// Whether an assessment is on the table right now.
    public var assessmentInProgress: Bool { session.mode == .assessment && session.isPlaying }

    func offerAssessment() {
        // Already sitting at one: go back to it. The card cannot offer to carry on with the sitting
        // that is on the table — the player is in it — so without this the only button left on the
        // card would start a second assessment and orphan the first.
        if assessmentInProgress {
            screen = .table
            return
        }
        sheet = .assessmentBrief
    }

    public func beginAssessment() {
        dealer?.cancel()
        thumbsUp = false
        sheet = nil
        changing { session.startAssessment() }
        screen = .table
        runOpponents()
    }

    func resumeAssessment() {
        guard let sitting = session.unfinishedAssessment() else { return beginAssessment() }
        dealer?.cancel()
        thumbsUp = false
        sheet = nil
        changing { session.resumeAssessment(sitting.id) }
        screen = .table
        runOpponents()
    }

    public func showAssessmentReport() {
        assessmentJustFinished = false
        sheet = .assessmentReport
    }

    // MARK: - Drills

    /// The drill on the table, or nil when this is not one.
    var drill: DrillRun? { session.drill }

    /// What picking a habit off the scorecard should do, which depends on where its drill stands:
    /// carry a half-finished one on, run the check that has come due, or start a fresh drill. The
    /// screen asks for the habit and this decides — the view has no business knowing the states.
    public func startOrResumeDrill(_ leak: Leak) {
        drillIfAllowed(leak) {
            if let standing = drills.standing(of: leak.id) {
                if standing.stage == .running, standing.sessionID != nil { return resumeDrill(standing) }
                if standing.stage == .due { return offerDrill(leak, retest: true) }
            }
            offerDrill(leak)
        }
    }

    public func offerDrill(_ leak: Leak, retest: Bool = false) {
        drillIfAllowed(leak) { sheet = .drillBrief(DrillTarget(habit: leak), retest: retest) }
    }

    func offerDrill(_ target: DrillTarget, retest: Bool = false) {
        if let leak = target.leak { return offerDrill(leak, retest: retest) }
        guard let seat = target.seat, mayDrillSeat(seat) else { return showPaywall() }
        sheet = .drillBrief(target, retest: retest)
    }

    /// The brief holds the target and whether this is a check, so both are handed back rather than
    /// parked in a second copy that could fall out of step with the sheet on screen.
    func beginDrill(_ target: DrillTarget, retest: Bool) {
        dealer?.cancel()
        thumbsUp = false
        sheet = nil
        changing { session.startDrill(target, retest: retest) }
        screen = .table
        runOpponents()
    }

    func beginDrill(_ leak: Leak, retest: Bool) { beginDrill(DrillTarget(habit: leak), retest: retest) }

    /// Carries a half-finished drill on from where it was left.
    func resumeDrill(_ standing: DrillBook.Standing) {
        if let leak = standing.leak {
            guard freeView.mayDrill(leakID: leak.id) else { return showPaywall() }
        } else if let seat = standing.target.seat {
            guard mayDrillSeat(seat) else { return showPaywall() }
        }
        guard let id = standing.sessionID else { return offerDrill(standing.target) }
        dealer?.cancel()
        thumbsUp = false
        sheet = nil
        var resumed = false
        changing { resumed = session.resumeDrill(standing.target, sessionID: id) }
        guard resumed else { return offerDrill(standing.target) }
        screen = .table
        runOpponents()
    }

    /// Opens the review by hand. This has to stop the clock: on a hand played well the next one is
    /// already on its way, and dealing it out from under someone who just asked to read the hand is
    /// exactly the bug that made the button look broken.
    public func showReview() {
        dealer?.cancel()
        thumbsUp = false
        redraw()
        sheet = .review
    }

    /// Whether the opponents are still on the clock. The window never asks; the suite does, because
    /// "the table carried on dealing while nobody was looking at it" is otherwise invisible from
    /// outside this class.
    public var dealerIsRunning: Bool {
        guard let dealer else { return false }
        return !dealer.isCancelled
    }

    /// Stops the clock without putting the sitting down. The hand keeps its state; the opponents
    /// stop acting until somebody is looking at them again.
    private func pauseTable() {
        dealer?.cancel()
        thumbsUp = false
        // A review raised at the table belongs to the table. Anywhere else it is a sheet nobody
        // asked for, over a screen it says nothing about.
        if case .review = sheet { sheet = nil }
    }

    /// Picks the table back up. A hand that finished while the player was away is left finished:
    /// the bar under the felt offers the review and the next hand, which is the same place they
    /// would have been had they never left.
    private func resumeTable() {
        guard !handComplete else {
            // Coming back to a hand that finished while nobody was looking at it. The same two
            // outcomes as if they had been sitting here: the review when there is something to
            // say, otherwise the thumbs up and the next hand. Nothing to press, here or anywhere.
            if reviewShouldInterrupt {
                sheet = .review
            } else if session.isPlaying {
                approveAndDealOn()
            }
            return
        }
        runOpponents()
    }

    /// Called when whatever was open closes, however it closed — its own button, a swipe, or the
    /// app putting something else up.
    ///
    /// This is the whole of "the table deals on by itself". The review used to be closed by a
    /// button that also dealt the next hand, so any other way out of it left the table stopped
    /// with a Next hand button as the only way to un-stick it. That button is gone, which means
    /// every way out of the review has to lead to the same place.
    /// Continuing can raise the paywall, which means putting a sheet up inside another one's
    /// dismissal. That is safe here for the same reason the app has one sheet and one presenter:
    /// the handover is a single assignment to `sheet`, so there is no dismissal racing a
    /// presentation. Doing it on a `Task` instead would put it beyond the reach of the suite,
    /// which is worse — this is the path every finished hand now takes.
    public func sheetDismissed() {
        guard screen == .table, handComplete, session.isPlaying else { return }
        continueAfterReview()
    }

    /// Deals the opponents out one at a time, so the hand can be watched rather than just arrived
    /// at. Each decision is drawn and heard before the next one starts.
    private func runOpponents() {
        // A finished hand has already scheduled what happens next — the thumbs up and then the
        // next hand — and that schedule lives on `dealer` too. Cancelling it here is what stranded
        // the table.
        //
        // `act` ends by calling this unconditionally, so any hand that ended on the player's OWN
        // action ran `approveAndDealOn`, set the timer, and then had it cancelled one line later.
        // A hand that ended on an OPPONENT's action was inside the dealer task rather than inside
        // `act`, so nothing cancelled it and the table dealt on by itself. That is the whole of
        // "sometimes I need to press next hand, sometimes I don't" — the button was covering for
        // this, and removing the button turned it into a table that locked.
        guard !handComplete else { return }
        dealer?.cancel()
        guard session.opponentToAct else { return }
        dealer = Task { @MainActor [weak self] in
            while let self, !Task.isCancelled, self.session.opponentToAct {
                try? await Task.sleep(for: self.opponentPace)
                guard !Task.isCancelled else { return }
                self.stepOpponent()
                if self.handComplete { return }
            }
        }
    }

    /// One opponent's decision, drawn and heard, and everything that follows from it if the hand
    /// ended on it. The dealer above calls this on a timer; the suite calls it in a loop, which is
    /// how the table can be played out without a clock in the room.
    @discardableResult public func stepOpponent() -> Bool {
        guard session.opponentToAct else { return false }
        var acted = false
        changing {
            if let applied = session.stepOpponent() {
                play(for: applied)
                acted = true
            }
        }
        return acted
    }

    /// The table's noises, or silence where there is nobody to hear them.
    private func play(_ cue: Sound.Cue) {
        guard !silent else { return }
        Sound.shared.play(cue)
    }

    private func play(for applied: TableSession.AppliedAction) {
        guard !silent else { return }
        Sound.shared.play(for: applied)
    }

    private var handIsOver: Bool { session.review != nil && session.engine?.isComplete == true }

    /// Whether to put the review in front of the player.
    ///
    /// Only hands with something to say interrupt. A hand played well gets a thumbs up on the felt
    /// and deals on — being stopped to be told you did nothing wrong is an interruption that costs
    /// something and teaches nothing. The hand is still under Hands if it is ever wanted.
    private var reviewShouldInterrupt: Bool {
        guard let review = session.review, session.engine?.isComplete == true else { return false }
        return !review.playedWell
    }

    /// `TableSession` is a plain class, so SwiftUI has to be told by hand that it moved.
    private func changing(_ work: () -> Void) {
        redraw()
        let wasOver = handIsOver
        work()
        if handIsOver {
            if !wasOver { play(.pot) }
            // Both of these are things the table does to the person watching it, so both of them
            // wait for somebody to be watching. The hand stays finished either way, and the bar
            // under the felt offers the review and the next hand when they come back.
            if screen == .table {
                if reviewShouldInterrupt {
                    sheet = .review
                } else if !wasOver {
                    approveAndDealOn()
                }
            }
        }
        redraw()
    }

    /// The whole of the feedback on a hand played well: a thumbs up, and then the next hand.
    private func approveAndDealOn() {
        thumbsUp = true
        dealer?.cancel()
        dealer = Task { @MainActor [weak self] in
            try? await Task.sleep(for: .milliseconds(2100))
            guard let self, !Task.isCancelled else { return }
            self.thumbsUp = false
            // Still playing? Deal on. Busted or finished, leave the table as it is.
            if self.session.isPlaying { self.continueAfterReview() } else { self.redraw() }
        }
    }

    /// True once a hand has finished, whether or not the review sheet is still up.
    public var handComplete: Bool { handIsOver }

    /// Set on the hands the coach had nothing to say about, so the table can show that quietly
    /// instead of opening a sheet to say nothing.
    var handWasClean: Bool { handIsOver && session.review?.playedWell == true }

    /// Throws away every saved hand and the coach's read on the player. Irreversible from inside
    /// the app: the previous file is kept on disk, but nothing here goes looking for it.
    func resetHistory() {
        redraw()
        dealer?.cancel()
        session.leave()
        session.history.clear()
        session.coach.reset()
        scope = .allTime
        sheet = nil
        thumbsUp = false
        screen = .home
        redraw()
    }

    var historyBackupPath: String? {
        session.history.hasBackup ? session.history.backupURL?.path : nil
    }

    func use(_ palette: Palette) {
        redraw()
        Theme.use(palette)
        self.palette = palette
    }

    func use(_ scale: TextScale) {
        redraw()
        Theme.use(scale)
        self.textScale = scale
    }

    func use(_ colours: CardColours) {
        redraw()
        Theme.use(colours)
        self.cardColours = colours
    }

    func showTutorial() {
        sheet = .tutorial
    }

    func finishTutorial() {
        sheet = nil
        UserDefaults.standard.set(true, forKey: Self.tutorialKey)
    }

    /// Closes whatever is open. The sheets themselves have "Not now" and "Close" buttons that all
    /// come here rather than each reaching for a flag of its own.
    public func dismissSheet() {
        sheet = nil
        assessmentJustFinished = false
    }

    var soundOn: Bool { Sound.shared.enabled }

    func toggleSound() {
        redraw()
        Sound.shared.enabled.toggle()
    }

    // MARK: - Table readout

    var mode: GameMode { session.mode }
    var engine: HandEngine? { session.engine }
    var isPlaying: Bool { session.isPlaying }
    var heroSeat: Int { session.heroSeat }
    var handNumber: Int { session.handNumber }
    var level: BlindLevel { session.level }
    var playersLeft: Int { session.playersLeft }
    var tableDifficulty: Int { session.tableDifficulty }
    var statusLine: String { session.statusLine }
    var handSummary: String { session.handSummary }
    var review: HandReview? { session.review }
    var pot: Int { session.pot }
    var board: [Card] { session.board }
    var seats: [SeatState] { session.seats }
    var positions: [Int: Position] { session.positions }
    public var heroCards: [Card] { session.heroCards }
    var actorSeat: Int? { session.actorSeat }
    var legal: LegalActions? { session.legal }
    var chipTray: ChipTray? { session.chipTray }

    func label(forSeat seat: Int) -> String { session.label(forSeat: seat) }
    func lastAction(forSeat seat: Int) -> TableSession.AppliedAction? { session.lastActions[seat] }
    // Deliberately not exposed to the table: which style an opponent is playing is the read the
    // player is meant to be making for themselves. `TableSession` still knows, for the tests and
    // for the coach.
    func revealedCards(forSeat seat: Int) -> [Card]? { session.revealedCards(forSeat: seat) }
}

/// What the analysis screens are pointed at. "This session" has to be resolved when it is read,
/// not stored, because sitting down again starts a new one.
enum HistoryFilter: Equatable, Hashable {
    case thisSession
    case allTime
    case past(String)

    static func title(for summary: SessionSummary) -> String {
        let when = summary.started.formatted(date: .abbreviated, time: .shortened)
        return "\(when) · \(Copy.hands(summary.hands))"
    }
}
