import SwiftUI
import PokerCore
import PokerAI
import PokerCoach
import PokerSession

struct TableView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics

    var body: some View {
        VStack(spacing: 0) {
            header
            if let run = app.assessment { AssessmentBanner(handNumber: app.handNumber, run: run) }
            if let run = app.drill { DrillBanner(handNumber: app.handNumber, run: run) }
            GeometryReader { geo in
                ZStack {
                    felt(in: geo.size)
                    centre(in: geo.size)
                    ForEach(app.seats.filter { $0.status != .sittingOut && $0.seat != app.heroSeat },
                            id: \.seat) { seat in
                        SeatView(seat: seat)
                            .position(seatPoint(for: seat.seat, in: geo.size))
                    }
                    // The button and the blinds, on the felt in front of the seats that hold them.
                    ForEach(markedSeats, id: \.seat) { entry in
                        MarkerRow(marks: entry.marks)
                            .position(markerPoint(for: entry.seat, marks: entry.marks.count, in: geo.size))
                    }
                    if app.thumbsUp { ThumbsUp() }
                    // Over everything else on the felt, including the seat ring, so a card held up
                    // is not competing with the table it came from.
                    if let card = app.magnified {
                        MagnifiedCard(card: card) { app.putCardBack() }
                    }
                }
                .animation(.spring(response: 0.32, dampingFraction: 0.72), value: app.thumbsUp)
            }
            .padding(.horizontal, metrics.compact ? 4 : 20)
            heroRow
            actionBar
        }
        .background(Theme.background)
    }

    /// The player's own cards, name, stack and bet, in a row of their own between the felt and
    /// the buttons. Not on the ring: the same layout as PLO Tutor, which Ken preferred to a seat
    /// box holding the cards. See `Metrics.heroCard`.
    @ViewBuilder private var heroRow: some View {
        if let hero = app.seats.first(where: { $0.seat == app.heroSeat && $0.status != .sittingOut }) {
            HeroRow(seat: hero)
                .frame(height: metrics.heroRowHeight)
                .frame(maxWidth: .infinity)
                .background(Theme.background)
        }
    }

    // MARK: Header

    private var header: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: metrics.compact ? 12 : 18) {
                Button { app.leaveTable() } label: {
                    HStack(spacing: 3) {
                        Symbol("chevron.left")
                        if !metrics.compact { Text("Leave") }
                    }
                    // On the whole row, so the chevron grows with the word beside it.
                    .font(.system(size: Theme.size(12), weight: .medium, design: .rounded))
                    .foregroundStyle(Theme.dim)
                    .frame(minWidth: metrics.compact ? 32 : 0, minHeight: metrics.compact ? 32 : 0)
                    .contentShape(Rectangle())
                }
                .buttonStyle(.plain)

                readout("Blinds", "\(app.level.smallBlind)/\(app.level.bigBlind)")
                if app.mode == .tournament {
                    readout(metrics.compact ? "Left" : "Players left", "\(app.playersLeft)")
                }
                if app.mode == .cash {
                    readout(Copy.cashStructureReadout, Copy.structureName(app.bettingStructure))
                }
                readout("Hand", "\(app.handNumber)")
                if !metrics.compact {
                    readout("Table", "difficulty \(app.tableDifficulty)")
                }

                Spacer()

                if !metrics.compact { commentary }

                Button { app.toggleSound() } label: {
                    Symbol(app.soundOn ? "speaker.wave.2" : "speaker.slash", size: 13)
                        .foregroundStyle(Theme.dim)
                        .frame(minWidth: 32, minHeight: 32)
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
            }
            // On a phone the running commentary needs the width to itself.
            if metrics.compact, !commentaryText.isEmpty {
                commentary
            }
        }
        .padding(.horizontal, metrics.compact ? 14 : 22)
        .padding(.vertical, metrics.compact ? 9 : 12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Theme.panel)
    }

    private var commentaryText: String {
        app.statusLine.isEmpty ? (app.handSummary.isEmpty ? "" : "You have \(app.handSummary)") : app.statusLine
    }

    @ViewBuilder private var commentary: some View {
        HStack(spacing: 10) {
            if !app.handSummary.isEmpty {
                Text("You have \(app.handSummary)").body().foregroundStyle(Theme.accent)
            }
            if !app.statusLine.isEmpty {
                Text(app.statusLine).body().foregroundStyle(Theme.gold)
            }
        }
    }

    private func readout(_ title: String, _ value: String) -> some View {
        VStack(alignment: .leading, spacing: 1) {
            Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim.opacity(0.8))
            Text(value).numeric().foregroundStyle(Theme.text)
        }
    }

    // MARK: Felt

    private func felt(in size: CGSize) -> some View {
        let inset: CGFloat = metrics.compact ? 30 : 62
        return ZStack {
            RoundedRectangle(cornerRadius: 300, style: .continuous)
                .fill(Theme.rail)
                .padding(inset - 16)
                .shadow(color: .black.opacity(0.5), radius: 18, y: 8)
            RoundedRectangle(cornerRadius: 300, style: .continuous)
                .fill(
                    RadialGradient(colors: [Theme.feltInner, Theme.feltOuter],
                                   center: .center, startRadius: 20, endRadius: max(size.width, size.height) * 0.55)
                )
                .padding(inset)
                .overlay(
                    RoundedRectangle(cornerRadius: 300, style: .continuous)
                        .stroke(Color.white.opacity(0.07), lineWidth: 1)
                        .padding(inset)
                )
        }
    }

    private func centre(in size: CGSize) -> some View {
        VStack(spacing: 12) {
            HStack(spacing: Metrics.boardGap) {
                ForEach(0..<5, id: \.self) { index in
                    if index < app.board.count {
                        CardView(card: app.board[index], size: metrics.boardCard,
                                 onTap: { app.magnify($0) })
                    } else {
                        RoundedRectangle(cornerRadius: 7, style: .continuous)
                            .stroke(Color.white.opacity(0.10), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
                            .frame(width: metrics.boardCard * 0.72, height: metrics.boardCard)
                    }
                }
            }
            if app.pot > 0 {
                HStack(spacing: 6) {
                    Text("POT").caption(weight: .medium).foregroundStyle(Theme.text.opacity(0.55))
                    Text("\(app.pot)").numeric(15).foregroundStyle(Theme.text)
                }
                .padding(.horizontal, 12).padding(.vertical, 5)
                .background(Capsule().fill(Color.black.opacity(0.32)))
            }
        }
        .position(x: size.width / 2, y: size.height / 2 - 6)
    }

    /// Seats sit around an ellipse, with the player always at the bottom.
    private func seatPoint(for seat: Int, in size: CGSize) -> CGPoint {
        let live = app.seats.filter { $0.status != .sittingOut }.map(\.seat).sorted()
        guard let index = live.firstIndex(of: seat), !live.isEmpty else { return .zero }
        let heroIndex = live.firstIndex(of: app.heroSeat) ?? 0
        return FeltRing(size: size, metrics: metrics)
            .point(seatsFromHero: (index - heroIndex + live.count) % live.count, of: live.count)
    }

    /// Which seats carry a marker this hand, and which ones. Heads-up the button posts the
    /// small blind, so it carries both.
    private var markedSeats: [(seat: Int, marks: [TableMark])] {
        let headsUp = app.positions.count == 2
        return app.seats.filter { $0.status != .sittingOut }.compactMap { seat in
            guard let position = app.positions[seat.seat] else { return nil }
            let marks = TableMark.marks(for: position, headsUp: headsUp)
            return marks.isEmpty ? nil : (seat.seat, marks)
        }
    }

    /// Where a seat's marker sits: see `Metrics.markerCentre`.
    private func markerPoint(for seat: Int, marks: Int, in size: CGSize) -> CGPoint {
        let live = app.seats.filter { $0.status != .sittingOut }.map(\.seat).sorted()
        guard let index = live.firstIndex(of: seat), !live.isEmpty else { return .zero }
        let heroIndex = live.firstIndex(of: app.heroSeat) ?? 0
        return metrics.markerCentre(seatsFromHero: (index - heroIndex + live.count) % live.count,
                                    of: live.count, marks: marks, in: size)
    }


    // MARK: Actions

    private var actionBar: some View {
        Group {
            if let legal = app.legal {
                // A fresh identity per decision, so the raise slider always starts from a sensible
                // size rather than wherever it was left on the last street.
                ActionBar(legal: legal)
                    .id("\(app.handNumber)-\(app.board.count)-\(app.pot)-\(legal.callCost)")
            } else if app.handComplete {
                // No buttons. Ken, 2026-08-22: "in some cases, I need to press next hand, sometimes
                // I don't. Lets make it consistent and not need that button."
                //
                // The table deals on by itself now, so all this row still has to do is say what
                // just happened — and on a phone, handing a button's worth of height back to the
                // felt is worth more than the buttons were. The review is not lost with them: a
                // hand worth reviewing opens itself, and every hand is under Hands afterwards.
                HStack {
                    Text(app.statusLine).body().foregroundStyle(Theme.gold)
                    Spacer()
                }
                .padding(.horizontal, metrics.compact ? 14 : 22)
                .padding(.vertical, metrics.compact ? 6 : 10)
                .background(Theme.panel)
            } else if app.engine == nil {
                VStack(alignment: .leading, spacing: 10) {
                    Text(app.statusLine.isEmpty ? "Table closed." : app.statusLine)
                        .body().foregroundStyle(Theme.dim)
                    HStack {
                        if !metrics.compact { Spacer() }
                        PillButton(title: "Back to home", wide: metrics.compact) { app.screen = .home }
                    }
                }
                .padding(.horizontal, metrics.compact ? 14 : 22)
                .padding(.vertical, metrics.compact ? 10 : 14)
                .background(Theme.panel)
            } else {
                HStack {
                    Text(waitingText).body().foregroundStyle(Theme.dim)
                    Spacer()
                }
                .frame(minHeight: metrics.compact ? 46 : 0)
                .padding(.horizontal, metrics.compact ? 14 : 22)
                .padding(.vertical, metrics.compact ? 14 : 22)
                .background(Theme.panel)
            }
        }
    }

    private var waitingText: String {
        guard let actor = app.actorSeat else { return "Dealing…" }
        return "\(app.label(forSeat: actor)) is thinking…"
    }
}

/// Fold, check or call, and a raise with a size you choose.
///
/// One row where there is room for one. On a phone it stacks: the sizing controls on top, then the
/// decisions themselves along the bottom, where a thumb naturally sits.
struct ActionBar: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    let legal: LegalActions

    /// The chips counted out so far, in the order they were picked up, so the last one can be put
    /// back down again.
    @State var picked: [Chip] = []

    /// Fixed-limit has nothing to count out: a bet is one size, so the tray stays in the drawer
    /// and the Bet or Raise button carries the amount.
    private var fixedLimit: Bool { app.bettingStructure == .fixedLimit }
    private var tray: ChipTray? { fixedLimit ? nil : app.chipTray }
    private var amount: Int { picked.reduce(0) { $0 + $1.value } }

    var body: some View {
        Group {
            if metrics.compact { stacked } else { inLine }
        }
        .padding(.horizontal, metrics.compact ? 14 : 22)
        .padding(.vertical, metrics.compact ? 10 : 14)
        .background(Theme.panel)
    }

    private var inLine: some View {
        HStack(alignment: .center, spacing: 14) {
            fold
            check
            call
            if let tray {
                Divider().frame(height: 46).overlay(Color.white.opacity(0.08))
                chips(tray)
                counter(tray)
                commit(tray)
            }
            fixedBet
            capNote
            Spacer()
        }
    }

    private var stacked: some View {
        VStack(spacing: 10) {
            if let tray {
                chips(tray)
                counter(tray)
            }
            capNote
            HStack(spacing: 8) {
                fold
                check
                call
                if let tray { commit(tray) }
                fixedBet
            }
        }
    }

    // MARK: Fixed-limit

    /// One size, so one button: "Bet 100" or "Raise to 200".
    @ViewBuilder private var fixedBet: some View {
        if fixedLimit, legal.canBetOrRaise {
            PillButton(title: legal.canBet ? Copy.fixedBetTitle(legal.maxRaiseTo)
                                           : Copy.fixedRaiseTitle(legal.maxRaiseTo),
                       subtitle: legal.maxIsAllIn ? "all in" : nil,
                       wide: metrics.compact) {
                app.act(legal.canBet ? .bet(to: legal.maxRaiseTo) : .raise(to: legal.maxRaiseTo))
            }
        }
    }

    @ViewBuilder private var capNote: some View {
        if legal.capped {
            Text(Copy.bettingCapped).caption().foregroundStyle(Theme.dim)
        }
    }

    // MARK: The chips

    private func chips(_ tray: ChipTray) -> some View {
        HStack(spacing: metrics.compact ? 5 : 7) {
            ForEach(tray.offered) { chip in
                ChipButton(chip: chip,
                           size: metrics.compact ? 42 : 46,
                           enabled: tray.canAdd(chip, to: amount)) {
                    picked.append(chip)
                    Sound.shared.play(.chips)
                }
            }
        }
    }

    /// What has been counted out, and the two ways to change your mind about it.
    private func counter(_ tray: ChipTray) -> some View {
        VStack(alignment: .leading, spacing: 3) {
            HStack(spacing: 6) {
                Text(legal.canBet ? "BET" : "RAISE TO").caption(weight: .medium).foregroundStyle(Theme.dim)
                Text("\(amount)").numeric(16).foregroundStyle(amount > 0 ? Theme.text : Theme.dim)
                if let short = tray.shortfall(amount), amount > 0 {
                    Text("· \(short) short of the minimum").caption().foregroundStyle(Theme.gold)
                } else if amount == 0 {
                    Text("· minimum \(tray.minimum)").caption().foregroundStyle(Theme.dim)
                } else if amount == tray.maximum {
                    Text("· all in").caption().foregroundStyle(Theme.warn)
                }
            }
            HStack(spacing: 6) {
                GhostButton(title: "Undo", narrow: true) {
                    if !picked.isEmpty { picked.removeLast() }
                }
                .opacity(picked.isEmpty ? 0.4 : 1)
                .disabled(picked.isEmpty)

                GhostButton(title: "Clear", narrow: true) { picked.removeAll() }
                    .opacity(picked.isEmpty ? 0.4 : 1)
                    .disabled(picked.isEmpty)

                // Counting out an all-in one chip at a time is nobody's idea of a good time.
                GhostButton(title: "All in", narrow: true) {
                    picked = tray.quickest(tray.maximum)
                    Sound.shared.play(.chips)
                }
            }
        }
    }

    // MARK: The decisions

    @ViewBuilder private var fold: some View {
        if legal.canFold && !legal.canCheck {
            GhostButton(title: "Fold", tint: Theme.warn, wide: metrics.compact) { app.act(.fold) }
        }
    }

    @ViewBuilder private var check: some View {
        if legal.canCheck {
            GhostButton(title: "Check", wide: metrics.compact) { app.act(.check) }
        }
    }

    @ViewBuilder private var call: some View {
        if legal.canCall {
            PillButton(title: "Call", subtitle: "\(legal.callCost)\(legal.callIsAllIn ? " — all in" : "")",
                       tint: Theme.hero, wide: metrics.compact) { app.act(.call) }
        }
    }

    private func commit(_ tray: ChipTray) -> some View {
        let ready = tray.isLegal(amount)
        return PillButton(title: legal.canBet ? "Bet" : "Raise",
                          subtitle: ready ? "\(amount)" : nil,
                          wide: metrics.compact,
                          enabled: ready) {
            app.act(legal.canBet ? .bet(to: amount) : .raise(to: amount))
        }
    }
}

/// A casino chip. Pressing it puts one more of these in.
struct ChipButton: View {
    let chip: Chip
    var size: CGFloat = 46
    var enabled = true
    let action: () -> Void

    @State var pressed = false

    var body: some View {
        Button(action: action) {
            ZStack {
                Circle().fill(Theme.chipFill(chip))
                    .shadow(color: .black.opacity(0.4), radius: 2, y: 1)
                // The dashed ring is what makes a coloured circle read as a poker chip.
                Circle()
                    .strokeBorder(Color.white.opacity(0.75), style: StrokeStyle(lineWidth: size * 0.09,
                                                                                dash: [size * 0.16, size * 0.13]))
                Circle().strokeBorder(Color.black.opacity(0.28), lineWidth: 1)
                Circle().fill(Theme.chipFill(chip)).padding(size * 0.19)
                // Fixed: the denomination is a fraction of the chip it is printed on. Growing it
                // does not make it easier to read, it makes it wider than the chip.
                Text(chip.label)
                    .font(.system(size: size * (chip.label.count > 3 ? 0.26 : 0.30),
                                  weight: .bold, design: .rounded))
                    .foregroundStyle(Theme.chipText(chip))
            }
            .frame(width: size, height: size)
            .scaleEffect(pressed ? 0.9 : 1)
            .opacity(enabled ? 1 : 0.28)
            .contentShape(Circle())
        }
        .buttonStyle(.plain)
        .disabled(!enabled)
        .animation(.spring(response: 0.2, dampingFraction: 0.6), value: pressed)
        .hoverHighlight { pressed = $0 && enabled }
    }
}

/// One opponent at the table. The player is not drawn here — see `HeroRow`.
struct SeatView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    let seat: SeatState

    private var isActor: Bool { app.actorSeat == seat.seat }
    private var revealed: [Card]? { app.revealedCards(forSeat: seat.seat) }

    var body: some View {
        VStack(spacing: 5) {
            HStack(spacing: Metrics.cardGap) {
                if let revealed {
                    ForEach(Array(revealed.enumerated()), id: \.offset) { _, card in
                        CardView(card: card, size: metrics.opponentRevealCard)
                    }
                } else if seat.isInHand {
                    ForEach(0..<Metrics.heroCardCount, id: \.self) { _ in
                        CardView(card: nil, size: metrics.faceDownCard, faceDown: true)
                    }
                }
            }
            .opacity(seat.status == .folded ? 0.25 : 1)

            SeatBox(seat: seat)

            SeatAction(seat: seat)
        }
        .frame(width: metrics.seatWidth)
        // A badge wider than its seat overlaps the seats beside it, and whichever seat happens to
        // be drawn later wins. The one that just did something is the one worth reading, so it is
        // lifted above its neighbours rather than left to the order of a ForEach.
        .zIndex(app.lastAction(forSeat: seat.seat) != nil || seat.committedThisRound > 0 ? 1 : 0)
        .scaleEffect(isActor ? 1.045 : 1)
        .animation(.spring(response: 0.3, dampingFraction: 0.75), value: isActor)
        .opacity(seat.status == .folded ? 0.72 : 1)
    }
}

/// The player's row under the felt: the two cards side by side, then the same name, stack and
/// bet an opponent's seat carries, laid across instead of down.
struct HeroRow: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    let seat: SeatState

    var body: some View {
        HStack(spacing: metrics.heroRowGap) {
            HStack(spacing: Metrics.cardGap) {
                ForEach(Array(app.heroCards.enumerated()), id: \.offset) { _, card in
                    CardView(card: card, size: metrics.heroCard, onTap: { app.magnify($0) })
                }
            }
            .opacity(seat.status == .folded ? 0.4 : 1)
            SeatBox(seat: seat)
                .frame(width: metrics.seatWidth)
            SeatAction(seat: seat)
                .frame(width: metrics.heroActionWidth, alignment: .leading)
        }
        .opacity(seat.status == .folded ? 0.8 : 1)
    }
}

/// A seat's name, seat number and stack, in the box that lights up when it is their turn.
struct SeatBox: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    let seat: SeatState

    private var isHero: Bool { seat.seat == app.heroSeat }
    private var isActor: Bool { app.actorSeat == seat.seat }

    var body: some View {
            VStack(spacing: 2) {
                HStack(spacing: 5) {
                    Text(app.label(forSeat: seat.seat))
                        .font(.system(size: metrics.seatText(metrics.compact ? 10 : 12),
                                      weight: .semibold, design: .rounded))
                        .lineLimit(1)
                        .foregroundStyle(isHero ? Theme.hero : Theme.text)
                    if let position = app.positions[seat.seat] {
                        // The number first, the old name after it: the badge is the one place a
                        // seat is read a hundred times a session, so it carries both.
                        Text(position.badge(inTableOf: app.positions.count))
                            .font(.system(size: metrics.seatText(9), weight: .medium, design: .rounded))
                            .foregroundStyle(Theme.background)
                            .padding(.horizontal, 4).padding(.vertical, 1)
                            .background(Capsule().fill(position == .button ? Theme.gold : Theme.dim.opacity(0.7)))
                    }
                }
                Text(seat.status == .folded ? "folded" : (seat.status == .allIn ? "all in" : "\(seat.stack)"))
                    .font(.system(size: metrics.seatText(metrics.compact ? 10 : 12),
                                  weight: .medium, design: .rounded)).digitsAligned()
                    .foregroundStyle(seat.status == .folded ? Theme.dim : Theme.text.opacity(0.85))
            }
            .padding(.horizontal, metrics.compact ? 7 : 11)
            .padding(.vertical, metrics.compact ? 4 : 6)
            .background(
                RoundedRectangle(cornerRadius: 9, style: .continuous)
                    .fill(isActor ? Theme.panelRaised : Theme.panel.opacity(0.92))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 9, style: .continuous)
                    .stroke(isActor ? Theme.accent : Color.white.opacity(0.07), lineWidth: isActor ? 1.6 : 1)
            )
    }
}

/// What a seat just did — a fold or check badge, or the chips they put in — under an opponent's
/// box and beside the player's.
struct SeatAction: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    let seat: SeatState

    var body: some View {
            ZStack {
                if let applied = app.lastAction(forSeat: seat.seat), applied.isPassive {
                    ActionBadge(caption: applied.caption, fold: applied.isFold)
                        .transition(.scale(scale: 0.7).combined(with: .opacity))
                } else if seat.committedThisRound > 0 {
                    ChipsView(amount: seat.committedThisRound,
                              caption: app.lastAction(forSeat: seat.seat)?.caption)
                        .transition(.scale(scale: 0.7).combined(with: .opacity))
                }
            }
            // Ken, 2026-08-22: "I cant always see the bet/raise from one of the other players."
            //
            // The seat box is 86 points wide on a phone and "Raise 12000" wants about 95, so the
            // badge was being handed less room than it needed and truncating the very thing
            // somebody was trying to read. It takes its own width now and hangs over the sides of
            // the seat, which costs nothing — it is drawn over felt, it is the widest thing on the
            // seat for about a second, and the alternative was a bet nobody could read.
            .fixedSize(horizontal: true, vertical: false)
            .frame(height: metrics.seatText(22))
            .animation(.spring(response: 0.28, dampingFraction: 0.7), value: seat.committedThisRound)
            .animation(.spring(response: 0.28, dampingFraction: 0.7), value: app.lastAction(forSeat: seat.seat))
    }
}

/// What a player just did, when there are no chips to show it with.
struct ActionBadge: View {
    @Environment(\.metrics) var metrics
    let caption: String
    let fold: Bool

    var body: some View {
        Text(caption)
            .font(.system(size: metrics.seatText(11), weight: .semibold, design: .rounded))
            .foregroundStyle(fold ? Theme.dim : Theme.text.opacity(0.9))
            .padding(.horizontal, 8).padding(.vertical, 3)
            .background(Capsule().fill(Color.black.opacity(0.42)))
    }
}

/// The one piece of praise in the app: shown on a hand played well, and gone before it can be
/// acknowledged. There is deliberately nothing to press.
struct ThumbsUp: View {
    @State var landed = false

    var body: some View {
        Symbol("hand.thumbsup.fill", size: 62)
            .foregroundStyle(Theme.accent)
            .shadow(color: .black.opacity(0.45), radius: 12, y: 4)
            .scaleEffect(landed ? 1 : 0.55)
            .opacity(landed ? 1 : 0)
            .rotationEffect(.degrees(landed ? 0 : -14))
            .onAppear {
                withAnimation(.spring(response: 0.34, dampingFraction: 0.6)) { landed = true }
            }
            .allowsHitTesting(false)
    }
}

/// Where the seats go on the felt, as arithmetic rather than as a drawing.
///
/// Pulled out of the view because it is the part that fails quietly: the seat boxes carry the
/// player's text-size setting, so at Extra large every box is wider and taller than the ring was
/// laid out for, and nine of them either overlap each other or slide off the edge of the window.
/// A test can walk every table size at every text size in a few milliseconds; a person cannot.
/// The three things a real table puts on the felt: the dealer button and the two blinds.
public enum TableMark: String, CaseIterable, Sendable {
    case dealer = "D"
    case smallBlind = "SB"
    case bigBlind = "BB"

    /// What a seat in `position` carries. Heads-up the button posts the small blind, so the
    /// engine names that seat the button and it shows both.
    public static func marks(for position: Position, headsUp: Bool) -> [TableMark] {
        switch position {
        case .button: return headsUp ? [.dealer, .smallBlind] : [.dealer]
        case .smallBlind: return [.smallBlind]
        case .bigBlind: return [.bigBlind]
        default: return []
        }
    }
}

/// The dealer's white disc and the blinds' dark chips, side by side where a seat holds more
/// than one. Sizes from `Metrics`; where it goes is `Metrics.markerCentre`.
struct MarkerRow: View {
    @Environment(\.metrics) var metrics
    let marks: [TableMark]

    var body: some View {
        HStack(spacing: metrics.markerGap) {
            ForEach(marks, id: \.self) { mark in
                let size = mark == .dealer ? metrics.dealerDisc : metrics.blindMarker
                Text(mark.rawValue)
                    .font(.system(size: size * (mark == .dealer ? 0.58 : 0.42), weight: .heavy, design: .rounded))
                    .foregroundStyle(mark == .dealer ? Color.black.opacity(0.85) : Theme.text)
                    .frame(width: size, height: size)
                    .background(
                        Circle().fill(mark == .dealer ? Color.white : Theme.panelRaised)
                    )
                    .overlay(
                        Circle().stroke(mark == .dealer ? Color.black.opacity(0.25) : Theme.dim.opacity(0.8),
                                        lineWidth: 1)
                    )
                    .shadow(color: .black.opacity(0.35), radius: 2, y: 1)
            }
        }
        .accessibilityLabel(marks.map(\.spoken).joined(separator: ", "))
    }
}

extension TableMark {
    var spoken: String {
        switch self {
        case .dealer: return "dealer button"
        case .smallBlind: return "small blind"
        case .bigBlind: return "big blind"
        }
    }
}

public struct FeltRing {
    public let size: CGSize
    public let seatWidth: CGFloat
    public let seatHeight: CGFloat
    public let compact: Bool

    public init(size: CGSize, seatWidth: CGFloat, seatHeight: CGFloat, compact: Bool) {
        self.size = size
        self.seatWidth = seatWidth
        self.seatHeight = seatHeight
        self.compact = compact
    }

    @MainActor public init(size: CGSize, metrics: Metrics) {
        self.init(size: size, seatWidth: metrics.seatWidth,
                  seatHeight: metrics.ringSeatHeight, compact: metrics.compact)
    }

    public var radiusX: CGFloat { size.width / 2 - seatWidth * 0.62 }

    /// Enough room for a seat box to sit fully on the felt at the top and the bottom.
    public var radiusY: CGFloat {
        size.height / 2 - max(compact ? 46 : 58, seatHeight / 2 + 8)
    }

    /// The player's place is the bottom of the ring and the rest run clockwise from there. Nothing
    /// is drawn at the player's point: their cards and their name sit in a row under the felt
    /// (`Metrics.heroCard`), so the ring's bottom is the gap the corner seats keep from that row.
    public func point(seatsFromHero: Int, of count: Int) -> CGPoint {
        guard count > 0 else { return .zero }
        let angle = (.pi / 2) + Double(seatsFromHero) / Double(count) * 2 * .pi
        return CGPoint(x: size.width / 2 + cos(angle) * radiusX,
                       y: size.height / 2 + sin(angle) * radiusY)
    }

    public func points(of count: Int) -> [CGPoint] {
        (0..<max(count, 0)).map { point(seatsFromHero: $0, of: count) }
    }

    /// Do the opponents' seat boxes clear each other, and stay inside the felt?
    ///
    /// Boxes are rectangles, so two of them are clear when they are a full box apart on either
    /// axis — seats stacked up the side of the felt need height between them, not width. The
    /// first point is the player's, and nothing is drawn there, so it is skipped.
    public func holds(_ count: Int) -> Bool {
        let placed = Array(points(of: count).dropFirst())
        for (index, first) in placed.enumerated() {
            if first.x - seatWidth / 2 < -0.5 || first.x + seatWidth / 2 > size.width + 0.5 { return false }
            if first.y - seatHeight / 2 < -0.5 || first.y + seatHeight / 2 > size.height + 0.5 { return false }
            for second in placed[(index + 1)...] {
                let dx = abs(first.x - second.x), dy = abs(first.y - second.y)
                if dx + 0.5 < seatWidth && dy + 0.5 < seatHeight { return false }
            }
        }
        return true
    }
}
