// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView,
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// controller-glyph hint bar, and the connected-controller status chip. One look across every
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).

import Glur
import GlurBackdrop
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController

/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense ✕, …)
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
/// (`GamepadManager.lastKnownKind` → `GamepadGlyphs`), else the caller's generic fallback.
///
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
/// runs flat — and permanently under `gamepadUIMode == "always"`, which puts the console UI up
/// with no pad by design — and the fallbacks are letter glyphs, so a DualSense user's ✕/◯ legends
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
/// `GamepadButtonRole` names.
///
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
@MainActor
func buttonGlyph(
    _ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
    let manager = GamepadManager.shared
    if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
        return live
    }
    guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
    return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
}

/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
/// at all. The other values follow the console shell's rhythm (title top = 18 design units,
/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole
/// header reads pressed against the bezel, which the tab strip's extra band made obvious.
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
    #if os(macOS)
    26
    #elseif os(tvOS)
    24
    #else
    compact ? 18 : 28
    #endif
}

/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is
/// one) before the content: the console leaves ~14 units of air under its tab pills, and without
/// it the first row sits shoulder-to-shoulder with the header.
func gamepadTitleBottomPadding(compact: Bool) -> CGFloat {
    #if os(tvOS)
    16
    #else
    compact ? 8 : 12
    #endif
}

/// Spacing between a header's stacked elements (title over tab strip / subtitle).
func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
    #if os(tvOS)
    13
    #else
    compact ? 6 : 10
    #endif
}

/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict
/// on the smaller first cut was "way too small" once the title moved off-centre.
func gamepadTitleSize(compact: Bool) -> CGFloat {
    #if os(tvOS)
    44
    #else
    compact ? 24 : 34
    #endif
}

/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) — one set of numbers so the screens read as the same surface, at the size
/// the screen they are on calls for.
///
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
/// a settings list at iPhone scale in the middle of a 13" display — the field verdict was that the
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
///
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
/// Manager or Split View window correctly gets the in-hand numbers — the window is what the user
/// is reading, not the panel it sits on.
struct GamepadFormMetrics {
    /// Which set this is, for the few things that are a KIND of layout rather than a number.
    enum Tier { case phone, pad, tv }

    let tier: Tier
    let headerFont: CGFloat
    let labelFont: CGFloat
    let valueFont: CGFloat
    let iconFont: CGFloat
    let iconWidth: CGFloat
    let chevronFont: CGFloat
    let rowHPad: CGFloat
    let rowVPad: CGFloat
    let rowCorner: CGFloat
    let rowMaxWidth: CGFloat
    let detailFont: CGFloat
    /// The option band's (GamepadOptionBand) fixed stage inside a choice row.
    let bandWidth: CGFloat
    /// The settings screen's section-tab pills.
    let tabFont: CGFloat
    /// The pinned controls legend (GamepadHintBar).
    let hintGlyphFont: CGFloat
    let hintTextFont: CGFloat
    let hintPad: CGFloat

    /// In-hand: a phone, or any window narrow enough to read like one.
    static let phone = GamepadFormMetrics(
        tier: .phone,
        headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
        chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
        detailFont: 13, bandWidth: 240,
        tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)

    /// A tablet-sized window — an arm's length away rather than in the palm.
    static let pad = GamepadFormMetrics(
        tier: .pad,
        headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
        chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
        detailFont: 16, bandWidth: 320,
        tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)

    /// 10-foot.
    static let tv = GamepadFormMetrics(
        tier: .tv,
        headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
        chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
        detailFont: 19, bandWidth: 380,
        tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)

    /// What a screen gets before anything publishes a tier — and the only tier tvOS and macOS ever
    /// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
    static var platformDefault: GamepadFormMetrics {
        #if os(tvOS)
        tv
        #else
        phone
        #endif
    }

    #if os(iOS)
    /// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
    static func forWindow(
        h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
    ) -> GamepadFormMetrics {
        h == .regular && v == .regular ? .pad : .phone
    }
    #endif
}

private struct GamepadMetricsKey: EnvironmentKey {
    static let defaultValue = GamepadFormMetrics.platformDefault
}

extension EnvironmentValues {
    /// The form metrics for the screen currently drawing. Published from ContentView — the app
    /// ROOT — rather than only from `gamepadPaletteInk`, because a screen that applies that
    /// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
    /// it would read the bare default instead of its own window's tier.
    var gamepadMetrics: GamepadFormMetrics {
        get { self[GamepadMetricsKey.self] }
        set { self[GamepadMetricsKey.self] = newValue }
    }
}

private struct DisplayBottomInsetKey: EnvironmentKey {
    static let defaultValue: CGFloat = 0
}

extension EnvironmentValues {
    /// The display's bottom safe-area inset — the home-indicator strip — measured by
    /// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
    /// lands (the legend keeps its plain margin for that first frame) and always 0 on
    /// macOS/tvOS, where nothing publishes it.
    var displayBottomInset: CGFloat {
        get { self[DisplayBottomInsetKey.self] }
        set { self[DisplayBottomInsetKey.self] = newValue }
    }
}

#if os(iOS)
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks — never
/// during a SwiftUI render.
///
/// This number has a history of wrong spellings, each failing silently:
///   - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports NO insets for an edge it
///     has been told to ignore, so that spelling can only ever answer 0;
///   - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
///     move content the inset mechanism itself placed; and
///   - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`) DURING body,
///     which answered correctly and then KILLED the calling view: on an iPad (never the
///     simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
///     silently severed — every later `@State` write lands in storage without ever re-running
///     `body` again, which is how Settings and Add Host stopped opening while their triggers
///     kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
/// answer hops out of the current update before anyone in SwiftUI reads it.
struct DisplayBottomInsetProbe: UIViewRepresentable {
    let onChange: (CGFloat) -> Void

    func makeUIView(context: Context) -> ProbeView {
        let view = ProbeView()
        view.onChange = onChange
        // Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
        view.isUserInteractionEnabled = false
        return view
    }

    func updateUIView(_ view: ProbeView, context: Context) {
        view.onChange = onChange
    }

    final class ProbeView: UIView {
        var onChange: ((CGFloat) -> Void)?
        private var last: CGFloat?

        override func didMoveToWindow() {
            super.didMoveToWindow()
            report()
        }

        override func safeAreaInsetsDidChange() {
            super.safeAreaInsetsDidChange()
            report()
        }

        // Rotation reshuffles the window's insets without necessarily touching this view's own.
        override func layoutSubviews() {
            super.layoutSubviews()
            report()
        }

        private func report() {
            // The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
            // own inset is 0 — the number the legend needs is the strip the window reserves.
            guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
            last = bottom
            let onChange = onChange
            // Out of the current UIKit/SwiftUI update before any state write.
            DispatchQueue.main.async { onChange?(bottom) }
        }
    }
}
#endif

/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
/// as it sits from the leading edge — so it lands on the diagonal of the display's rounded corner,
/// which is what the corner asks for.
///
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
/// bar in the middle, so the two never meet.
///
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
///
/// `displayBottom` is `\.displayBottomInset` — measured by `DisplayBottomInsetProbe`, NEVER asked
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
/// view's updates (see the probe's comment). Pure arithmetic only.
func gamepadLegendBottomPadding(
    _ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
) -> CGFloat {
    guard tier == .pad else { return margin }
    // Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
    return max(-displayBottom, margin - displayBottom)
}

/// The tray gradient blur, back — as a real progressive BACKDROP blur this time.
///
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
/// (`GlurBackdrop`) — the public `.glur()` modifier is a shader on a view's own content and
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
///
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
struct GamepadTrayBlur: View {
    let edge: VerticalEdge

    var body: some View {
        // offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
        // in the open field is touched — which is why, unlike the scrim, this band takes NO
        // content-side overhang. The scrim's -44/-72 runway existed because a material carries
        // body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
        // here blurred fully-visible rows at rest (field verdict on the first cut). Full
        // strength lands at 60% of the band, so the tray's own text always sits on the strong
        // region while the ramp still reads as a gradient, not an edge.
        GlurView(
            radius: 14, offset: 0, interpolation: 0.6,
            direction: edge == .top ? .up : .down)
            // Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
            // beat after insertion (outside any geometry group and outside this view's own
            // transaction), which reads as a visible pop. 80 pt clears every inset on every
            // device, and backgrounds never clip — the overhang simply draws.
            .padding(edge == .top ? .top : .bottom, -80)
            .padding(.horizontal, -80)
            // And the shape must NEVER animate: mounted inside a pushed shell layer, any late
            // geometry would ride the push's transaction and visibly grow into place. The
            // layer's own fade/slide still carries the band; only its SHAPE is pinned.
            .transaction { $0.animation = nil }
    }
}

/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
    let glyph: String
    let text: String
    /// What tapping/clicking this cell does — the same thing its button does. Optional because a
    /// few legend cells NAME an input rather than an action ("↔ Adjust" is the stick itself;
    /// there is no single thing a tap on it could mean), and those stay inert labels.
    var action: (() -> Void)? = nil
    var id: String { glyph + text }
}

/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration —
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
    @Environment(\.gamepadInk) private var ink
    /// Sized with the screen it pins to — a legend at phone scale on a 13" iPad is the same
    /// mismatch the form rows had (see GamepadFormMetrics).
    @Environment(\.gamepadMetrics) private var metrics
    let hints: [GamepadHint]

    var body: some View {
        HStack(spacing: 18) {
            ForEach(hints) { hint in
                cell(hint)
            }
        }
        .font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
        .foregroundStyle(ink.fg(0.85))
        .padding(metrics.hintPad)
        .consoleGlass(Capsule())
        // The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
        // Spelled out rather than left to defaults, because a swallowed touch in this bar is
        // invisible — the legend simply stops doing anything.
        .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
    }

    /// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
    /// comment for why tvOS is always the latter).
    @ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
        #if os(tvOS)
        label(hint)
        #else
        if let action = hint.action {
            Button(action: action) { label(hint) }
                .buttonStyle(HintCellStyle())
                .accessibilityLabel(hint.text)
        } else {
            label(hint)
        }
        #endif
    }

    private func label(_ hint: GamepadHint) -> some View {
        HStack(spacing: 7) {
            Image(systemName: hint.glyph)
                .font(.system(size: metrics.hintGlyphFont))
                .foregroundStyle(ink.fg)
            Text(hint.text)
        }
        .fixedSize() // keep glyph + label together; never truncate a hint mid-word
        // The tappable area covers the gap between glyph and label, not just their painted
        // pixels — a legend cell is small enough already.
        .contentShape(Rectangle())
    }
}

#if !os(tvOS)
/// Press feedback for a legend cell. Deliberately quiet — the bar is chrome, and a cell that lit
/// up like a primary button would pull the eye off the content it describes.
///
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
/// animation that shrinks the artwork must never move the target out from under a resting finger,
/// or the touch-up lands outside and SwiftUI discards the tap.
private struct HintCellStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(configuration.isPressed ? 0.55 : 1)
            .scaleEffect(configuration.isPressed ? 0.94 : 1)
            .animation(.smooth(duration: 0.14), value: configuration.isPressed)
            .contentShape(Rectangle())
    }
}
#endif

/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
/// regresses.
///
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only
/// the contrast differs, so a screen change can't make the field jump.
///
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why.
///
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
    /// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
    /// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
    /// environment here is the screen's parent's — the dark default under a cover or a sheet. It
    /// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
    /// white instead of settling onto its own ink. (see `GamepadInk.stored`)
    private var ink: GamepadInk { .stored(paletteID) }
    /// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
    /// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
    /// it during a push/pop — the console does the same with its `bg_mix` — and every
    /// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where
    /// re-built gradient stops do not.
    var calmMix: Double

    /// The Bool spelling every non-shell call site uses (see the type comment for `calm`).
    init(calm: Bool = false) {
        calmMix = calm ? 1 : 0
    }

    init(calmMix: Double) {
        self.calmMix = calmMix
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"

    var body: some View {
        let palette = GamepadPalette.named(paletteID)
        Group {
            if reduceMotion {
                composite(at: 0, palette: palette)
            } else {
                // 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
                // redraw cost of a battery-fed couch device vs. the display's native rate.
                TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
                    composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
                }
            }
        }
        .ignoresSafeArea()
    }

    /// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
    /// vignette, and the title/hints legibility scrim — in that order, matching the console
    /// shader's `composite` so the two platforms' backdrops stay the same picture.
    private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
        // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark
        // field's strength bleaches the chroma straight out of the gradient, so a pale palette
        // gets under half — the same `u_scrim.a` the console shader carries.
        let scrim: Color = palette.light ? ink.fg : .black
        let strength = palette.light ? 0.45 : 1.0
        return ZStack {
            Self.color(palette.ground)
            colorField(at: t, palette: palette)
                // ±8° over ~5 min — the whole field very slowly warms and cools.
                .hueRotation(.degrees(sin(t * 0.021) * 8))
                // Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
                // lerps toward it, so this layer alone IS the whole calm mix.
                .opacity(1 - 0.4 * calmMix)
            // A further plusLighter wash of the ground, which lets a DARK palette's bright pools
            // come down to meet its ground rather than merely fading toward it.
            //
            // Suppressed on a pale palette (the factor goes to 0), because there it was destroying
            // the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
            // already mixed 0.4 toward that same ground saturated the form screens to flat white —
            // the field ask was "in bright mode the sub-screens are basically just white". Written
            // as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
            // animating instead of popping when a screen is pushed.
            Self.color(palette.ground)
                .opacity(0.4 * calmMix * (palette.light ? 0 : 1))
                .blendMode(.plusLighter)
            // Cinematic vignette: the edges settle toward the scrim so the cards sit in the
            // pooled light. Soft (extends past the frame) so the corners deepen rather than
            // crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
            // screen's rows run out toward the edges, where crushing them just eats the list.
            EllipticalGradient(
                colors: [.clear, scrim.opacity(0.42 * strength)],
                center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
                .opacity(1 - 0.5 * calmMix)
            // Legibility grounding for the pinned title (top) and hint pill (bottom). This one
            // works on the field itself (it's the backdrop's bottom layer — nothing behind it to
            // blur), so it stays a gradient, just a light one.
            LinearGradient(
                stops: [
                    .init(color: scrim.opacity(0.38 * strength), location: 0),
                    .init(color: scrim.opacity(0.06 * strength), location: 0.32),
                    .init(color: scrim.opacity(0.08 * strength), location: 0.68),
                    .init(color: scrim.opacity(0.40 * strength), location: 1),
                ],
                startPoint: .top, endPoint: .bottom)
        }
    }

    @ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
        if #available(iOS 18, macOS 15, tvOS 18, *) {
            MeshGradient(
                width: 4, height: 4,
                points: Self.meshPoints(at: t),
                colors: palette.meshColors.map(Self.color),
                smoothsColors: true)
        } else {
            LegacyBlobField(t: t, palette: palette)
        }
    }

    // MARK: - MeshGradient aurora (iOS 18 / macOS 15+)

    static func color(_ c: SIMD3<Double>) -> Color {
        Color(red: c.x, green: c.y, blue: c.z)
    }

    /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
    /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
    /// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
    /// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop.
    private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
        func wob(_ bx: Float, _ by: Float, _ a: Float,
                 _ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
            SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
        }
        return [
            SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
            SIMD2(0, 0.333),
                wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
                wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
            SIMD2(1, 0.333),
            SIMD2(0, 0.667),
                wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
                wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
            SIMD2(1, 0.667),
            SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
        ]
    }
}

/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
/// setting too instead of being stuck on violet.
private struct LegacyBlobField: View {
    let t: TimeInterval
    let palette: GamepadPalette

    /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
    /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. The COLOUR comes from the
    /// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
    private struct Blob {
        let center: CGPoint
        let drift: CGSize
        let speed: (x: Double, y: Double)
        let phase: (x: Double, y: Double)
        let radius: CGFloat
        let breathe: (amount: CGFloat, speed: Double)
        let opacity: Double
    }

    private static let blobs: [Blob] = [
        Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
             speed: (0.111, 0.083), phase: (0.0, 1.9),
             radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
        Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
             speed: (0.071, 0.096), phase: (2.4, 0.7),
             radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
        Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
             speed: (0.089, 0.067), phase: (4.1, 3.2),
             radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
        Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
             speed: (0.059, 0.104), phase: (1.2, 5.0),
             radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
    ]

    var body: some View {
        GeometryReader { geo in
            let side = max(geo.size.width, geo.size.height)
            ZStack {
                ForEach(Self.blobs.indices, id: \.self) { i in
                    blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
                }
            }
            .drawingGroup()
        }
    }

    private func blobView(
        _ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
    ) -> some View {
        let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
        let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
        let r = side * blob.radius
            * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
        let color = GamepadScreenBackground.color(tone)
        return Circle()
            .fill(RadialGradient(
                colors: [color, color.opacity(0)],
                center: .center, startRadius: 0, endRadius: r / 2))
            .frame(width: r, height: r)
            .position(x: x * size.width, y: y * size.height)
            .opacity(blob.opacity)
            // Additive only works over a DARK ground; over a pale one every blob saturates to
            // white and the field turns grey. Pale palettes tint instead.
            .blendMode(palette.light ? .normal : .plusLighter)
    }
}

/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
struct GamepadFormBackground: View {
    var body: some View {
        GamepadScreenBackground(calm: true)
    }
}

#if os(tvOS)
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
/// interactive-glass feel elsewhere.
struct ConsoleBareButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.97 : 1)
            .animation(.smooth(duration: 0.15), value: configuration.isPressed)
    }
}
#endif

/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
    @Environment(\.gamepadInk) private var ink
    let controller: GamepadManager.DiscoveredController

    // Legible from the couch on tvOS, quiet in hand elsewhere.
    #if os(tvOS)
    private static let font: CGFloat = 17
    private static let hPad: CGFloat = 16
    private static let vPad: CGFloat = 10
    #else
    private static let font: CGFloat = 12
    private static let hPad: CGFloat = 12
    private static let vPad: CGFloat = 7
    #endif

    var body: some View {
        HStack(spacing: 7) {
            Image(systemName: controller.hasTouchpadAndMotion
                ? "playstation.logo" : "gamecontroller.fill")
                .font(.system(size: Self.font))
            Text(controller.name)
                .lineLimit(1)
            if let level = controller.batteryLevel {
                Image(systemName: batterySymbol(level))
                    .font(.system(size: Self.font))
                    .foregroundStyle(level <= 0.2 && !controller.isCharging
                        ? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
            }
        }
        .font(.geist(Self.font, .medium, relativeTo: .caption))
        .foregroundStyle(ink.fg(0.7))
        .padding(.horizontal, Self.hPad)
        .padding(.vertical, Self.vPad)
        .background(Capsule().fill(ink.fg(0.08)))
        .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
    }

    private func batterySymbol(_ level: Float) -> String {
        if controller.isCharging { return "battery.100.bolt" }
        switch level {
        case ..<0.125: return "battery.0"
        case ..<0.375: return "battery.25"
        case ..<0.625: return "battery.50"
        case ..<0.875: return "battery.75"
        default: return "battery.100"
        }
    }
}
#endif
