import AppKit
import SwiftUI

/// **A reusable SF Symbol picker** — a single clickable rectangle (`PickerRect.swift`'s shared
/// chrome) showing the resolved symbol, opening a curated grid popover on any click (03-board-ui.md §
/// Styling ▸ Controls: "its leading well is the level's default symbol and removes the `icon` key …
/// Any other SF Symbol name works written by hand"). `StyleEditor.swift`
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
/// control that only ever needs one glyph — a saved search, a smart filter, a future per-item
/// affordance — is not forced to carry the style editor's background section or its `BoardStore`
/// coupling to get one.
///
/// ### Why the curated set differs from `CuratedSymbols`
///
/// `CuratedSymbols` is three sets grouped by *level* — boards, lanes, cards (2026-08-09) — because a
/// board's identity, a lane's stage and a card's content want different glyphs. This control has no
/// level built in; a caller aimed at one names it explicitly (`BoardInfoPopover` passes
/// `CuratedSymbols.availableBoards`, the card sidebar `CuratedSymbols.availableCards`), and a caller
/// with no level in mind — a saved search, a smart filter — falls back to `SymbolPickerCatalog.defaultSet`,
/// a smaller, ungrouped 36 chosen for the general "boards and projects" case instead. The lists are
/// free to diverge; nothing here reads the others beyond the merge below.
///
/// ### The one thing `CuratedSymbols` never needed
///
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
/// general-purpose control cannot assume its 36 will always contain what the caller is after — a
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
/// look further".
///
/// ### Shape and grammar
///
/// This control used to be a single 20pt bordered square with one hit zone, then (2026-08-09) grew a
/// second, trigger, zone to rhyme with the colour combo's own two-zone shape — the face opening
/// `SymbolBrowserPanel` directly, the trigger popping this curated grid. The owner's 2026-08-10
/// reversal (Pipeline card 5004c540: "revert to previous look of the symbol picker, no combo trigger
/// … just a rectangle … clickable to show a popover with grid of symbols and colors") retired the
/// second zone outright: this is a `PickerRectControl` (`PickerRect.swift`) with exactly **one** hit
/// zone, and that one zone always opens the curated popover. `SymbolBrowserPanel`, the standalone
/// searchable/categorised browser, is reachable now **only** through the popover's own **More
/// Symbols…** row — which is also, as it always was, the only *keyboard* route to it, since a plain
/// rectangle has no second key that would mean "the other door."
///
/// The popover still carries the two rows that grammar implies: **More Symbols…** onto the browser,
/// and an **Other…** under the tint grid onto the Colors panel. The tint grid itself is 4×3, eleven
/// tints plus a leading None (`Palette.tints`) — unrelated to this card and unchanged by it.

// MARK: - The symbol catalogs

/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
/// inventory it searches into once the grid alone isn't enough.
enum SymbolPickerCatalog {

    /// The picker's curated grid, in order — a general "boards and projects" set rather than one of
    /// the style editor's level-specific groupings, chosen so a first-run picker with no
    /// caller-supplied `symbols` still shows something broadly useful. A stored constant, not a
    /// computed property, for `CuratedSymbols`' own reason: the list is the design decision, and
    /// `available` is the only thing the OS gets a say in.
    static let defaultSet: [String] = [
        "star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
        "tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
        "list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
        "paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
        "airplane", "gamecontroller", "globe",
    ]

    /// The set this Mac can actually draw — `CuratedSymbols.available(for:)`'s rule, mirrored: a
    /// curated list is a convenience, never a claim about the running system.
    static var available: [String] { defaultSet.filter(ItemSymbol.exists) }

    /// The colour row's tints — `Palette.tints`' names, which is the icon-tint palette minus the four
    /// grayscale steps (a symbol's *tint* wants colour; "no tint" is the None well's job, not a
    /// gray's) and minus `deep-cool-granite`, the mutedest of the ring. Palette names, not hexes,
    /// exactly as the style editor's wells write them.
    ///
    /// **Eleven now, seven before** — the palette grew to sixteen on 2026-08-09 and the exclusions
    /// did not, so the row gained its third 4-wide row (`SymbolPickerLayout.colorRows`) and its
    /// leading None still fills the grid exactly. That fit is asserted, not hoped for
    /// (`SymbolPickerColorSetTests`).
    ///
    /// Derived from `Palette` rather than hand-listed: the old constant restated seven names the
    /// palette already knew, which is one rename away from a dead well.
    static var colorSet: [String] { Palette.tints.map(\.name) }

    /// Where the OS keeps the full SF Symbols inventory — read-only system metadata, present on
    /// every Mac that ships SF Symbols at all.
    private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"

    /// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
    /// synchronous and the result is a plain `[String]` — Sendable, immutable once computed — so
    /// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
    /// needs, with no actor to hang it off.
    private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)

    /// Every SF Symbol name the running OS knows, sorted and deduplicated — the search grid's source.
    ///
    /// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
    /// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
    /// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
    /// for an answer the file has already given for free. A curated list is different: it is a
    /// hand-written guess that might be stale, and only guesses need checking.
    ///
    /// `bundlePath` defaults to the real system location and is cached there; any other path — the
    /// test suite's nonexistent one, chiefly — reloads (and re-falls-back) on every call, which is
    /// the honest cost of asking a question the cache was never built to answer.
    static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
        bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
    }

    /// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
    /// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
    /// way — as "no inventory to read" — rather than as three different failure modes to chase. The
    /// merged curated set is never empty, so the picker always has *something* to search, even on a
    /// system whose metadata this reader cannot make sense of.
    private static func load(bundlePath: String) -> [String] {
        guard
            let bundle = Bundle(path: bundlePath),
            let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
            let data = FileManager.default.contents(atPath: plistPath),
            let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
            let root = plist as? [String: Any],
            let symbols = root["symbols"] as? [String: Any]
        else {
            return Set(defaultSet + CuratedSymbols.combined).sorted()
        }
        return symbols.keys.sorted()
    }

    /// `symbols` narrowed to the names matching `query` — pure, so the AND semantics and the
    /// order-preservation are assertable without a picker on screen.
    ///
    /// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" — a
    /// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
    /// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
    /// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
    /// search would, rather than requiring the words adjacent or in order.
    static func filter(_ query: String, in symbols: [String]) -> [String] {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return symbols }
        let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
        return symbols.filter { name in
            let lowered = name.lowercased()
            return tokens.allSatisfy { lowered.contains($0) }
        }
    }
}

// MARK: - Geometry

/// The picker's font-derived geometry — well side, well spacing, the fixed 6×6 grid, and the
/// popover's own padding — following `StyleEditorLayout`'s derivation rather than restating it: the
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
/// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid
/// is this popover's whole subject, where the style editor's is one section among several), still
/// anchored to the shared base so the two components move together at every text size.
///
/// **The at-rest control's geometry is not here** — it is `PickerRectMetrics`', shared with the
/// colour rectangle, which is what makes the two the same size. This type now describes the
/// popover alone. Only the shape wraps a picker's own frame around them — six
/// columns fixed (not a
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
/// keystrokes would be distracting rather than helpful.
struct SymbolPickerLayout: Equatable {

    static let columns = 6
    static let rows = 6
    /// The colour row's own shape — 4×3, the leading None plus `SymbolPickerCatalog.colorSet`'s
    /// eleven tints. Was 4×2 and seven until the palette grew (2026-08-09).
    static let colorColumns = 4
    static let colorRows = 3
    /// The grid's enlargement over the style editor's well size — glyphs read at a glance rather
    /// than in miniature.
    static let gridScale: CGFloat = 1.3

    /// The glyph's own point size inside a grid well — the body size under `gridScale`, since a
    /// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
    var glyphPointSize: CGFloat
    var wellSide: CGFloat
    var wellSpacing: CGFloat
    /// The gap between the search field and the grid below it — one figure rather than a pixel
    /// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
    /// scaling rule).
    var searchSpacing: CGFloat
    /// The popover's own inset, on all four sides.
    var contentPadding: CGFloat
    var gridWidth: CGFloat
    /// The search grid's scroll cap — six rows tall, so a long result list scrolls inside the popover
    /// rather than growing it.
    var gridHeight: CGFloat
    /// A colour well's width: the symbol grid's width re-divided into four columns, so the colour
    /// rows sit flush under the symbol grid rather than introducing a second width. Height stays
    /// `wellSide` — the swatch is wide, not tall.
    var colorWellWidth: CGFloat
    /// The grid's width plus its padding on both sides — the popover's fixed width.
    var popoverWidth: CGFloat

    static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
        let side = (StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) * gridScale).rounded()
        let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
        let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
        let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
        let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
        return SymbolPickerLayout(
            glyphPointSize: (bodyPointSize * gridScale).rounded(),
            wellSide: side,
            wellSpacing: spacing,
            searchSpacing: spacing,
            contentPadding: padding,
            gridWidth: gridWidth,
            gridHeight: gridHeight,
            colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
            popoverWidth: (gridWidth + padding * 2).rounded()
        )
    }
}

// MARK: - The control

/// A single-rectangle symbol picker — the reusable primitive a caller can aim at one symbol field
/// without wiring up a `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery
/// `StyleEditorView` carries for the board's own background+icon editor.
///
/// **No store, no undo stack, no target set** is known to this type; `onSelect`/`onSelectColor` are
/// the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding would be.
/// That contract's API is byte for byte what it was through both the 2026-08-09 two-zone rework and
/// the 2026-08-10 reversal back to one — every existing caller kept working while the control
/// underneath changed shape twice.
struct SymbolPicker: View {

    /// The committed symbol name, or `nil` for "no override" — read alongside `fallback` rather than
    /// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
    /// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
    /// may have no `FieldValue` at all).
    let current: String?
    /// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
    let fallback: String
    /// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
    /// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
    /// say) can supply its own.
    var symbols: [String] = SymbolPickerCatalog.available
    /// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
    /// the picker to the curated grid alone — a caller with no use for the OS's whole inventory
    /// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
    var searchable: Bool = true
    /// The name to set, or `nil` to clear back to the default — mirrors `StyleChange`'s `set`/`remove`
    /// split without importing that type, since a caller outside the styling system has no `StyleChange`
    /// to hand back.
    let onSelect: (String?) -> Void
    /// The committed tint (`iconColor`), or `nil` for "no tint" — read only when `onSelectColor` is
    /// wired, since a picker with no colour row has no tint to state.
    var currentColor: String? = nil
    /// The colour row's contract, `onSelect`'s shape one dimension over: a palette name to set, or
    /// `nil` to clear the tint. **`nil` here means no colour row at all** — the grid is opt-in, so
    /// the callers that wanted a symbol picker keep getting exactly one.
    var onSelectColor: ((String?) -> Void)? = nil

    @Environment(\.isEnabled) private var isEnabled

    /// The curated popover's presented flag — **view-local, and a SwiftUI `.popover` deliberately**.
    ///
    /// The rework's first cut popped an `NSPopover` straight from the control, which is the natural
    /// thing for an `NSView` to do and is wrong here: this picker is mounted *inside* another popover
    /// at one of its anchors (`BoardInfoPopover`, beside the rename field), and a second popover that
    /// takes key status from the first would dismiss the surface it was opened from. SwiftUI already
    /// handles that nesting, and did before this card; keeping the presentation where it was is the
    /// change that is not being made.
    @State private var isPopoverPresented = false

    var body: some View {
        SymbolGlyphView(
            current: current,
            fallback: fallback,
            isEnabled: isEnabled,
            currentColor: onSelectColor == nil ? nil : currentColor,
            onClick: { isPopoverPresented = true }
        )
        .help("Symbol")
        .accessibilityLabel("Symbol")
        .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
            SymbolPickerPopoverContent(
                current: current,
                fallback: fallback,
                symbols: symbols,
                searchable: searchable,
                layout: SymbolPickerLayout.metrics(bodyPointSize: CardWindowMetrics.bodyPointSize),
                onSelect: { name in
                    isPopoverPresented = false
                    onSelect(name)
                },
                // **More Symbols…** — the popover's route to the standalone browser, and the only
                // way there at all now that the rectangle itself has no second zone or second key
                // (`PickerRect.swift`'s grammar).
                onBrowse: {
                    isPopoverPresented = false
                    openBrowser()
                },
                currentColor: currentColor,
                onSelectColor: onSelectColor.map { select in
                    { name in
                        isPopoverPresented = false
                        select(name)
                    }
                },
                // **Other…** under the tint grid — the colour rectangle's own row, one field over.
                onPickCustomColor: onSelectColor.map { select in
                    {
                        isPopoverPresented = false
                        openTintPanel(select)
                    }
                }
            )
        }
    }

    /// **More Symbols…**'s door onto the standalone browser — the popover's only route there now
    /// that the rectangle itself has no second zone of its own (`PickerRect.swift`'s grammar).
    ///
    /// The closure handed over **outlives this view on purpose**. Opening the browser dismisses
    /// whichever popover this picker was mounted in, which would take a view-owned closure with it and
    /// leave the browser picking into nothing; the shared panel keeps the last closure it was given
    /// until another surface claims it or its own window closes (`SymbolBrowserPanel`).
    private func openBrowser() {
        SymbolBrowserPanel.shared.present(current: current, fallback: fallback, onSelect: onSelect)
    }

    /// The tint's **Other…** — the shared, debounced Colors panel, for the same
    /// outlives-the-popover reason (`SharedColorPanelSession`). Seeded with the live tint, matched
    /// against the icon palette so a pick landing on one of its colours stores the *name*.
    private func openTintPanel(_ select: @escaping (String?) -> Void) {
        SharedColorPanelSession.present(
            seed: currentColor.flatMap(Palette.nsColor(for:)),
            matching: Palette.foregrounds
        ) { value in
            select(value)
        }
    }
}

// MARK: - The AppKit bridge

/// `SymbolPicker`'s face: a `SymbolGlyphControl` with its one zone wired to the caller's closure.
///
/// Deliberately **dumb** — it draws and it reports clicks. Every decision about what a click *means*
/// (which popover, which panel, what closure survives what) is `SymbolPicker`'s, in SwiftUI, where
/// the presentation modifiers and the state that drives them already live. Neither this view nor
/// `ColorSwatchPicker`'s own AppKit bridge carries a coordinator any more — the last thing that
/// needed one was the retired combo's `NSMenu` dropdown.
///
/// `.disabled(_:)` reaches it through `@Environment(\.isEnabled)` on `SymbolPicker` rather than a
/// parameter, since callers already spell the lock that way (`CardStyleSection`, `BoardInfoPopover`).
private struct SymbolGlyphView: NSViewRepresentable {

    let current: String?
    let fallback: String
    let isEnabled: Bool
    /// The live tint, or `nil` for the standard label colour — already gated by the caller on whether
    /// a colour row is offered at all, so this view has no opinion about it.
    let currentColor: String?
    let onClick: () -> Void

    /// What the face draws — `current` if this system can resolve it, `fallback` otherwise. The
    /// lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated because this
    /// control's `current` arrives as a plain optional string.
    private var resolvedName: String {
        if let current, ItemSymbol.exists(current) { return current }
        return fallback
    }

    func makeNSView(context: Context) -> SymbolGlyphControl {
        SymbolGlyphControl(frame: .zero)
    }

    func updateNSView(_ control: SymbolGlyphControl, context: Context) {
        control.metrics = .current
        control.isEnabled = isEnabled
        control.glyphName = resolvedName
        control.glyphTint = currentColor.flatMap(Palette.nsColor(for:))
        control.accessibilityValueText = resolvedName
        // Rewired on every update rather than once in `makeNSView`: the closure closes over this
        // struct's current values, and a stale one would open the popover for the field this picker
        // used to be pointed at.
        control.onClick = onClick
    }

    /// Obeys an explicit finite proposal when SwiftUI hands one over, else falls back to the control's
    /// own intrinsic width — `ColorSwatchView.sizeThatFits`'s rule, restated so the two rectangles
    /// answer a proposal identically and a caller that frames them alike gets two controls the same
    /// size.
    func sizeThatFits(_ proposal: ProposedViewSize, nsView: SymbolGlyphControl, context: Context) -> CGSize? {
        let width: CGFloat
        if let proposed = proposal.width, proposed.isFinite {
            width = proposed
        } else {
            width = nsView.intrinsicContentSize.width
        }
        return CGSize(width: width, height: nsView.intrinsicContentSize.height)
    }
}

// MARK: - The face

/// `PickerRectControl` with a **glyph** filling the whole face — the symbol half of the shared
/// chrome, and the whole of what is specific to symbols about it.
final class SymbolGlyphControl: PickerRectControl {

    /// The resolved name the face draws — the coordinator's `resolvedName`, handed over on every
    /// SwiftUI update.
    var glyphName: String = ItemSymbol.card {
        didSet {
            guard glyphName != oldValue else { return }
            needsDisplay = true
        }
    }

    /// The live `iconColor`, or `nil` for the standard label colour. Lenient exactly like the glyph:
    /// an unresolvable value tints nothing.
    var glyphTint: NSColor? {
        didSet {
            guard glyphTint != oldValue else { return }
            needsDisplay = true
        }
    }

    /// The glyph, centred and tinted. A name this system cannot draw falls back to the dashed
    /// question mark the wells use — the same "show that there is nothing here" the grids draw.
    ///
    /// **Centred on both axes since 2026-08-09** — the doc comment above always said "centred" but the
    /// draw only ever centred vertically and offset from the leading edge by the swatch's own
    /// horizontal padding, which read as left-aligned once the glyph was more than a sliver narrower
    /// than the face. The owner's review named this directly ("for symbol picker center the symbol").
    ///
    /// **Sized off the actual `rect`, not a pure metrics figure, since the evening
    /// 2026-08-09 review.** That figure (now removed) was a pure derivation from the *intrinsic*
    /// portrait width, but `sizeThatFits` obeys any finite width SwiftUI proposes, and the card
    /// sidebar's row proposes real width — so the control renders wide while a glyph pinned at the
    /// intrinsic figure stayed small, a glyph floating in visible dead space. That space *was* the
    /// "padding" the owner's "reduce vertical size … on symbol picker by reducing padding" was naming,
    /// so the fix reads the point size off the rect the face actually receives, via `glyphPointSize(
    /// forFace:)` and `fittedSize(for:in:)` below — pure functions, not inlined here, so the sizing
    /// rule is assertable without an actual draw.
    override func drawFace(in rect: NSRect) {
        guard rect.width > 0, rect.height > 0 else { return }
        let name = ItemSymbol.exists(glyphName) ? glyphName : "questionmark.square.dashed"
        let config = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize(forFace: rect), weight: .regular)
            .applying(.init(paletteColors: [glyphTint ?? .labelColor]))
        guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
            .withSymbolConfiguration(config)
        else { return }
        let size = Self.fittedSize(for: image.size, in: rect)
        image.draw(in: NSRect(
            x: rect.midX - size.width / 2,
            y: rect.midY - size.height / 2,
            width: size.width,
            height: size.height
        ))
    }

    /// The glyph's target point size for a face rect — no inset subtracted, so this is simply whichever
    /// of the rect's own two dimensions is smaller. SF Symbols already carry their own internal
    /// margins, and the owner's evening review asked for the *dead space around* the glyph gone, not a
    /// second margin layered on top of the system's own.
    static func glyphPointSize(forFace rect: NSRect) -> CGFloat {
        min(rect.width, rect.height)
    }

    /// `imageSize` unchanged, unless it overshoots `rect` on either axis — a symbol configured at
    /// `glyphPointSize(forFace:)` can still render wider (or, rarely, taller) than that on its long
    /// axis, since SF Symbols are not all square glyphs. Scaled down proportionally so the drawn glyph
    /// stays fully inside its face rather than clipping at the edges, and never scaled up: a glyph
    /// smaller than its face on both axes is left exactly as configured.
    static func fittedSize(for imageSize: NSSize, in rect: NSRect) -> NSSize {
        guard rect.width > 0, rect.height > 0 else { return imageSize }
        let overshoot = max(imageSize.width / rect.width, imageSize.height / rect.height)
        guard overshoot > 1 else { return imageSize }
        return NSSize(width: imageSize.width / overshoot, height: imageSize.height / overshoot)
    }
}

// MARK: - The popover's content

/// The popover's body: the search field (when `searchable`), and either the curated grid or a
/// live search result — never both, since a query and the at-rest curated set answer the same
/// question two different ways.
private struct SymbolPickerPopoverContent: View {

    let current: String?
    let fallback: String
    let symbols: [String]
    let searchable: Bool
    let layout: SymbolPickerLayout
    let onSelect: (String?) -> Void
    /// Opens the standalone browser — the popover's route to the face zone's door, present whenever
    /// the caller wired one.
    var onBrowse: (() -> Void)? = nil
    var currentColor: String? = nil
    /// `nil` is "no colour row" — `SymbolPicker.onSelectColor`'s opt-in, passed through.
    var onSelectColor: ((String?) -> Void)? = nil
    /// Opens the Colors panel for the tint. Wired only where the colour row is.
    var onPickCustomColor: (() -> Void)? = nil

    @State private var query = ""

    var body: some View {
        VStack(alignment: .leading, spacing: layout.searchSpacing) {
            if searchable {
                searchField
            }
            resultBody
            if let onBrowse {
                deeperRow("More Symbols…", action: onBrowse)
            }
            // The colour row rides below whichever grid is up — a search narrows the symbols, not
            // the tints, so the row keeps standing where the eye left it.
            if let onSelectColor {
                Divider()
                SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
                if let onPickCustomColor {
                    deeperRow("Other…", action: onPickCustomColor)
                }
            }
        }
        .padding(layout.contentPadding)
        .frame(width: layout.popoverWidth)
    }

    /// A row onto a standalone picker — **More Symbols…** under the glyph grid, **Other…** under the
    /// tint grid. Trailing-aligned link buttons rather than extra wells in the grids above them: a
    /// well that is not a value is a well that has to be *learned*, and the colour combo's dropdown
    /// already spells this door as a titled row.
    private func deeperRow(_ title: String, action: @escaping () -> Void) -> some View {
        HStack(spacing: 0) {
            Spacer(minLength: 0)
            Button(title, action: action)
                .buttonStyle(.link)
                .font(.caption)
        }
    }

    private var searchField: some View {
        TextField("Search Symbols", text: $query)
            .textFieldStyle(.roundedBorder)
            // **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
            // standing Escape grammar): a non-empty query clears itself and keeps the popover open,
            // an empty one lets the press through to the popover's own dismissal.
            .onKeyPress(.escape) {
                guard !query.isEmpty else { return .ignored }
                query = ""
                return .handled
            }
    }

    @ViewBuilder
    private var resultBody: some View {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty {
            SymbolWellGrid(wells: curatedWells, layout: layout) { well in
                onSelect(well.isDefault ? nil : well.name)
            }
        } else {
            let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
            if matches.isEmpty {
                Text("No matches")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(.vertical, layout.wellSpacing)
            } else {
                ScrollView(.vertical) {
                    SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
                        onSelect(well.name)
                    }
                }
                .frame(height: layout.gridHeight)
            }
        }
    }

    /// The at-rest grid: the leading default well, then up to 35 more from `symbols` — 03-board-ui.md
    /// § Styling ▸ Controls' "leading well is the level's default symbol" rule, restated for this
    /// control's plain-optional `current`/`fallback` pair.
    ///
    /// `fallback` is dropped from the trailing set if present, so the default is never drawn twice —
    /// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
    /// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
    /// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
    /// full when it wasn't (board and lane) — a quieter outcome than a grid that overflows its own
    /// 6×6 cap.
    private var curatedWells: [SymbolPickerWell] {
        let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
        var wells = [SymbolPickerWell(
            id: 0,
            name: fallback,
            label: "Default (\(fallback))",
            isSelected: isDefaultSelected,
            isDefault: true
        )]
        let trailing = symbols.filter { $0 != fallback }
        for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
            wells.append(SymbolPickerWell(
                id: index + 1,
                name: name,
                label: name,
                isSelected: current == name,
                isDefault: false
            ))
        }
        return wells
    }

    private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
        matches.enumerated().map { index, name in
            SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
        }
    }
}

// MARK: - Wells

/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
private struct SymbolPickerWell: Identifiable {
    let id: Int
    let name: String
    let label: String
    let isSelected: Bool
    /// Whether this is the leading "no override" well — drawn quieter (`StyleWellFace`'s
    /// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
    /// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
    let isDefault: Bool
}

/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
/// reach — a small sibling here, rather than widening that file's access for one caller outside it.
private struct SymbolWellFace: View {

    let name: String
    let isDefault: Bool
    let size: CGFloat
    /// The glyph's font size — set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
    /// inherited, since the grid's enlargement lives in the font, not the frame.
    let glyphPointSize: CGFloat

    var body: some View {
        Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
            .font(.system(size: glyphPointSize))
            .foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
            .frame(width: size, height: size)
    }
}

/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid — `StyleWellGrid`'s pattern,
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
/// is small (one `move(_:)` handler) and the alternative — exporting `StyleWellGrid` generically out
/// of the style editor — would widen a file whose whole point is staying anchor-agnostic to a second,
/// unrelated caller.
private struct SymbolWellGrid: View {

    let wells: [SymbolPickerWell]
    let layout: SymbolPickerLayout
    let onSelect: (SymbolPickerWell) -> Void

    @FocusState private var focused: Int?
    @Environment(\.colorSchemeContrast) private var contrast

    var body: some View {
        LazyVGrid(
            columns: Array(
                repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
                count: SymbolPickerLayout.columns
            ),
            spacing: layout.wellSpacing
        ) {
            ForEach(wells) { well in
                Button {
                    onSelect(well)
                } label: {
                    SymbolWellFace(
                        name: well.name,
                        isDefault: well.isDefault,
                        size: layout.wellSide,
                        glyphPointSize: layout.glyphPointSize
                    )
                        .overlay(selectionRing(well.isSelected))
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .focusable()
                .focused($focused, equals: well.id)
                .help(well.label)
                .accessibilityLabel(well.label)
                .accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
            }
        }
        .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
            move(press.key)
        }
    }

    private func selectionRing(_ isSelected: Bool) -> some View {
        RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
            .strokeBorder(
                isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
                lineWidth: Accommodations.borderWidth(2, contrast: contrast)
            )
            .padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
    }

    /// One step per press, clamped at the ends — `StyleWellGrid.move(_:)`'s rule, restated for this
    /// grid's own fixed column count.
    private func move(_ key: KeyEquivalent) -> KeyPress.Result {
        let delta: Int
        switch key {
        case .leftArrow: delta = -1
        case .rightArrow: delta = 1
        case .upArrow: delta = -SymbolPickerLayout.columns
        case .downArrow: delta = SymbolPickerLayout.columns
        default: return .ignored
        }
        let current = focused ?? 0
        let next = min(max(0, current + delta), wells.count - 1)
        focused = next
        return .handled
    }
}

// MARK: - The colour row

/// One well in the colour grid: a palette name, or `nil` for the leading None.
private struct SymbolColorWell: Identifiable {
    let id: Int
    /// The palette name this well writes, or `nil` for the None well — the removal.
    let name: String?
    let label: String
    let isSelected: Bool
}

/// The tint grid under the symbol grid — the leading **None** well and
/// `SymbolPickerCatalog.colorSet`'s eleven tints, 4×3 (the board popover's colour row, 2026-08-07;
/// grown by a row with the palette on 2026-08-09).
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
/// four-column re-division of the symbol grid's width) fits neither.
private struct SymbolColorGrid: View {

    /// The committed tint as written, or `nil` when the key is absent.
    let current: String?
    let layout: SymbolPickerLayout
    let onSelect: (String?) -> Void

    @FocusState private var focused: Int?
    @Environment(\.colorSchemeContrast) private var contrast

    var body: some View {
        LazyVGrid(
            columns: Array(
                repeating: GridItem(.flexible(minimum: layout.colorWellWidth), spacing: layout.wellSpacing),
                count: SymbolPickerLayout.colorColumns
            ),
            spacing: layout.wellSpacing
        ) {
            ForEach(wells) { well in
                Button {
                    onSelect(well.name)
                } label: {
                    swatch(well.name.flatMap(Palette.color(named:)))
                        .overlay(selectionRing(well.isSelected))
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .focusable()
                .focused($focused, equals: well.id)
                .help(well.label)
                .accessibilityLabel(well.label)
                .accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
            }
        }
        .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
            move(press.key)
        }
    }

    /// The None well leads, selected whenever no tint would render — a missing key and an
    /// unresolvable value read the same way here, `ItemSymbol.name(_:fallback:)`'s lenient rule
    /// turned on the colour dimension.
    private var wells: [SymbolColorWell] {
        let isNoneSelected = current.map { Palette.color(named: $0) == nil } ?? true
        var wells = [SymbolColorWell(id: 0, name: nil, label: "No Color", isSelected: isNoneSelected)]
        for (index, name) in SymbolPickerCatalog.colorSet.enumerated() {
            wells.append(SymbolColorWell(id: index + 1, name: name, label: name, isSelected: current == name))
        }
        return wells
    }

    /// A colour swatch, always stroked (`chalk`'s lesson from the style editor's wells: a pale
    /// swatch with no border is an invisible control), with the corner-to-corner slash standing in
    /// for a colour on the None well — Finder's own vocabulary for "there isn't one".
    private func swatch(_ color: Color?) -> some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .fill(color ?? Color(nsColor: .textBackgroundColor))
            .overlay {
                if color == nil {
                    ColorNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded()))
                        .stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
                }
            }
            .overlay(
                RoundedRectangle(cornerRadius: cornerRadius)
                    .strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
            )
            .frame(width: layout.colorWellWidth, height: layout.wellSide)
    }

    private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.25).rounded()) }

    private func selectionRing(_ isSelected: Bool) -> some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .strokeBorder(
                isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
                lineWidth: Accommodations.borderWidth(2, contrast: contrast)
            )
            .padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
    }

    /// `SymbolWellGrid.move(_:)`, at this grid's own four columns.
    private func move(_ key: KeyEquivalent) -> KeyPress.Result {
        let delta: Int
        switch key {
        case .leftArrow: delta = -1
        case .rightArrow: delta = 1
        case .upArrow: delta = -SymbolPickerLayout.colorColumns
        case .downArrow: delta = SymbolPickerLayout.colorColumns
        default: return .ignored
        }
        let current = focused ?? 0
        let next = min(max(0, current + delta), wells.count - 1)
        focused = next
        return .handled
    }
}

/// The None well's corner-to-corner slash — `StyleEditor.swift`'s `NoValueStrike`, restated as a
/// sibling for `SymbolWellFace`'s reason: that type is private to a file whose whole point is
/// staying anchor-agnostic, and one two-point path is cheaper than widening it.
private struct ColorNoneStrike: Shape {
    let inset: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
        path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
        return path
    }
}
