#if !os(Android)
import Foundation
#endif
// On Android, Skip's SwiftUI brings Foundation with it, and importing Foundation here as well
// leaves two `CGFloat`s in scope (Foundation's own and SwiftUI's stand-in) — nearly four hundred
// "ambiguous" errors, all from this one line. SwiftUI re-exports Foundation on Apple's platforms
// too, so nothing is lost either way.
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession

/// The sizes everything is drawn at, decided once from the space available.
///
/// This is what stops the phone version being a second copy of every screen: the layouts read
/// these numbers instead of hard-coding Mac-sized ones. Controls get *larger* in the small layout,
/// not smaller — a thumb needs more room than a pointer does.
public struct Metrics: Equatable, Sendable {
    public let width: CGFloat
    public let height: CGFloat
    /// Carried rather than looked up, so every size below is a plain function of the two things
    /// that decide it — the space available and the player's setting. That is what lets a test walk
    /// nine seats at four text sizes without a screen or a global to put back afterwards.
    public let textScale: TextScale

    public init(width: CGFloat, height: CGFloat, textScale: TextScale) {
        self.width = width
        self.height = height
        self.textScale = textScale
    }

    @MainActor public init(width: CGFloat, height: CGFloat) {
        self.init(width: width, height: height, textScale: Theme.textScale)
    }

    /// A phone, or a window squeezed down to phone width.
    public var compact: Bool { width < 700 }

    var gutter: CGFloat { compact ? 16 : 26 }
    /// A screen's title. This already carries the player's text-size setting, so scaling it a
    /// second time at the call site draws titles twice as far up as everything around them.
    public var titleSize: CGFloat { textScale.size(compact ? 22 : 26) }
    /// The app's own name on the home screen — the one piece of type set larger than a title.
    var bigTitleSize: CGFloat { textScale.size(compact ? 24 : 28) }
    /// How much the seat furniture — the name, the badge and the stack — actually grows by.
    ///
    /// Everything a person *reads* on the table screen carries the setting in full: the running
    /// commentary, the pot, the blinds, the buttons, the coach's review. The seat boxes are the one
    /// exception, because nine of them have to fit round the felt at once, and past a certain size
    /// they simply do not — the words get bigger and the table becomes unreadable, which is a poor
    /// trade for the person who asked for bigger words.
    ///
    /// So the limit is worked out from the space available rather than fixed: a Mac window grows
    /// the whole way, a phone stops where it has to. `FeltRing.holds` is the same arithmetic the
    /// felt is drawn with, so this cannot drift away from what actually gets laid out.
    public var seatScale: CGFloat {
        var scale = textScale.multiplier
        while scale > Self.smallestSeat {
            if ringHolds(at: scale) { return scale }
            scale -= 0.02
        }
        return Self.smallestSeat
    }

    /// Below this the names under the cards stop being readable at all, so a window too small to
    /// seat nine goes on and lets them touch rather than shrinking into nothing. Nothing the app
    /// ships at needs it — it is the floor that stops a dragged-down window doing something absurd.
    private static let smallestSeat: CGFloat = 0.74

    private func ringHolds(at scale: CGFloat) -> Bool {
        FeltRing(size: feltArea,
                 seatWidth: (seatBase * scale).rounded(),
                 seatHeight: seatHeightBase(at: scale).rounded(),
                 compact: compact)
            .holds(TableSession.seatsAtTable)
    }

    private var seatBase: CGFloat { compact ? 86 : 140 }

    /// The space the ring of seats actually gets, once the header above, the player's own row and
    /// the buttons below have taken theirs. What the view lays out in is the real thing measured
    /// at draw time; this is the same area worked out in advance, so the size can be settled
    /// before anything is drawn.
    public var feltArea: CGSize {
        CGSize(width: width - (compact ? 8 : 40), height: height - chromeHeight - heroRowHeight)
    }

    /// Header, the action bar under the felt, and the padding around it — the space the ring never
    /// gets. Deliberately generous: a seat that only just fits is a seat touching the buttons.
    private var chromeHeight: CGFloat { compact ? 210 : 190 }

    /// An opponent's seat box: their two cards, then the name and the stack, then whatever they
    /// just did. Measured off the backs, which is what an opponent's seat actually holds.
    private func seatHeightBase(at scale: CGFloat) -> CGFloat {
        opponentCard + 5 + (compact ? 34 : 40) * scale + 22 * scale
    }

    /// Wide enough for a name, a seat badge and a stack — all three of which carry the player's
    /// text-size setting, so the box around them has to carry it too or the writing is clipped the
    /// moment it grows. The ring the seats sit on is measured off this, so the felt reflows to suit.
    public var seatWidth: CGFloat { (seatBase * seatScale).rounded() }

    /// A size for words that sit inside a seat box, so they grow by exactly as much as the box did.
    public func seatText(_ base: CGFloat) -> CGFloat { max((base * seatScale).rounded(), 7) }
    /// The five cards in the middle.
    ///
    /// Ken, 2026-08-22: "can we make the flop cards a little bigger?" They were 32 points tall on a
    /// phone, which is the smallest thing on the felt apart from the opponents' face-down cards —
    /// and the board is what everybody at the table reads and re-reads all hand.
    ///
    /// 40 rather than more, and the ceiling is geometry rather than taste: the board sits in the
    /// middle of the felt with nine seats on a ring around it, and a board much larger runs into
    /// the two at the sides. Matching the player's own cards at 46 already overlaps them on the
    /// smallest iPhone the app supports. `boardClearance` is that gap, and a test walks it across
    /// every screen at every text size — so the next request for bigger cards has an answer rather
    /// than needing this worked out again.
    public var boardCard: CGFloat { compact ? 40 : 58 }

    /// Spacing between the board cards, which the width below has to account for.
    public static let boardGap: CGFloat = 7

    /// How wide the five board cards are, laid out together.
    public var boardWidth: CGFloat {
        5 * (boardCard * 0.72) + 4 * Metrics.boardGap
    }

    /// The gap between the edge of the board and the nearest edge of the seat boxes at the sides.
    ///
    /// Negative means they overlap. The seats furthest out horizontally sit at `radiusX` from the
    /// middle, and half a seat box reaches back in towards the board from there.
    public var boardClearance: CGFloat {
        let ring = FeltRing(size: feltArea, seatWidth: seatWidth,
                            seatHeight: ringSeatHeight, compact: compact)
        return ring.radiusX - seatWidth / 2 - boardWidth / 2
    }

    /// The board and the pot under it, as one block centred on the felt: the cards, a 12-point
    /// gap and the pot capsule, drawn six points above the middle. Where its bottom edge lands
    /// is what decides that the player's row underneath never touches it.
    public var boardBlockHeight: CGFloat { boardCard + 12 + 26 }
    public var boardBottom: CGFloat { feltArea.height / 2 - 6 + boardBlockHeight / 2 }

    // MARK: The player's cards

    /// A card's width as a share of its height. `CardView` draws every card at this shape, and
    /// every row of cards below is measured with it.
    public static let cardAspect: CGFloat = 0.72
    /// The gap between two cards laid side by side.
    public static let cardGap: CGFloat = 3

    /// The player's own cards, in a row of their own under the felt.
    ///
    /// Ken, 2026-09-10: "I like the way the cards are presented in the PLO app. Can we have holdem
    /// match it?" PLO Tutor took the player off the ring of seats because four whole cards do not
    /// fit a seat box on a phone; the same row suits two cards just as well, and the two apps now
    /// look alike. The player's cards sit between the felt and the buttons, side by side with a
    /// gap, with the player's name and stack beside them, and the ring holds the eight opponents.
    /// The row costs the felt 58 points of height on a phone; the ring is still 303 points tall
    /// there, and `boardClearance` — which is horizontal — does not move.
    ///
    /// 46 tall on a phone, as before: two of them are 69 points across on a 375-point screen,
    /// with 298 to spare for the name, the stack and the bet. Every number here is checked by a
    /// test at every screen size and text setting.
    public var heroCard: CGFloat { compact ? 46 : 50 }
    /// Two cards, this being hold'em. The row below is measured off it rather than off a literal.
    public static let heroCardCount = 2
    public var heroCardCount: Int { Metrics.heroCardCount }
    public var heroCardWidth: CGFloat { heroCard * Metrics.cardAspect }

    /// How wide the player's cards are, side by side with `cardGap` between them.
    public var heroHandWidth: CGFloat { cardRowWidth(count: heroCardCount, height: heroCard) }

    /// Where each of the player's cards sits within the row, left to right. The test that says the
    /// cards never overlap reads these rather than trusting the words above.
    public var heroCardFrames: [CGRect] {
        (0..<heroCardCount).map { index in
            CGRect(x: CGFloat(index) * (heroCardWidth + Metrics.cardGap), y: 0,
                   width: heroCardWidth, height: heroCard)
        }
    }

    /// How wide a row of `count` cards is at a given card height, laid side by side.
    public func cardRowWidth(count: Int, height: CGFloat) -> CGFloat {
        CGFloat(count) * height * Metrics.cardAspect + CGFloat(max(0, count - 1)) * Metrics.cardGap
    }

    /// The gap between the cards and the box beside them, and between the box and the bet.
    public var heroRowGap: CGFloat { 10 }
    /// The room kept beside the player's box for what they just did — "Raise 12000" wants about
    /// 95 points at the largest setting, and unlike an opponent's badge this one has nowhere to
    /// hang over.
    public var heroActionWidth: CGFloat { max(60, seatText(22) * 4.4).rounded() }
    /// The whole row: cards, box, bet. Has to fit the screen, and a test says it does.
    public var heroRowWidth: CGFloat {
        heroHandWidth + heroRowGap + seatWidth + heroRowGap + heroActionWidth
    }
    /// The row's height: the cards, with a little felt above and below them.
    public var heroRowHeight: CGFloat { heroCard + (compact ? 12 : 14) }

    /// The lowest point any opponent's seat box reaches on the felt, `count` handed. The player's
    /// row begins at the bottom of the felt, so this has to stay above it.
    public func lowestSeatBottom(of count: Int) -> CGFloat {
        let ring = FeltRing(size: feltArea, seatWidth: seatWidth,
                            seatHeight: ringSeatHeight, compact: compact)
        return ring.points(of: count).dropFirst().map { $0.y + ringSeatHeight / 2 }.max() ?? 0
    }

    // MARK: The button and the blinds

    /// Ken, 2026-09-11: "lets make the dealer have a button in front of their seat. It is hard to
    /// see who has the button at any given time. Maybe also have some indicator on the table as to
    /// who is the small and big blinds." The badge by each name still says "9 BTN", but a badge is
    /// read; a disc on the felt is seen. So the button seat gets a white disc marked D on the felt
    /// in front of it, and the two blinds get smaller dark chips marked SB and BB, the way a real
    /// table shows them, and all three move round with the button every hand.
    ///
    /// Where they sit is worked out from the same ring the seats are on: from the seat's centre,
    /// straight towards the middle of the felt, to just past the edge of the seat box, plus
    /// `markerInset` of felt, plus half the disc. The player has no seat on the ring — their row
    /// is under the felt — so their marker sits at the empty bottom point of the ring instead,
    /// which on a 375-point phone is 46 points above the felt's edge and 34 points clear of the
    /// two corner seats (their boxes reach in to 45 points from the middle; a 22-point disc reaches
    /// out to 11). Putting it beside the player's box in the row was the other option, and it lost:
    /// the row is where the cards are read, and a marker there is one more thing beside them.
    /// Heads-up the button posts the small blind, so that seat shows D and SB side by side.
    /// Every marker is checked by a test at every screen size and text setting: inside the felt,
    /// at least four points off its seat box, and never over the board.
    public var dealerDisc: CGFloat { compact ? 22 : 26 }
    public var blindMarker: CGFloat { compact ? 16 : 19 }
    public var markerInset: CGFloat { 6 }
    /// The least felt a marker keeps from anybody else's box and from the board.
    public var markerClearance: CGFloat { 4 }
    public var markerGap: CGFloat { 3 }

    /// The centre of the marker for the seat `seatsFromHero` round the ring (0 is the player),
    /// `count` handed, on a felt of `size` (the area the view actually got, or `feltArea` when
    /// working it out in advance).
    ///
    /// First choice is straight in front of the seat, towards the middle of the felt. Where that
    /// lands on the board — the seats beside the board on a narrow phone have only about 24
    /// points of felt between their box and the cards, and a disc with its inset needs 34 — the
    /// marker goes just under the seat's box instead (for a seat in the top half of the felt) or
    /// just over it (bottom half), then the other of those, then beside it on its outer side (the
    /// seat at the very top of a short Mac window has the board under it and the edge over it).
    /// Whichever
    /// place is taken has to be on the felt, off every seat's box and off the board; the tests
    /// walk all of them at every screen and text size.
    public func markerCentre(seatsFromHero: Int, of count: Int, marks: Int = 1, in size: CGSize? = nil) -> CGPoint {
        let area = size ?? feltArea
        let ring = FeltRing(size: area, seatWidth: seatWidth, seatHeight: ringSeatHeight, compact: compact)
        let point = ring.point(seatsFromHero: seatsFromHero, of: count)
        guard seatsFromHero != 0 else { return point }       // nothing is drawn at the player's point

        let half = dealerDisc / 2
        // D and SB side by side, heads-up: the run is wider than one disc.
        let halfWidth = (dealerDisc + CGFloat(max(0, marks - 1)) * (blindMarker + markerGap)) / 2
        let boxes = (1..<max(count, 1)).map { seat -> CGRect in
            let p = ring.point(seatsFromHero: seat, of: count)
            return CGRect(x: p.x - seatWidth / 2, y: p.y - ringSeatHeight / 2, width: seatWidth, height: ringSeatHeight)
        }
        let ownBox = boxes[seatsFromHero - 1]
        let board = boardBlock(in: area).insetBy(dx: -markerClearance, dy: -markerClearance)
        func clear(_ centre: CGPoint) -> Bool {
            let disc = CGRect(x: centre.x - halfWidth, y: centre.y - half, width: halfWidth * 2, height: dealerDisc)
            guard disc.minX >= 0, disc.minY >= 0, disc.maxX <= area.width, disc.maxY <= area.height else { return false }
            if disc.intersects(board) { return false }
            return !boxes.contains { disc.intersects($0.insetBy(dx: -markerClearance + 0.5, dy: -markerClearance + 0.5)) }
        }

        // Straight towards the middle, clearing the seat's own edge by the inset measured across
        // that edge rather than along the slant — a seat on the diagonal otherwise has the disc's
        // corner touching its box.
        let dx = area.width / 2 - point.x, dy = area.height / 2 - point.y
        let length = max(1, (dx * dx + dy * dy).squareRoot())
        let ux = dx / length, uy = dy / length
        let toSide = abs(ux) < 0.0001 ? CGFloat.infinity : (seatWidth / 2) / abs(ux)
        let toTop = abs(uy) < 0.0001 ? CGFloat.infinity : (ringSeatHeight / 2) / abs(uy)
        let across = toSide < toTop ? abs(ux) : abs(uy)
        let travel = min(toSide, toTop) + (markerInset + (toSide < toTop ? halfWidth : half)) / across
        let ahead = CGPoint(x: point.x + ux * travel, y: point.y + uy * travel)

        let under = CGPoint(x: point.x, y: ownBox.maxY + markerInset + half)
        let over = CGPoint(x: point.x, y: ownBox.minY - markerInset - half)
        // Beside the box, on its outer side, for the seat at the very top of a short felt whose
        // box has the board right under it and the felt's edge right over it.
        let outward: CGFloat = point.x >= area.width / 2 ? 1 : -1
        let beside = CGPoint(x: outward > 0 ? ownBox.maxX + markerInset + halfWidth : ownBox.minX - markerInset - halfWidth,
                             y: point.y)
        let topHalf = point.y < area.height / 2
        let candidates = [ahead] + (topHalf ? [under, over, beside] : [over, under, beside])
        return candidates.first(where: clear) ?? ahead
    }

    /// The box a run of markers occupies, centred on `markerCentre`: one disc, or D and SB side
    /// by side heads-up.
    public func markerFrame(seatsFromHero: Int, of count: Int, marks: Int = 1, in size: CGSize? = nil) -> CGRect {
        let centre = markerCentre(seatsFromHero: seatsFromHero, of: count, marks: marks, in: size)
        let width = dealerDisc + CGFloat(max(0, marks - 1)) * (blindMarker + markerGap)
        return CGRect(x: centre.x - width / 2, y: centre.y - dealerDisc / 2, width: width, height: dealerDisc)
    }

    /// An opponent's seat box on the felt, for the tests that keep the markers off it.
    public func seatBox(seatsFromHero: Int, of count: Int) -> CGRect {
        let ring = FeltRing(size: feltArea, seatWidth: seatWidth, seatHeight: ringSeatHeight, compact: compact)
        let point = ring.point(seatsFromHero: seatsFromHero, of: count)
        return CGRect(x: point.x - seatWidth / 2, y: point.y - ringSeatHeight / 2,
                      width: seatWidth, height: ringSeatHeight)
    }

    /// The board and the pot as one block on the felt, for the same tests.
    public var boardBlock: CGRect { boardBlock(in: feltArea) }

    public func boardBlock(in area: CGSize) -> CGRect {
        CGRect(x: area.width / 2 - boardWidth / 2, y: area.height / 2 - 6 - boardBlockHeight / 2,
               width: boardWidth, height: boardBlockHeight)
    }

    /// An opponent's cards: backs while they play, faces if they show.
    public var opponentCard: CGFloat { compact ? 19 : 34 }
    var faceDownCard: CGFloat { opponentCard }
    /// Cards an opponent turned over are held a little larger than the backs, since these are
    /// meant to be read.
    public var opponentRevealCard: CGFloat { opponentCard * 1.12 }
    /// How tall a seat box on the ring is: cards, then name and stack, then whatever they just
    /// did. The ring keeps this much room at the top and the bottom or a chip count goes off the
    /// edge of the felt — which is exactly what happened on a phone. The name and stack grow with
    /// the text-size setting, so the room kept for them has to grow by the same amount.
    public var ringSeatHeight: CGFloat { seatHeightBase(at: seatScale).rounded() }

    // MARK: The by-seat table on My game

    /// The three number columns of the by-seat table, and the gap between columns.
    ///
    /// Capped, so they grow with the player's setting up to Large and no further — past that they
    /// eat the seat name whole. A number that has lost its last digit is worse than useless: it
    /// still looks like a number.
    /// Sized to what they actually hold — "23", "45%", "+12.4 bb" — rather than to a round number.
    /// The old widths were generous by about a third, which is where the row ran out of screen.
    public var statHands: CGFloat { textScale.fittedSize(44) }
    public var statPlayed: CGFloat { textScale.fittedSize(50) }
    public var statPerHundred: CGFloat { textScale.fittedSize(64) }
    public var statGap: CGFloat { textScale.fittedSize(4) }

    /// Everything in a by-seat row that cannot shrink.
    public var statFixedColumns: CGFloat {
        statHands + statPlayed + statPerHundred + statGap * 3
    }

    /// The width a panel's contents actually get: the screen, less the margins either side of it,
    /// less the padding inside the panel.
    public var panelContentWidth: CGFloat { width - gutter * 2 - 36 }

    /// What is left for the seat's own name once the numbers have taken theirs.
    ///
    /// Ken, 2026-08-23: "on the my game screen, the text scrolls too far over to the right and cant
    /// be fully read." Every column had a width of its own, multiplied by the text-size setting,
    /// and four of them together outgrew the screen the moment the type went up — which is exactly
    /// what somebody who finds a table hard to read does first. The seat column takes what is left
    /// now, and this is the arithmetic that says what is left is enough to read.
    public var statNameWidth: CGFloat { panelContentWidth - statFixedColumns }

    /// Apple's guidance is a 44pt minimum touch target; the buttons here clear it.
    var controlHeight: CGFloat { compact ? 46 : 34 }
    var controlWidth: CGFloat { compact ? 72 : 92 }
}

private struct MetricsKey: EnvironmentKey {
    /// Only ever the value before the first layout pass hands the real one down, so the setting it
    /// is built with does not matter — but it has to be spelled out, since reading the player's
    /// choice here would tie a plain default to the main actor.
    static let defaultValue = Metrics(width: 1240, height: 820, textScale: .normal)
}

extension EnvironmentValues {
    var metrics: Metrics {
        get { self[MetricsKey.self] }
        set { self[MetricsKey.self] = newValue }
    }
}

/// How many colours the deck is printed in.
///
/// Four by default. The traditional two are kept because plenty of people have read cards that way
/// for fifty years and a repainted deck reads as wrong to them — but the default is the one that
/// answers the complaint, rather than a setting somebody has to go and find.
public enum CardColours: String, CaseIterable, Sendable, Identifiable {
    case four
    case two

    public var id: String { rawValue }

    public var name: String {
        switch self {
        case .four: return "A colour per suit"
        case .two: return "Traditional red and black"
        }
    }

    public var detail: String {
        switch self {
        case .four: return "Clubs and spades stop looking alike at a glance."
        case .two: return "The deck you grew up with. Clubs and spades are both black."
        }
    }

    public static var storageKey: String { AppVariant.current.key("cardColours") }

    // `Foundation.UserDefaults`, spelled out: on Android, Skip's build plugin shadows the bare
    // name with a module-private alias, and a public signature cannot mention a private type.
    /// Anything unrecognised falls back to four rather than refusing to draw a card.
    public static func stored(in defaults: Foundation.UserDefaults) -> CardColours {
        guard let raw = defaults.string(forKey: storageKey),
              let value = CardColours(rawValue: raw) else { return .four }
        return value
    }

    public static func store(_ value: CardColours, in defaults: Foundation.UserDefaults) {
        defaults.set(value.rawValue, forKey: storageKey)
    }

    /// The same, in the player's own settings. Internal so the default can be the module's
    /// `UserDefaults.standard` — which on Android is Skip's, backed by the device's settings store.
    static func stored() -> CardColours { stored(in: UserDefaults.standard) }
    static func store(_ value: CardColours) { store(value, in: UserDefaults.standard) }
}

/// Which set of colours the app is wearing.
///
/// The default scheme says good in green and bad in red, which is the one pairing eight percent of
/// men cannot tell apart. The alternative swaps that axis for blue against orange — distinguishable
/// under every common form of colour blindness, and still obvious to everyone else.
public enum Palette: String, CaseIterable, Sendable, Identifiable {
    case classic
    case colourBlind

    public var id: String { rawValue }

    public var name: String {
        switch self {
        case .classic: return "Classic"
        case .colourBlind: return "Colour-blind friendly"
        }
    }

    /// Good news. Green normally; a light blue when green would be indistinguishable from red.
    var accent: Color {
        self == .colourBlind ? Color(red: 0.353, green: 0.678, blue: 0.937)
                             : Color(red: 0.373, green: 0.788, blue: 0.545)
    }

    /// Worth your attention but not an error.
    var gold: Color {
        self == .colourBlind ? Color(red: 0.933, green: 0.831, blue: 0.427)
                             : Color(red: 0.855, green: 0.706, blue: 0.373)
    }

    /// A mistake, or money lost. Red normally; a strong orange otherwise — orange against blue
    /// survives every common form of colour blindness.
    var warn: Color {
        self == .colourBlind ? Color(red: 0.902, green: 0.549, blue: 0.196)
                             : Color(red: 0.898, green: 0.478, blue: 0.376)
    }

    /// The player themselves, as distinct from everyone else at the table.
    var hero: Color {
        self == .colourBlind ? Color(red: 0.749, green: 0.729, blue: 0.980)
                             : Color(red: 0.396, green: 0.643, blue: 0.937)
    }

    /// What a suit is printed in.
    ///
    /// Ken, 2026-08-22: "hard to see the difference between the black suit." Clubs and spades are
    /// the same colour in a traditional deck and very nearly the same shape at the size a phone
    /// draws them, so the only thing separating them is a small notch at the bottom of the pip.
    /// Giving every suit its own colour is what online poker settled on for exactly this reason,
    /// and it is the setting most players who read a lot of cards turn on first.
    ///
    /// Spades stay black. That matters: the point is to move the THREE that were sharing two
    /// colours, not to repaint the deck into something unrecognisable.
    func suitColour(_ suit: Suit, fourColour: Bool) -> Color {
        let c = suitComponents(suit, fourColour: fourColour)
        return Color(red: c.red, green: c.green, blue: c.blue)
    }

    /// The same colours as plain numbers.
    ///
    /// SwiftUI's `Color` cannot be taken apart or compared, which would leave the one thing that
    /// actually matters here untestable: that no two suits come out looking alike. The numbers are
    /// the source and the `Color` is built from them, so there is no second copy to drift.
    public func suitComponents(_ suit: Suit, fourColour: Bool) -> Components {
        guard fourColour else {
            switch suit {
            case .hearts, .diamonds: return Palette.traditionalRed
            case .clubs, .spades: return Palette.traditionalBlack
            }
        }
        switch suit {
        case .spades: return Palette.traditionalBlack
        case .hearts: return self == .colourBlind
            // Vermillion. Reads as red to anyone who can see red and as a dark warm tone to
            // anyone who cannot, which is the point.
            ? Components(red: 0.835, green: 0.369, blue: 0.000)
            : Palette.traditionalRed
        case .diamonds: return self == .colourBlind
            ? Components(red: 0.000, green: 0.447, blue: 0.698)
            // Orange, not blue. Ken, 2026-08-22: "Diamond color looked too much like a club."
            // Blue and green are both dark and both cool, and they were being read on a green
            // felt, which is the worst possible backdrop for telling them apart. Orange is the
            // furthest thing on the wheel from the green that clubs keep.
            //
            // The cost is that hearts and diamonds are neighbours again, which is what the
            // traditional deck already does — so the pair that shares a family is the pair players
            // have always had share one, rather than the two the eye has no practice separating.
            : Components(red: 0.914, green: 0.529, blue: 0.043)
        case .clubs: return self == .colourBlind
            // Reddish purple, not green. Green is the one colour a red-green blind reader cannot
            // separate from hearts, and clubs against hearts is the pair this setting exists to
            // fix. Darker than the usual reddish purple so it still reads on a cream card.
            //
            // These four are taken from the Okabe-Ito set, which was chosen for exactly this
            // problem — four marks that stay apart under every common form of colour blindness —
            // rather than picked by eye and hoped over.
            ? Components(red: 0.686, green: 0.278, blue: 0.545)
            : Components(red: 0.075, green: 0.475, blue: 0.271)
        }
    }

    public struct Components: Equatable, Sendable {
        public let red: Double, green: Double, blue: Double
        public init(red: Double, green: Double, blue: Double) {
            self.red = red; self.green = green; self.blue = blue
        }
    }

    static let traditionalRed = Components(red: 0.827, green: 0.239, blue: 0.271)
    static let traditionalBlack = Components(red: 0.106, green: 0.129, blue: 0.157)

    /// The four colours that carry meaning, so a scheme can be shown without being switched on.
    /// `Theme.palette` is written by exactly one thing — `Theme.use` — and a row of swatches is
    /// not it: repainting the whole app mid-draw to sample four colours worked only by accident.
    func colours() -> [Color] { [accent, gold, warn, hero] }

    public var detail: String {
        switch self {
        case .classic: return "Green for good, red for trouble."
        case .colourBlind: return "Blue for good, orange for trouble — no red-green pairs anywhere."
        }
    }
}

/// How large the app draws its type.
///
/// The coach's write-ups are the reason the app exists and some of them run to a paragraph, so
/// these steps move the reading sizes properly rather than by a point. Words packed into something
/// a fixed size — a seat box, a chip, a button under the table — stop growing at Large instead:
/// past that the felt stops fitting on the screen. That cap is `fittedSize`, and it is the only
/// place the two behaviours differ.
///
/// Deliberately a value type with the multiplying and the remembering both on it, so a test can
/// check what a fresh launch will read back without going anywhere near a screen.
public enum TextScale: String, CaseIterable, Sendable, Identifiable {
    case small
    case normal
    case large
    case extraLarge

    public var id: String { rawValue }

    public var name: String {
        switch self {
        case .small: return "Small"
        case .normal: return "Normal"
        case .large: return "Large"
        case .extraLarge: return "Extra large"
        }
    }

    public var detail: String {
        switch self {
        case .small: return "More on the screen at once."
        case .normal: return "The size everything was drawn at to begin with."
        case .large: return "Easier going, especially on the coach's write-ups."
        case .extraLarge: return "As big as it gets. The felt keeps its shape; the writing grows."
        }
    }

    /// The coach's own writing — the reviews, the verdicts, the briefs and the reports — is set a
    /// step above the app's ordinary text, at every one of the four settings. It is read rather
    /// than glanced at, and it is the reason the app exists.
    ///
    /// Applied to the BASE size, before the player's setting multiplies it, so the step up holds
    /// at Small and at Extra large rather than washing out at the ends.
    public static func coachBase(_ base: CGFloat) -> CGFloat { (base * 1.16).rounded() }

    /// The walk-through, which gets the same step up as the coach's writing and for a plainer
    /// reason: it is the one screen in the app somebody reads BEFORE they could possibly have
    /// found the text-size setting. Everything else can be turned up by a player who finds it hard
    /// going. The introduction has to be legible at whatever size it arrives at, first launch, on a
    /// phone held at arm's length rather than a Mac at desk distance.
    ///
    /// Ken, 2026-08-22, reading it on an iPhone for the first time: "Intro fonts are too small on
    /// the phone. By a bit."
    public static func introBase(_ base: CGFloat) -> CGFloat { coachBase(base) }

    /// What every type size in the app is multiplied by.
    public var multiplier: CGFloat {
        switch self {
        case .small: return 0.88
        case .normal: return 1
        case .large: return 1.18
        case .extraLarge: return 1.38
        }
    }

    /// A type size at this setting. Rounded to whole points: half a point of type sits visibly off
    /// the baseline from the type beside it that landed on one.
    public func size(_ base: CGFloat) -> CGFloat { (base * multiplier).rounded() }

    /// The same, for words that have to stay inside something of a fixed size. It still grows —
    /// just no further than Large does.
    public func fittedSize(_ base: CGFloat) -> CGFloat {
        (base * min(multiplier, TextScale.large.multiplier)).rounded()
    }

    // MARK: Remembering the choice

    static var storageKey: String { AppVariant.current.key("textSize") }

    /// What a fresh launch reads back. Anything unrecognised — an older version's value, or a hand
    /// edit — falls back to Normal rather than refusing to draw.
    public static func stored(in defaults: Foundation.UserDefaults) -> TextScale {
        guard let raw = defaults.string(forKey: storageKey),
              let scale = TextScale(rawValue: raw) else { return .normal }
        return scale
    }

    public static func store(_ scale: TextScale, in defaults: Foundation.UserDefaults) {
        defaults.set(scale.rawValue, forKey: storageKey)
    }

    static func stored() -> TextScale { stored(in: UserDefaults.standard) }
    static func store(_ scale: TextScale) { store(scale, in: UserDefaults.standard) }
}

/// Colours, type and the small shared pieces. Everything visual lives in this target; no poker
/// logic does, which is what keeps the iPhone version a layout job rather than a rewrite.
@MainActor
enum Theme {
    private static var paletteKey: String { AppVariant.current.key("palette") }

    /// Read by every colour below. Static rather than passed down the view tree because the
    /// alternative is threading a palette through several hundred call sites; the root view is
    /// keyed on this value, so changing it redraws everything.
    static var palette: Palette = {
        guard let raw = UserDefaults.standard.string(forKey: paletteKey),
              let stored = Palette(rawValue: raw) else { return .classic }
        return stored
    }()

    static func use(_ palette: Palette) {
        Theme.palette = palette
        UserDefaults.standard.set(palette.rawValue, forKey: paletteKey)
    }

    /// Read by every type size below, for the same reason the palette is: the sizes are handed out
    /// by free functions on `Text` that nothing can pass a value into. The root view is keyed on
    /// this, so changing it throws the screen away and draws it again at the new size.
    static var textScale: TextScale = TextScale.stored()

    static func use(_ scale: TextScale) {
        Theme.textScale = scale
        TextScale.store(scale)
    }

    /// Whether every suit gets its own colour. Read by `CardView` and by nothing else.
    static var cardColours: CardColours = CardColours.stored()

    static func use(_ colours: CardColours) {
        Theme.cardColours = colours
        CardColours.store(colours)
    }

    static func suitColour(_ suit: Suit) -> Color {
        palette.suitColour(suit, fourColour: cardColours == .four)
    }

    /// The size a piece of type is actually drawn at. `capped` is for words with a fixed-size box
    /// around them — see `TextScale.fittedSize`.
    static func coachBase(_ base: CGFloat) -> CGFloat { TextScale.coachBase(base) }
    static func introBase(_ base: CGFloat) -> CGFloat { TextScale.introBase(base) }

    static func size(_ base: CGFloat, capped: Bool = false) -> CGFloat {
        capped ? textScale.fittedSize(base) : textScale.size(base)
    }

    private static var colourBlind: Bool { palette == .colourBlind }

    static let background = Color(red: 0.055, green: 0.078, blue: 0.070)
    static let panel = Color(red: 0.094, green: 0.125, blue: 0.114)
    static let panelRaised = Color(red: 0.129, green: 0.169, blue: 0.153)
    static let feltOuter = Color(red: 0.055, green: 0.207, blue: 0.129)
    static let feltInner = Color(red: 0.086, green: 0.325, blue: 0.203)
    static let rail = Color(red: 0.180, green: 0.114, blue: 0.078)
    static let text = Color(red: 0.902, green: 0.925, blue: 0.914)
    static let dim = Color(red: 0.545, green: 0.596, blue: 0.573)

    /// The four colours that carry meaning all belong to the scheme itself, so one can be sampled
    /// without the app being switched over to it. See `Palette.colours()`.
    static var accent: Color { palette.accent }
    static var gold: Color { palette.gold }
    static var warn: Color { palette.warn }
    static var hero: Color { palette.hero }

    /// Chip colours follow the casino convention closely enough to be recognisable at a glance,
    /// which is the entire reason for using chips rather than a number field. Every chip also
    /// carries its value in writing, so the colour is a shortcut rather than the only signal.
    static func chipFill(_ chip: Chip) -> Color {
        switch chip {
        case .twentyFive:
            return colourBlind ? Color(red: 0.235, green: 0.522, blue: 0.776)
                               : Color(red: 0.180, green: 0.545, blue: 0.341)
        case .fifty:
            return colourBlind ? Color(red: 0.451, green: 0.451, blue: 0.478)
                               : Color(red: 0.204, green: 0.396, blue: 0.702)
        case .hundred:      return Color(red: 0.129, green: 0.145, blue: 0.169)
        case .fiveHundred:  return Color(red: 0.443, green: 0.286, blue: 0.639)
        case .thousand:     return Color(red: 0.867, green: 0.706, blue: 0.259)
        case .fiveThousand:
            return colourBlind ? Color(red: 0.831, green: 0.478, blue: 0.114)
                               : Color(red: 0.729, green: 0.216, blue: 0.235)
        case .tenThousand:  return Color(red: 0.514, green: 0.549, blue: 0.588)
        }
    }

    static func chipText(_ chip: Chip) -> Color {
        switch chip {
        case .thousand, .tenThousand: return Color(red: 0.09, green: 0.10, blue: 0.11)
        default: return .white
        }
    }

    static func severityColour(_ severity: Severity) -> Color {
        switch severity {
        case .fine, .slight: return dim
        case .minor: return gold
        case .moderate: return colourBlind ? Color(red: 0.945, green: 0.667, blue: 0.310)
                                           : Color(red: 0.933, green: 0.612, blue: 0.353)
        case .major: return warn
        }
    }
}

/// The roles a piece of type can play, and the size each is drawn at before the player's setting
/// multiplies it.
///
/// Ken, 2026-09-12, on My game: "The font on my game seem very small. Let's make them bigger and
/// make sure that all of the text is consistent in terms of how font size and use of the font size
/// selector." Before this every screen picked its own numbers — 9-point column headings, 10-point
/// footnotes, 11-point captions, 12-point body — and the setting multiplied whatever it was handed,
/// so a screen drawn small stayed small at every setting. Now a view says what a piece of text IS
/// and the size follows from that: the same footnote is the same size on every screen, and a test
/// (`TextSizeTests`, "every size of type has a role") fails on any view that reaches for a number
/// below its role's floor.
///
/// Sizes are for a phone held at arm's length; the Mac gets the same and has the room.
public enum TextRole: String, CaseIterable, Sendable {
    /// Dim footnotes, column headings, the small print under a number. Never below 12.
    case caption
    /// Ordinary reading text: card copy, rows, notes. Never below 14.
    case body
    /// Body with a little weight, for a line that has to stand out from the body around it.
    case bodyStrong
    /// A panel or row heading.
    case heading
    /// A screen's title.
    case title
    /// A number read as a number: stacks, rates, counts, digits aligned.
    case numeric
    /// A number sitting inside a caption or a table column, where the room is what it is.
    case numericSmall
    /// Words inside something of a fixed size — a seat box, the tab bar, the felt's badges. Always
    /// capped (see `TextScale.fittedSize`), so the felt keeps its shape.
    case felt

    /// The size at the Normal setting, in points.
    public var base: CGFloat {
        switch self {
        case .caption: return 12
        case .body, .bodyStrong, .numeric: return 14
        case .heading: return 16
        case .title: return 20
        case .numericSmall: return 13
        case .felt: return 11
        }
    }

    public var weight: Font.Weight {
        switch self {
        case .caption, .body: return .regular
        case .bodyStrong, .felt: return .medium
        case .heading, .title, .numeric, .numericSmall: return .semibold
        }
    }

    /// Whether the role stops growing at Large — only the felt does.
    public var isCapped: Bool { self == .felt }
}

/// Every size of type in the app comes through one of these, which is what makes the text-size
/// setting a five-line change rather than a hundred of them — and what makes "bigger" one number.
/// A role's base is the default; a view may ask for MORE (a display number at 46, a title at 22)
/// and never less. Pass `capped: true` when the words sit in something that cannot grow with
/// them — a seat box, a chip, a button under the felt.
@MainActor
extension Text {
    func role(_ role: TextRole, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(role.base, capped: capped || role.isCapped),
                     weight: role.weight, design: .rounded))
    }
    func caption(weight: Font.Weight = .regular, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(TextRole.caption.base, capped: capped), weight: weight, design: .rounded))
    }
    func title(_ size: CGFloat = TextRole.title.base, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(max(size, TextRole.title.base), capped: capped), weight: .semibold, design: .rounded))
    }
    func heading(_ size: CGFloat = TextRole.heading.base, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(max(size, TextRole.heading.base), capped: capped), weight: .semibold, design: .rounded))
    }
    func body(_ size: CGFloat = TextRole.body.base, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(max(size, TextRole.body.base), capped: capped), weight: .regular, design: .rounded))
    }
    /// Felt-only: words inside a fixed box. Anything else that wants small medium type is a caption.
    func label(_ size: CGFloat = TextRole.felt.base, capped: Bool = true) -> Text {
        font(.system(size: Theme.size(size, capped: capped), weight: .medium, design: .rounded))
    }
    func numeric(_ size: CGFloat = TextRole.numeric.base, weight: Font.Weight = .semibold, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(max(size, TextRole.numeric.base), capped: capped), weight: weight, design: .rounded)).digitsAligned()
    }
    func numericSmall(weight: Font.Weight = .semibold, capped: Bool = false) -> Text {
        font(.system(size: Theme.size(TextRole.numericSmall.base, capped: capped), weight: weight, design: .rounded)).digitsAligned()
    }
}

/// A playing card.
struct CardView: View {
    let card: Card?
    var size: CGFloat = 46
    var faceDown = false
    /// Handed a card when one is touched, where the caller has somewhere to show it bigger.
    ///
    /// A closure rather than reaching for `AppState`: this view is drawn inside sheets, replays and
    /// seat rings as well as on the felt, and most of those have nowhere to put an enlarged card.
    /// The ones that do say so.
    var onTap: ((Card) -> Void)? = nil

    private var suitColour: Color {
        guard let card else { return .black }
        return Theme.suitColour(card.suit)
    }

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: size * 0.13, style: .continuous)
                .fill(faceDown || card == nil
                      ? AnyShapeStyle(LinearGradient(colors: [Color(red: 0.204, green: 0.263, blue: 0.396),
                                                              Color(red: 0.129, green: 0.176, blue: 0.290)],
                                                     startPoint: .topLeading, endPoint: .bottomTrailing))
                      : AnyShapeStyle(Color(red: 0.976, green: 0.973, blue: 0.961)))
                .overlay(
                    RoundedRectangle(cornerRadius: size * 0.13, style: .continuous)
                        .stroke(Color.black.opacity(0.35), lineWidth: 0.6)
                )
                .shadow(color: .black.opacity(0.35), radius: 2, y: 1)

            if faceDown || card == nil {
                RoundedRectangle(cornerRadius: size * 0.09, style: .continuous)
                    .stroke(Color.white.opacity(0.16), lineWidth: 1)
                    .padding(size * 0.13)
            } else if let card {
                // Deliberately not scaled with the text-size setting: the rank and the pip are
                // fractions of the card they are printed on, and a bigger rank on the same card is
                // a rank hanging over the edge of it.
                VStack(spacing: -size * 0.06) {
                    Text(String(card.rank.letter))
                        .font(.system(size: size * 0.46, weight: .bold, design: .rounded))
                    Text(card.suit.symbol)
                        .font(.system(size: size * 0.38, weight: .semibold))
                }
                .foregroundStyle(suitColour)
            }
        }
        .frame(width: size * 0.72, height: size)
        .contentShape(Rectangle())
        .onTapGesture {
            // Face-down cards have nothing to show. Enlarging the back of a card would be a
            // gesture that appears to do something and does not.
            if let card, !faceDown, let onTap { onTap(card) }
        }
    }
}

/// One card, held up large enough to read without leaning in.
///
/// Ken, 2026-08-22: "lets make it so that if a user touches a card, it gets bigger so they can see
/// it clearly." Drawn over the felt rather than by scaling the card where it sits — a card in the
/// seat ring is inside a frame that would clip it, and a card growing in place shoves the board
/// along. This has the screen to itself and goes away on the next touch.
struct MagnifiedCard: View {
    let card: Card
    let dismiss: () -> Void

    @Environment(\.metrics) var metrics

    var body: some View {
        ZStack {
            Color.black.opacity(0.55)
                .ignoresSafeArea()
                .contentShape(Rectangle())
                .onTapGesture(perform: dismiss)
            VStack(spacing: 14) {
                CardView(card: card, size: metrics.compact ? 220 : 260)
                Text(Copy.tapToPutItBack)
                    .body().foregroundStyle(Theme.text.opacity(0.75))
            }
            .contentShape(Rectangle())
            .onTapGesture(perform: dismiss)
        }
        .transition(.opacity)
    }
}

/// The chips a player has in front of them this round, with what they just did if it is worth
/// saying — "Raise 600" reads as a raise; "600" on its own does not.
struct ChipsView: View {
    @Environment(\.metrics) var metrics
    let amount: Int
    var caption: String? = nil

    var body: some View {
        HStack(spacing: 4) {
            ZStack {
                Circle().fill(Theme.gold).frame(width: 11, height: 11)
                Circle().stroke(Color.black.opacity(0.35), lineWidth: 1).frame(width: 11, height: 11)
            }
            // A step above the name and stack around it. This is the one thing on a seat that
            // changes every few seconds and has to be read while it does — the rest can be looked
            // up at leisure.
            Text(caption ?? "\(amount)")
                .font(.system(size: metrics.seatText(12), weight: .semibold, design: .rounded))
                .digitsAligned()
                .foregroundStyle(Theme.text.opacity(0.9))
        }
        .padding(.horizontal, 7)
        .padding(.vertical, 3)
        .background(Capsule().fill(Color.black.opacity(0.38)))
    }
}

/// A titled block used on every screen that is not the table.
struct Panel<Content: View>: View {
    let title: String
    var subtitle: String? = nil
    @ViewBuilder let content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            VStack(alignment: .leading, spacing: 3) {
                Text(title).heading().foregroundStyle(Theme.text)
                if let subtitle {
                    Text(subtitle).body().foregroundStyle(Theme.dim)
                }
            }
            content
        }
        .padding(18)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Theme.panel))
        .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 1))
    }
}

/// A horizontal bar showing a rate against the level a competent player sits at.
struct RateBar: View {
    let rate: Double
    let acceptable: Double
    var height: CGFloat = 7
    /// Whether there is anything behind the rate. An unmeasured row draws the track and the mark
    /// and no fill: a rate of nought off no chances would draw the short green bar that means "this
    /// part of your game is working", which is a claim nobody has measured.
    var measured = true

    var body: some View {
        GeometryReader { geo in
            let width = geo.size.width
            ZStack(alignment: .leading) {
                Capsule().fill(Color.white.opacity(0.07))
                if measured {
                    Capsule()
                        .fill(rate > acceptable ? Theme.warn : Theme.accent)
                        .frame(width: max(3, width * min(1, rate)))
                }
                Rectangle()
                    .fill(Theme.text.opacity(0.55))
                    .frame(width: 1.5, height: height + 4)
                    .offset(x: width * min(1, acceptable))
            }
        }
        .frame(height: height)
    }
}

struct PillButton: View {
    let title: String
    var subtitle: String? = nil
    var tint: Color = Theme.accent
    /// The second button of a pair — "Read the report" beside "Start", "Close" beside "See my
    /// game". It used to be drawn as a dark pill with the *dark* label colour the bright pills use,
    /// which is the look of a button that cannot be pressed; Ken reported it as greyed out. A quiet
    /// button keeps the light text and gets an edge, so it reads as a button that simply shouts less.
    var quiet = false
    var wide = false
    var enabled = true
    let action: () -> Void

    @Environment(\.metrics) var metrics
    @State var hovering = false

    var body: some View {
        Button(action: action) {
            VStack(spacing: 2) {
                // Capped: these sit in a button of a fixed height, with the felt above them.
                Text(title).heading(capped: true)
                if let subtitle {
                    Text(subtitle).caption(capped: true)
                        .opacity(0.75)
                        .multilineTextAlignment(.center)
                }
            }
            .foregroundStyle(quiet ? Theme.text : Theme.background)
            .frame(maxWidth: wide ? .infinity : nil)
            .frame(minWidth: metrics.controlWidth,
                   minHeight: subtitle == nil ? metrics.controlHeight : metrics.controlHeight + 12)
            .padding(.horizontal, metrics.compact ? 12 : 16)
            .background(
                RoundedRectangle(cornerRadius: 9, style: .continuous)
                    .fill(quiet ? Theme.panelRaised.opacity(hovering ? 1.0 : 0.9)
                                : tint.opacity(hovering ? 1.0 : 0.88))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 9, style: .continuous)
                    .stroke(Theme.dim.opacity(quiet ? 0.55 : 0), lineWidth: 1)
            )
            .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
            .opacity(enabled ? 1 : 0.35)
        }
        .buttonStyle(.plain)
        .disabled(!enabled)
        .hoverHighlight { hovering = $0 && enabled }
    }
}

struct GhostButton: View {
    let title: String
    var tint: Color = Theme.text
    var wide = false
    /// Sizing chips carry two or three characters; giving them a full button's width is what
    /// pushes the Raise button off the edge of a narrow window.
    var narrow = false
    let action: () -> Void

    @Environment(\.metrics) var metrics
    @State var hovering = false

    var body: some View {
        Button(action: action) {
            Text(title).label(capped: true)
                .foregroundStyle(tint)
                .frame(maxWidth: wide ? .infinity : nil)
                .frame(minWidth: narrow ? 58 : metrics.controlWidth, minHeight: metrics.controlHeight)
                .padding(.horizontal, narrow ? 8 : (metrics.compact ? 12 : 16))
                .background(
                    RoundedRectangle(cornerRadius: 9, style: .continuous)
                        .fill(Color.white.opacity(hovering ? 0.13 : 0.07))
                )
                .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
        }
        .buttonStyle(.plain)
        .hoverHighlight { hovering = $0 }
    }
}

/// Whose app this is, said where somebody will actually see it.
///
/// Ken, 2026-08-02: "I dont see any branding on the app." The first attempt put it in a panel at the
/// bottom of Settings, which is branding somebody has to go looking for — so it was not branding.
/// This sits under the name on the home screen and under the wordmark in the sidebar, and it opens
/// the site.
///
/// The olive is The (Digital) Ad Lab's own, taken from its stylesheet rather than approximated, and
/// it is the one place in the app that colour appears — a brand mark should not be repainted by the
/// colour-blind palette, because then it is no longer the mark.
struct AdLabMark: View {
    var size: CGFloat = 11
    var showRule = true

    /// #8cb04a — the olive from thedigitaladlab.com, and the same one in the app icon.
    static let olive = Color(red: 0.549, green: 0.690, blue: 0.290)

    var body: some View {
        Link(destination: URL(string: Copy.adLabURL)!) {
            HStack(spacing: 7) {
                if showRule {
                    RoundedRectangle(cornerRadius: 1, style: .continuous)
                        .fill(AdLabMark.olive)
                        .frame(width: Theme.size(size) * 1.5, height: 2)
                }
                Text(Copy.madeByTitle)
                    .font(.system(size: Theme.size(size), weight: .semibold, design: .serif))
                    .foregroundStyle(AdLabMark.olive)
            }
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

// MARK: - Pointer-only niceties

extension View {
    /// Hover feedback where there is a pointer to hover with. A phone has none, and Skip's Android
    /// SwiftUI has no such modifier, so there it is a no-op rather than a build error.
    @ViewBuilder func hoverHighlight(_ perform: @escaping (Bool) -> Void) -> some View {
        #if os(Android)
        self
        #else
        self.onHover(perform: perform)
        #endif
    }

    /// A tooltip, on the platforms that draw them.
    @ViewBuilder func tooltip(_ text: String) -> some View {
        #if os(Android)
        self
        #else
        self.help(text)
        #endif
    }
}

#if os(Android)
extension View {
    /// Skip's SwiftUI has no `contentShape` yet. On Android every button is tappable across its
    /// whole frame already, so this leaves the view as it is rather than failing the build.
    func contentShape<S: Shape>(_ shape: S) -> some View { self }
}
#endif

// MARK: - Modifiers Skip's Android SwiftUI does not have yet
//
// Each of these is the real modifier on Apple's platforms and a no-op on Android, so a screen can
// ask for the nicety once and the build decides. When Skip grows the modifier, delete the shim.

extension View {
    /// `menuStyle(.borderlessButton)`: a menu drawn as its label alone.
    @ViewBuilder func plainMenuStyle() -> some View {
        #if os(Android)
        self
        #else
        self.menuStyle(.borderlessButton)
        #endif
    }

    /// `controlSize(.mini)`: the smallest switch.
    @ViewBuilder func miniControl() -> some View {
        #if os(Android)
        self
        #else
        self.controlSize(.mini)
        #endif
    }

    /// `accessibilityElement(children: .ignore)`: read this view out as one thing.
    @ViewBuilder func oneAccessibleElement() -> some View {
        #if os(Android)
        self
        #else
        self.accessibilityElement(children: .ignore)
        #endif
    }
}

extension Text {
    /// `monospacedDigit()`: figures that line up in columns. Android's rounded face is close enough
    /// until Skip offers the real thing.
    func digitsAligned() -> Text {
        #if os(Android)
        self
        #else
        self.monospacedDigit()
        #endif
    }
}

// MARK: - Symbols

/// An Apple symbol by name, drawn wherever the app runs.
///
/// Apple's platforms have the whole SF Symbols set. Skip's Android SwiftUI has a few dozen of
/// them, so the rest are shapes drawn by hand and carried in `Resources/Module.xcassets` as
/// white templates that take the current colour — or, where a close cousin exists in Skip's
/// set, that cousin. Use this everywhere rather than `Image(systemName:)`, or an icon that looks
/// fine on the Mac will draw as a warning triangle on a phone.
struct Symbol: View {
    let name: String
    var size: CGFloat = 14

    init(_ name: String, size: CGFloat = 14) {
        self.name = name
        self.size = size
    }

    var body: some View {
        #if os(Android)
        if let bundled = Symbol.bundled[name] {
            Image(bundled, bundle: .module)
                .renderingMode(.template)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: size * 1.25, height: size * 1.25)
        } else {
            Image(systemName: Symbol.cousin[name] ?? name).font(.system(size: size))
        }
        #else
        Image(systemName: name).font(.system(size: size))
        #endif
    }

    /// Symbols Skip cannot draw, and the bundled shape that stands in for each.
    static let bundled: [String: String] = [
        "suit.spade": "tab-table", "suit.spade.fill": "tab-table",
        "suit.club": "tab-club", "suit.club.fill": "tab-club",
        "rectangle.stack.fill": "glyph-cards",
        "target": "tab-practice",
        "chart.bar.doc.horizontal": "tab-leaks",
        "clock.arrow.circlepath": "tab-history",
        "chart.line.uptrend.xyaxis": "tab-ladder",
        "speaker.wave.2": "glyph-speaker", "speaker.slash": "glyph-speaker-off",
        "hourglass": "glyph-hourglass",
        "exclamationmark.circle": "glyph-alert",
        "xmark.circle.fill": "glyph-close",
        "line.3.horizontal.decrease.circle": "glyph-filter",
        "largecircle.fill.circle": "glyph-radio-on", "circle": "glyph-radio-off",
        "circle.grid.3x3.fill": "glyph-grid",
    ]

    /// Symbols Skip cannot draw that have a near enough relative it can.
    static let cousin: [String: String] = [
        "checkmark.seal": "checkmark.circle",
        "arrow.counterclockwise": "arrow.clockwise.circle",
        "person.2.fill": "person.fill",
    ]
}

// MARK: - The free tier's blur

/// A row a free player can see is there but cannot read.
///
/// The text stays in the tree at its real size — so the row keeps its height and the list keeps its
/// shape, which is the whole point: somebody can see there are other habits in their game — and is
/// redacted and blurred so the name and the numbers cannot be made out. The accessibility label
/// replaces the content, so a screen reader does not read the name aloud either. Tapping goes to
/// the price.
struct HiddenUntilSubscribed: ViewModifier {
    let hidden: Bool
    let open: () -> Void

    func body(content: Content) -> some View {
        if hidden {
            Button(action: open) {
                content
                    .redacted(reason: .placeholder)
                    .blur(radius: 5)
                    .overlay(alignment: .trailing) {
                        Symbol("lock.fill", size: Theme.size(12, capped: true))
                            .foregroundStyle(Theme.dim)
                            .padding(.trailing, 4)
                    }
                    .contentShape(Rectangle())
            }
            .buttonStyle(.plain)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel(Copy.hiddenRowLabel)
            .accessibilityAddTraits(.isButton)
        } else {
            content
        }
    }
}

extension View {
    /// Blurs and redacts the view when `hidden`, sending a tap to `open` (the paywall).
    func hiddenUntilSubscribed(_ hidden: Bool, open: @escaping () -> Void) -> some View {
        modifier(HiddenUntilSubscribed(hidden: hidden, open: open))
    }
}
