import AppKit
import SwiftUI
import UniformTypeIdentifiers

/// **The one style editor** — "a background palette grid and a curated symbol grid — presented from
/// three anchors" (03-board-ui.md § Styling ▸ Controls), plus the two small surfaces that stand
/// beside it: the quick-style recents row the context menus carry, and the funnel every anchor's
/// writes pass through.
///
/// This file is deliberately anchor-agnostic. It knows a `BoardStore`, a `StyleTarget` and the app's
/// recents, and nothing at all about popovers, card-window sidebars or the board popover — which is
/// what lets "one component, one behavior, three anchors" be a fact about the code rather than a
/// promise. The Style… popover's *lifecycle* lives elsewhere for the same reason: it is a reload
/// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`).
///
/// The one thing here that *names* an anchor is `StyleEditorLayout`, and it names only geometry: a
/// popover is a window this app sizes and a sidebar section is a column the window sizes, so the two
/// cannot share a frame. Nothing behavioral hangs off it — see its own doc comment.

// MARK: - The write funnel

/// Where every style application from every anchor goes: the store's write, and the recents list
/// that the write feeds.
///
/// **It exists so "updated on every background application from any anchor" is structural.** Two
/// surfaces apply backgrounds — the editor's wells and the quick-style row — and the recents list is
/// app-wide state a board store has no business knowing about (02-architecture.md § Per-board app
/// state), so neither of them may be trusted to remember it and neither may be given the job alone.
///
/// **The None well never records.** It is a *removal* — `background` leaves the file — so there is no
/// colour to remember; only `.set` reaches `StyleRecents.record`.
@MainActor
enum StyleCommand {
    /// - Parameter undo: the issuing window's own stack, for the one anchor that has one — the card
    ///   window's sidebar (13-native-undo.md ▸ Rules ▸ two levels). `nil`, which every board-side
    ///   anchor passes, is the board's stack.
    static func apply(
        background: StyleChange = .keep,
        icon: StyleChange = .keep,
        iconColor: StyleChange = .keep,
        to target: StyleTarget,
        in store: BoardStore,
        recents: StyleRecents,
        on undo: CardWindowUndo? = nil
    ) {
        store.applyStyle(to: target, background: background, icon: icon, iconColor: iconColor, on: undo)
        if case let .set(value) = background {
            recents.record(value)
        }
    }
}

// MARK: - The curated symbol sets

/// The symbol grid's contents — **three hand-picked, level-specific sets** (03-board-ui.md § Styling
/// ▸ Controls: "three hand-picked, level-specific sets … for boards/lanes/cards"), because a board's
/// identity, a lane's stage and a card's content are different things to reach for a glyph about, and
/// one flat list made every context scroll past the other two's symbols to find its own
/// (2026-08-09, splitting the original single ~65-entry list this card's `combined` still records).
///
/// Each set is seeded from that original list and `SymbolPickerCatalog.defaultSet`, reorganized by
/// which level the glyph actually reads as being *about*:
///
/// - **`boards`**: project/container/identity-flavored — a board is a whole workspace, so this leans
///   on places, organizations and domains (a briefcase, a house, a globe) over single-item content.
/// - **`lanes`**: workflow/stage/status-flavored — a lane is a stage a card passes through, so this
///   leans on flow and gating glyphs (arrows, an hourglass, a checkmark, a stop sign).
/// - **`cards`**: work-item/content-flavored — a card is one piece of work, so this keeps the
///   documents-and-craft vocabulary the original single list was built around (a doc, a hammer, a
///   paperclip).
///
/// **Overlap is allowed where a glyph is genuinely apt at every level** (`flag`, `star`, `bolt`: a
/// status marker means the same thing on a board, a lane or a card) — the three lists are not a
/// partition, because forcing one would mean dropping a symbol from two contexts it actually fits.
///
/// **Filtered through `ItemSymbol.exists` at read time**, for the same reason the renderer is
/// lenient: symbol inventories grow per macOS release, and a name this OS does not know would draw
/// an empty well. A curated list is a convenience, never a claim about the running system.
enum CuratedSymbols {

    /// Board-level: project/container/identity-flavored. A stored constant rather than a computed
    /// property: the list is the design decision, and `availableBoards` is the only thing the OS
    /// gets a say in.
    static let boards: [String] = [
        // Identity and structure
        "rectangle.split.3x1", "square.grid.2x2", "square.grid.3x3", "rectangle.3.group", "cube",
        "shippingbox",
        // Places and organizations
        "building.2", "building.columns", "house", "map", "globe", "location",
        // Containers
        "folder", "archivebox", "tray.full", "server.rack",
        // Domains
        "briefcase", "graduationcap", "cart", "airplane", "car", "book", "newspaper",
        "gamecontroller", "paintpalette",
        // Status and markers
        "flag", "flag.checkered", "star", "target", "bolt", "sparkles",
        // Data
        "chart.bar", "chart.pie", "chart.line.uptrend.xyaxis", "network",
        // People
        "person.2", "person.3",
        // Work
        "hammer", "wrench.and.screwdriver", "lightbulb",
    ]

    /// Lane-level: workflow/stage/status-flavored.
    static let lanes: [String] = [
        // Status and flow
        "square.stack", "tray", "tray.full", "arrow.right.circle", "arrow.triangle.branch",
        "arrow.triangle.2.circlepath", "arrow.up.arrow.down", "checkmark.circle", "checkmark.seal",
        "xmark.circle", "exclamationmark.triangle", "questionmark.circle", "circle", "pause.circle",
        "play.circle", "stop.circle",
        // Time and pacing
        "hourglass", "clock", "alarm", "timer", "calendar",
        // Review and gates
        "eye", "flag", "flag.checkered", "target", "bolt",
        // Containers
        "folder", "archivebox", "shippingbox",
        // People
        "person", "person.2", "bubble.left", "bubble.left.and.bubble.right",
        // Priority and risk
        "exclamationmark.circle", "flame", "star",
    ]

    /// Card-level: work-item/content-flavored — the vocabulary the original single list was built
    /// around.
    static let cards: [String] = [
        // Documents and content
        "doc.text", "doc.on.doc", "doc.richtext", "note.text", "list.bullet",
        "list.bullet.rectangle", "checklist", "book", "bookmark", "paperclip",
        // Work and craft
        "hammer", "wrench.and.screwdriver", "gearshape", "ant", "lightbulb", "paintbrush", "pencil",
        "terminal",
        // Markers
        "tag", "link", "pin", "flag", "star", "flame", "leaf", "sparkles", "heart",
        // People and communication
        "person", "person.2", "bubble.left", "envelope", "megaphone",
        // Status
        "checkmark.circle", "xmark.circle", "exclamationmark.triangle", "clock",
        // Security and other
        "lock", "key", "trash",
    ]

    /// The three sets combined, in stable order (boards, then lanes, then cards) and deduplicated —
    /// close kin to the original single list this card split apart, kept alive as: (1) the style
    /// editor's fallback for a target that spans more than one level, and (2) the seed
    /// `SymbolPickerCatalog`'s own full-catalog fallback merges in.
    ///
    /// A spanning target is unreachable today — 04-interactions.md's cards-XOR-lanes rule keeps a
    /// live selection homogeneous, and `BoardStore.styleLevel(of:)` collapses even a hypothetical mix
    /// to `.card` for the leading well's default — but the symbol grid asks the finer question
    /// (`BoardStore.styleTargetSpansLevels`) rather than trust that collapse, so a loosened invariant
    /// would degrade to "shows everything" instead of silently narrowing to one level's vocabulary.
    static var combined: [String] {
        var seen = Set<String>()
        return (boards + lanes + cards).filter { seen.insert($0).inserted }
    }

    /// The level's set, filtered to what this Mac can actually draw — the style editor's grid reads
    /// this once it knows which level a target sits at (`BoardStore.styleLevel(of:)`).
    static func available(for level: StyleLevel) -> [String] {
        switch level {
        case .board: boards.filter(ItemSymbol.exists)
        case .lane: lanes.filter(ItemSymbol.exists)
        case .card: cards.filter(ItemSymbol.exists)
        }
    }

    static var availableBoards: [String] { boards.filter(ItemSymbol.exists) }
    static var availableLanes: [String] { lanes.filter(ItemSymbol.exists) }
    static var availableCards: [String] { cards.filter(ItemSymbol.exists) }
    /// The mixed-target fallback, filtered — `combined`'s own doc comment.
    static var availableCombined: [String] { combined.filter(ItemSymbol.exists) }

    /// The style editor's grid-content decision, pulled out as a pure function so it is testable
    /// without a view on screen: `available(for:)` when `target` sits at one level, `availableCombined`
    /// when it spans more than one (`BoardStore.styleTargetSpansLevels`'s own doc comment on why that
    /// question gets asked at all). `StyleEditorView.curatedSymbols` is a one-line call to this.
    static func availableForStyleEditor(level: StyleLevel, spansLevels: Bool) -> [String] {
        spansLevels ? availableCombined : available(for: level)
    }
}

// MARK: - The anchor's chrome

/// Everything about the editor that is the **anchor's** business rather than the editor's: how wide
/// it is, what padding it brings, how many wells fall in a row, and whether its symbol grid scrolls.
///
/// **It exists so "one component, one behavior, another anchor" survives an anchor that is not a
/// popover** (05-card-window.md ▸ Style: the card sidebar embeds this same editor). A popover is a
/// window the app sizes; a sidebar section is a column the window sizes — and the 268-point frame
/// that makes the first one narrow enough to sit beside a card would overflow the second by 70
/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the
/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves.
///
/// ### Everything here scales with the body font
///
/// A well is a **container for a glyph**, and the glyph inside it is drawn at `.imageScale(.medium)`
/// — a relative size. So a well fixed at 20 points would be overrun by its own symbol at a large
/// system text size, and 10-accessibility.md's "every well Tab-reachable and labeled by name" would
/// be true of controls the user could no longer read. Every figure below is therefore a multiple of
/// the body point size, chosen to reproduce today's numbers at the standard 13pt body — the same
/// derivation, and the same rationale, as `BoardMetrics` and `CardWindowMetrics`.
struct StyleEditorLayout: Equatable {

    /// One well's side, and the gap between two — the numbers the grids are laid out on, derived
    /// once so the fit rule below and the wells themselves cannot drift apart.
    ///
    /// 1.55 em and 0.45 em: 20pt and 6pt at the standard 13pt body, which is what the grids have
    /// always drawn.
    static func wellSide(bodyPointSize: CGFloat) -> CGFloat {
        max(1, (bodyPointSize * 1.55).rounded())
    }

    static func wellSpacing(bodyPointSize: CGFloat) -> CGFloat {
        max(1, (bodyPointSize * 0.45).rounded())
    }

    /// The gap between the editor's two sections — and, at the popover anchor, its inset too, which
    /// is why it is one figure rather than two that happen to agree. The *sidebar* anchor brings no
    /// inset of its own (its column is already gutted) but still wants the sections apart, so the
    /// spacing has to survive `padding` going to zero.
    static func sectionSpacing(bodyPointSize: CGFloat) -> CGFloat {
        max(1, (bodyPointSize * 1.08).rounded())
    }

    /// A fixed width, or `nil` to take whatever the anchor proposes.
    var width: CGFloat?
    /// The editor's own inset. Zero where the anchor already insets its column.
    var padding: CGFloat
    var backgroundColumns: Int
    var symbolColumns: Int
    /// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" —
    /// the grid then draws whole and the anchor scrolls it.
    var symbolGridMaximumHeight: CGFloat?
    /// The well geometry this layout's grids draw on — carried on the value rather than read from
    /// the statics above, so a view has one thing to consult and the two can never disagree about
    /// which text size they were computed for.
    var wellSide: CGFloat
    var wellSpacing: CGFloat

    /// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and
    /// a symbol grid that scrolls within it.
    ///
    /// Seventeen background wells (None + the sixteen) fall as 7 + 7 + 3 — two rows until the
    /// palette grew on 2026-08-09, three since — which keeps the popover narrow
    /// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
    /// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the
    /// popover fits beside a card on a laptop screen.
    ///
    /// The column counts are the design's own and stay fixed at every text size — 03-board-ui.md
    /// names the 7 + 6 fall — while the *frame* around them grows, which is what keeps the 7 wells
    /// inside it (20.6 em is 268pt at the standard body size, the number 03 settled on).
    static func popover(bodyPointSize: CGFloat) -> StyleEditorLayout {
        StyleEditorLayout(
            width: (bodyPointSize * 20.6).rounded(),
            padding: sectionSpacing(bodyPointSize: bodyPointSize),
            backgroundColumns: 7,
            symbolColumns: 8,
            symbolGridMaximumHeight: (bodyPointSize * 12.9).rounded(),
            wellSide: wellSide(bodyPointSize: bodyPointSize),
            wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
        )
    }

    /// The card window's sidebar section (05-card-window.md ▸ Style).
    ///
    /// - **No width and no padding of its own**: the sidebar's width is `CardWindowMetrics`' one
    ///   decision and its gutter is already applied to the whole section stack, so an editor with an
    ///   opinion here would either overflow the column or inset twice.
    /// - **As many wells per row as the column holds**, rather than the popover's 7 and 8 — the
    ///   sidebar is narrower than the popover at every text size, and a grid wider than its column is
    ///   a grid with wells the pointer cannot reach.
    /// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view
    ///   inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason).
    static func sidebar(contentWidth: CGFloat, bodyPointSize: CGFloat) -> StyleEditorLayout {
        let columns = columns(fitting: contentWidth, bodyPointSize: bodyPointSize)
        return StyleEditorLayout(
            width: nil,
            padding: 0,
            backgroundColumns: columns,
            symbolColumns: columns,
            symbolGridMaximumHeight: nil,
            wellSide: wellSide(bodyPointSize: bodyPointSize),
            wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
        )
    }

    /// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than
    /// one. Pure, and the whole of "the grid never overflows the column it was given".
    ///
    /// Both the column and the wells grow with the text size, so the count stays roughly stable
    /// across text sizes rather than collapsing to one: `CardWindowMetrics`' sidebar is 26 body
    /// *characters* wide and a well is 1.55 body *ems*, and the ratio between those does not move.
    static func columns(fitting width: CGFloat, bodyPointSize: CGFloat) -> Int {
        let side = wellSide(bodyPointSize: bodyPointSize)
        let spacing = wellSpacing(bodyPointSize: bodyPointSize)
        return max(1, Int((width + spacing) / (side + spacing)))
    }
}

// MARK: - The editor

/// The style editor: a background section and a symbol section (each omissible — `showsBackground`/
/// `showsSymbols`, since 03's anchors compose the halves they need), each a leading "no value" well
/// followed by its grid, with the target set's current value stated beside the section title.
///
/// ### What it shows for a batch
///
/// Per dimension, `StyleFieldState`: every target agreeing shows that well selected, a disagreement
/// shows nothing selected and reads "—" ("Mixed" to VoiceOver — 10-accessibility.md's
/// never-colour-alone rule), and an off-palette value — a hand-written hex, an uncurated symbol —
/// states itself verbatim beside the title, outside the grids, where "choosing any well replaces
/// it".
///
/// ### Keyboard
///
/// "Inside the editor the grids are arrow-navigable and every well Tab-reachable" (§ Controls,
/// 10-accessibility.md): every well is a focusable button, and each grid moves focus by one on
/// ←/→ and by a row on ↑/↓.
struct StyleEditorView: View {

    let store: BoardStore
    let recents: StyleRecents
    let target: StyleTarget
    /// The anchor's geometry, and nothing else (`StyleEditorLayout`). `nil` takes the popover's, so
    /// the two anchors that were here first say nothing about it — resolved in `body` rather than
    /// defaulted in the declaration, because the popover's geometry now depends on the live text
    /// size and a default argument cannot read one.
    var layout: StyleEditorLayout?

    /// **Which stack this anchor's writes register on** — the card window's own when the editor is
    /// mounted in one (`CardStyleSection`), and `nil`, the board's, everywhere else
    /// (13-native-undo.md ▸ Rules ▸ two levels). It sits beside `layout` and arrives the same way —
    /// the anchor telling the shared component about itself — but unlike `layout` it is not geometry:
    /// a colour chosen in a card window is one of that window's session gestures.
    var undo: CardWindowUndo?

    /// Whether the symbol section appears at all. On everywhere but the board info popover, whose
    /// inline `SymbolPicker` beside the rename field owns the board's glyph now — two surfaces
    /// writing the same key in one popover would make the second read as a different setting.
    var showsSymbols: Bool = true

    /// Whether the background section appears at all. On everywhere but the card window sidebar,
    /// where the captioned `ColorSwatchPicker` column is the background story (03 ▸ Styling ▸
    /// Controls, the 2026-08-06 anchor-ownership rule): the sidebar is the narrow context that
    /// rectangle was built for, and grid-plus-rectangle over one value read as two settings —
    /// `showsSymbols`' reasoning, pointed the other way.
    var showsBackground: Bool = true

    /// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
    /// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
    /// rule).
    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    var body: some View {
        let layout = self.layout ?? .popover(bodyPointSize: pointSize)
        let subjects = store.styleSubjects(of: target)
        let background = StyleFieldState.resolve(subjects.map(\.background))
        let icon = StyleFieldState.resolve(subjects.map(\.icon))

        VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
            targetCaption(count: subjects.count)
            if showsBackground {
                backgroundSection(background, layout: layout)
            }
            if showsBackground && showsSymbols {
                Divider()
            }
            if showsSymbols {
                symbolSection(icon, layout: layout)
            }
        }
        .padding(layout.padding)
        .frame(width: layout.width)
        // The read-only lock and the focused-editor rule disable every mutating surface, not only
        // the menu items (02-architecture.md § The lock's scope) — an editor whose wells would be
        // refused should not look available. The popover stays *open*: the lock is a condition the
        // banner is already explaining, not a reason to yank a surface out from under the pointer.
        .disabled(!store.acceptsBoardMutations)
    }

    /// Who is being styled — one quiet line, because a batch gesture with no statement of its scope
    /// is the one place this editor could silently do more than the user meant.
    private func targetCaption(count: Int) -> some View {
        Text(caption(count: count))
            .font(.caption)
            .foregroundStyle(.secondary)
    }

    private func caption(count: Int) -> String {
        switch store.styleLevel(of: target) {
        case .board: "Board"
        case .lane: count == 1 ? "Lane" : "\(count) lanes"
        case .card: count == 1 ? "Card" : "\(count) cards"
        }
    }

    // MARK: - Background

    /// The palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: "palette-only
    /// in-app … plus a leading **None** well that removes the `background` key"), an **Other…** row
    /// onto the system Colors panel (2026-08-09), and — on the board target alone — a **Choose
    /// Image…** row onto a standard, image-restricted `NSOpenPanel` (ruled 2026-08-09, reversing
    /// BackgroundField.swift's original "there is no image picker and none is planned").
    ///
    /// **"Palette-only in-app" is the sentence that changed**, and it changed before this card:
    /// the card sidebar's background control shipped an **Other…** row of its own, so the card
    /// window's sidebar could already write an arbitrary hex while the Style… popover — the
    /// *primary* styling surface — could not. This closes that gap rather than opening a new one.
    /// Seventeen wells at seven columns is three rows where the old twelve made two, which is the
    /// other half of the same change — and, since 2026-08-10, the same 7-wide fit `ColorSwatchPicker`
    /// reuses for its own popover grid.
    ///
    /// **Choose Image… is absent, not disabled, off the board.** `background.image` is a board-root
    /// field alone — a lane or a card has a colour well and nothing else to point an image subkey at
    /// (`BoardModel.backgroundImage`; `Lane`/`Card` carry no such field) — so an item-targeted editor
    /// has no key this row could write, the same reasoning `showsSymbols`/`showsBackground` already
    /// apply to their own anchors. `store.styleLevel(of: target) == .board` is the same predicate the
    /// symbol section already computes for its own leading well's default, asked here for the row
    /// instead.
    private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
        let isBoardTarget = store.styleLevel(of: target) == .board
        return VStack(alignment: .leading, spacing: layout.wellSpacing) {
            sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
            StyleWellGrid(
                wells: backgroundWells(state),
                columns: layout.backgroundColumns,
                layout: layout,
                apply: { change in
                    StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
                }
            )
            HStack(spacing: layout.wellSpacing) {
                Spacer(minLength: 0)
                if isBoardTarget {
                    Button("Choose Image…") { chooseBackgroundImage() }
                        .buttonStyle(.link)
                        .font(.caption)
                }
                Button("Other…") { openBackgroundPanel(state) }
                    .buttonStyle(.link)
                    .font(.caption)
            }
        }
    }

    /// Opens the Colors panel on the background dimension, seeded with what the target set currently
    /// reads (nothing, for a mixed set — there is no one colour to start from).
    ///
    /// **The panel fires continuously**, so this debounces exactly as `CardStyleSection` does for the
    /// colour combo's own drag: ~400ms trailing, cancelled and replaced on every tick, so a drag
    /// writes once it settles instead of once per pixel it passes through.
    ///
    /// It goes to `store.applyStyle` rather than through `StyleCommand.apply`, which is the same call
    /// the sidebar's debounce makes and for the same reason: `StyleRecents` remembers *deliberate
    /// palette picks*, and a drag that sweeps through six palette-exact colours on its way somewhere
    /// else would otherwise flush the row it is supposed to be helping.
    private func openBackgroundPanel(_ state: StyleFieldState) {
        let seed: NSColor? = if case let .uniform(value) = state { Palette.nsColor(for: value) } else { nil }
        let target = self.target
        SharedColorPanelSession.present(seed: seed, matching: Palette.backgrounds) { [weak store, weak undo] value in
            guard let store else { return }
            store.applyStyle(to: target, background: .set(value), icon: .keep, on: undo)
        }
    }

    /// **Choose Image…** — opens the panel and, on a pick, hands the URL to `ChosenBoardBackground`,
    /// the whole of this row's non-panel logic. Split out so a modal `NSOpenPanel.runModal()` is the
    /// only thing this method does that a test cannot drive.
    private func chooseBackgroundImage() {
        guard let url = BoardBackgroundImagePanel.chooseImage() else { return }
        ChosenBoardBackground.apply(from: url, to: store)
    }

    private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] {
        var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
        for (index, color) in Palette.backgrounds.enumerated() {
            wells.append(StyleWell(
                id: index + 1,
                face: .color(color.name),
                label: color.name,
                change: .set(color.name),
                isSelected: state == .uniform(color.name)
            ))
        }
        return wells
    }

    /// What the background dimension currently reads — including the verbatim off-palette case, which
    /// is exactly why this is a chip beside the title and not a highlighted well.
    private func backgroundCurrent(_ state: StyleFieldState) -> CurrentValue {
        switch state {
        case .unset: CurrentValue(face: .noValue, text: "None")
        case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed")
        case let .uniform(value): CurrentValue(face: .color(value), text: value)
        }
    }

    // MARK: - Symbol

    /// The curated grid and its leading default well — "its leading well is the level's default
    /// symbol and removes the `icon` key" (§ Controls).
    private func symbolSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
        let level = store.styleLevel(of: target)
        let fallback = ItemSymbol.default(for: level)
        return VStack(alignment: .leading, spacing: layout.wellSpacing) {
            sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback), layout: layout)
            symbolGrid(state, fallback: fallback, layout: layout)
        }
    }

    /// The curated grid, scrolling within its own cap or drawn whole — the anchor's call
    /// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the
    /// popover and the card sidebar.
    @ViewBuilder
    private func symbolGrid(_ state: StyleFieldState, fallback: String, layout: StyleEditorLayout) -> some View {
        let grid = StyleWellGrid(
            wells: symbolWells(state, fallback: fallback),
            columns: layout.symbolColumns,
            layout: layout,
            apply: { change in
                StyleCommand.apply(icon: change, to: target, in: store, recents: recents, on: undo)
            }
        )
        if let maximumHeight = layout.symbolGridMaximumHeight {
            ScrollView(.vertical) { grid }
                .frame(maxHeight: maximumHeight)
        } else {
            grid
        }
    }

    /// Which set the curated grid draws from — `CuratedSymbols.availableForStyleEditor`'s decision,
    /// fed `target`'s own level (`BoardStore.styleLevel(of:)`) and whether it spans more than one
    /// (`BoardStore.styleTargetSpansLevels`). The decision itself lives on `CuratedSymbols` so it is
    /// testable without a view on screen; this is the one-line wiring.
    private var curatedSymbols: [String] {
        CuratedSymbols.availableForStyleEditor(
            level: store.styleLevel(of: target),
            spansLevels: store.styleTargetSpansLevels(target)
        )
    }

    private func symbolWells(_ state: StyleFieldState, fallback: String) -> [StyleWell] {
        var wells = [StyleWell(
            id: 0,
            face: .defaultSymbol(fallback),
            label: "Default (\(fallback))",
            change: .remove,
            isSelected: state == .unset
        )]
        for (index, name) in curatedSymbols.enumerated() {
            wells.append(StyleWell(
                id: index + 1,
                face: .symbol(name),
                label: name,
                change: .set(name),
                isSelected: state == .uniform(name)
            ))
        }
        return wells
    }

    private func symbolCurrent(_ state: StyleFieldState, fallback: String) -> CurrentValue {
        switch state {
        case .unset: CurrentValue(face: .defaultSymbol(fallback), text: "Default")
        case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed")
        case let .uniform(value): CurrentValue(face: .symbol(value), text: value)
        }
    }

    // MARK: - Section chrome

    private func sectionHeader(_ title: String, current: CurrentValue, layout: StyleEditorLayout) -> some View {
        HStack(spacing: layout.wellSpacing) {
            Text(title)
                .font(.subheadline.weight(.semibold))
            Spacer(minLength: layout.wellSpacing)
            if let face = current.face {
                // A touch smaller than a well: this is a *statement* of the current value, not a
                // control, and it must not read as a fourteenth swatch that can be clicked.
                StyleWellFace(face: face, size: (layout.wellSide * 0.7).rounded())
            }
            Text(current.text)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("\(title), \(current.spoken ?? current.text)")
    }
}

// MARK: - Choose Image…

/// The panel behind **Choose Image…** — a single-selection, image-restricted `NSOpenPanel`
/// (03-board-ui.md § Styling ▸ Controls, ruled 2026-08-09) — the same open-panel shape
/// `AttachmentPanel` (CardAttachments.swift) uses for attachments, narrowed to what `UTType.image`
/// claims.
///
/// **Restricted, unlike the attachments panel's "every file type"**: a background is a picture and
/// nothing else, so `allowedContentTypes` states that up front — a filesystem provider that honours
/// it dims or refuses everything else before the panel ever returns. That is convenience, not the
/// boundary: `ChosenBoardBackground.apply(from:to:)` re-checks the pick regardless, because a drag
/// onto an open panel is not gated the same way a click on a listed row is.
@MainActor
enum BoardBackgroundImagePanel {
    static func chooseImage() -> URL? {
        let panel = NSOpenPanel()
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        panel.allowsMultipleSelection = false
        panel.resolvesAliases = true
        panel.allowedContentTypes = [.image]
        panel.prompt = "Choose"
        panel.message = "Choose an image for the board background."
        guard panel.runModal() == .OK else { return nil }
        return panel.urls.first
    }
}

/// **Choose Image…'s whole non-panel logic** — split out from `StyleEditorView.chooseBackgroundImage`
/// so the guard and the data flow are testable without driving a modal `NSOpenPanel`, the way
/// `PastedImage`'s pure classification is tested apart from `NSPasteboard`.
///
/// Three steps, none of them decoding the picture: the cheap guard first (`PastedImage.isImageName`
/// — the panel's own filter, restated for a bypass such as a drag onto the panel), the sandboxed read
/// second (security-scoped exactly as `CardAttachments.add()` opens one), and
/// `BoardStore.applyChosenBackground` last, which owns the naming, the one write bracket, the orphan
/// tidy's composition and the undo step.
enum ChosenBoardBackground {
    @discardableResult
    @MainActor
    static func apply(from url: URL, to store: BoardStore) -> Bool {
        guard PastedImage.isImageName(url.lastPathComponent) else { return false }

        let scoped = url.startAccessingSecurityScopedResource()
        defer { if scoped { url.stopAccessingSecurityScopedResource() } }
        guard let data = try? Data(contentsOf: url) else { return false }

        return store.applyChosenBackground(data: data, baseName: url.lastPathComponent)
    }
}

// MARK: - Current value

/// The current-value chip beside a section title: the one place an off-palette value is stated
/// ("labeled verbatim, outside the grids"), and the one place a mixed batch reads "—".
private struct CurrentValue {
    let face: StyleWellFace.Face?
    let text: String
    /// What VoiceOver says when the written text would not do — "Mixed" for the em dash, which is a
    /// glyph rather than a word (10-accessibility.md: a batch's mixed state "reads as 'mixed', never
    /// conveyed by highlight alone").
    var spoken: String?

    init(face: StyleWellFace.Face?, text: String, spoken: String? = nil) {
        self.face = face
        self.text = text
        self.spoken = spoken
    }
}

// MARK: - Wells

/// One well: what it draws, what it is called, and what clicking it asks of the frontmatter key.
private struct StyleWell: Identifiable {
    let id: Int
    let face: StyleWellFace.Face
    let label: String
    let change: StyleChange
    let isSelected: Bool
}

/// A well's face — a colour, a symbol, or one of the two "no value" leading wells.
private struct StyleWellFace: View {

    enum Face: Equatable {
        /// The background grid's None well: a slashed empty swatch, Finder's own vocabulary for
        /// "there isn't one".
        case noValue
        /// A palette name or a hand-written hex. An unresolvable value draws like `noValue` — the
        /// renderer's lenient rule, which is what makes an off-palette chip honest about a value the
        /// app cannot read.
        case color(String)
        case symbol(String)
        /// The symbol grid's leading well: the level's default, drawn quieter than a chosen one so
        /// "no symbol set" and "this symbol set" do not look alike.
        case defaultSymbol(String)
    }

    let face: Face
    /// The well's side, supplied by the caller because it is font-derived and the caller is the one
    /// holding the layout it came from (`StyleEditorLayout.wellSide`).
    let size: CGFloat

    /// Increase Contrast, for the swatch's border below (10-accessibility.md; `Accommodations`).
    @Environment(\.colorSchemeContrast) private var contrast

    var body: some View {
        switch face {
        case .noValue:
            swatch(nil)
        case let .color(value):
            swatch(Palette.color(named: value))
        case let .symbol(name):
            glyph(name, tint: AnyShapeStyle(.primary))
        case let .defaultSymbol(name):
            glyph(name, tint: AnyShapeStyle(.secondary))
        }
    }

    /// A colour well. **Always stroked**: `chalk` is `#FFFFFF` and an unbordered white swatch is an
    /// invisible control on a light popover (10-accessibility.md's contrast stance turned on the
    /// app's own chrome). A `nil` colour adds the diagonal strike that means "none".
    private func swatch(_ color: Color?) -> some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .fill(color ?? Color(nsColor: .textBackgroundColor))
            .overlay {
                if color == nil {
                    NoValueStrike(inset: strikeInset)
                        .stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
                }
            }
            // A point heavier under Increase Contrast — this hairline is the *only* thing separating
            // a `chalk` well from the popover it sits on, which is the same reason it is drawn at all
            // (10-accessibility.md's contrast stance turned on the app's own chrome).
            .overlay(
                RoundedRectangle(cornerRadius: cornerRadius)
                    .strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
            )
            .frame(width: size, height: size)
    }

    /// The swatch's radius and its "none" strike's inset, as fractions of the well — so both follow
    /// the well when the text size grows it (10-accessibility.md's full-relative-scaling rule).
    private var cornerRadius: CGFloat { max(1, (size * 0.2).rounded()) }

    private var strikeInset: CGFloat { max(1, (size * 0.15).rounded()) }

    private func glyph(_ name: String, tint: AnyShapeStyle) -> some View {
        Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
            .imageScale(.medium)
            .foregroundStyle(tint)
            .frame(width: size, height: size)
    }
}

/// The corner-to-corner slash on the None well — the pathfinder's swatch vocabulary, kept because it
/// is also the system's (an empty colour well slashes in Finder's own tag editor).
private struct NoValueStrike: Shape {
    /// How far in from each corner the stroke starts — a fraction of the well, supplied by the
    /// caller, so it follows the well when the text size grows it.
    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
    }
}

/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid (10-accessibility.md ▸ Style
/// editor).
///
/// Focus is the grid's own state rather than the editor's, because the two grids are independently
/// navigable and Tab is what crosses between them — which is exactly what the accessibility doc asks
/// for ("the grids are arrow-navigable and every well Tab-reachable"). The arrow handler sits on the
/// container: a focused `Button` does not consume arrow keys, so the press bubbles here, and moving
/// focus is all it does — **selection is never implied by focus**, since a well's job is to write to
/// disk and a stray arrow key must not restyle a board.
private struct StyleWellGrid: View {

    let wells: [StyleWell]
    let columns: Int
    /// The anchor's geometry — the well side and spacing this grid lays out on
    /// (`StyleEditorLayout`, all font-derived).
    let layout: StyleEditorLayout
    let apply: (StyleChange) -> Void

    @FocusState private var focused: Int?

    /// Increase Contrast, for the selection ring below — 10-accessibility.md names the selection
    /// indicator specifically, and this is the style editor's ("the current value is stated by
    /// trait", whose visible half is this ring).
    @Environment(\.colorSchemeContrast) private var contrast

    var body: some View {
        LazyVGrid(
            columns: Array(
                repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
                count: columns
            ),
            spacing: layout.wellSpacing
        ) {
            ForEach(wells) { well in
                Button {
                    apply(well.change)
                } label: {
                    StyleWellFace(face: well.face, size: layout.wellSide)
                        .overlay(selectionRing(well.isSelected))
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                // **Every well is Tab-reachable and labeled by name** (10-accessibility.md ▸ Style
                // editor). The focus ring is deliberately *not* disabled here, unlike the board
                // strip's: a well is a control, and Full Keyboard Access has to be able to show
                // which one Tab landed on.
                .focusable()
                .focused($focused, equals: well.id)
                .help(well.label)
                .accessibilityLabel(well.label)
                // "The current value is stated by trait" — never by the highlight alone.
                .accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
            }
        }
        .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
            move(press.key)
        }
    }

    /// The selected well's ring — a point heavier under Increase Contrast, which is 10's
    /// "strengthens … the selection indicator" landing on the one selection indicator this component
    /// has (`Accommodations`). The trait beside it is what makes the state readable without it.
    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)
            )
            // Drawn *outside* the well, by the ring's own half-width, so a heavier ring under
            // Increase Contrast grows outward instead of eating into the swatch it is marking.
            .padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
    }

    /// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short
    /// would wrap into a hole, and Finder's own icon grids clamp too.
    private func move(_ key: KeyEquivalent) -> KeyPress.Result {
        let delta: Int
        switch key {
        case .leftArrow: delta = -1
        case .rightArrow: delta = 1
        case .upArrow: delta = -columns
        case .downArrow: delta = columns
        default: return .ignored
        }
        let current = focused ?? 0
        let next = min(max(0, current + delta), wells.count - 1)
        focused = next
        return .handled
    }
}

// MARK: - Context-menu surfaces

/// The two style entries every context menu carries — Style… and the quick-style recents row
/// (11-command-nexus.md ▸ Context menus; 03-board-ui.md § Styling ▸ Controls).
///
/// One view for both menus because the entries are identical on a card and on a lane: only the
/// *target* differs, and that is the caller's to compute (the clicked item, or the selection it
/// belongs to).
///
/// **The target arrives as a closure, deliberately.** `.contextMenu`'s builder is non-escaping, so a
/// caller's menu is assembled while its own body runs — and a target computed eagerly is a
/// `store.selection` read at body time, which under Observation subscribes that body to every
/// selection change on the board. Deferring it means a card face's context menu costs its face
/// nothing until a row actually acts (`CardFaceView.isSelected` has the measurement).
struct StyleMenuItems: View {

    let store: BoardStore
    let recents: StyleRecents
    let target: () -> StyleTarget

    var body: some View {
        Button("Style…") {
            store.transient.beginStyleEditor(for: target())
        }
        .disabled(!store.acceptsBoardMutations)

        QuickStyleRow(store: store, recents: recents, target: target)
    }
}

/// The quick-style row: "one compact row of recently used backgrounds … one-click recolor for the
/// common case; the pathfinder's second full-palette tier is gone" (03-board-ui.md § Styling ▸
/// Controls).
///
/// A `.palette`-styled `Picker` is what macOS renders as a horizontal swatch strip inside a menu —
/// the pathfinder's finding, and the only shape that puts colours in a menu row at all. AppKit draws
/// a menu item from an image and a title, so the dots are `NSImage`s (`PaletteSwatch`) rather than
/// SwiftUI shapes.
///
/// **Absent until it has something to offer.** A brand-new install has no recents, and an empty
/// picker in a context menu is a row that looks broken.
///
/// The target is deferred for `StyleMenuItems`' reason, and this row is where the deferral has to
/// hold: the `Binding` below is read when the menu shows a checkmark and written when a dot is
/// picked, both of them the picker's own doing rather than the enclosing card face's body.
struct QuickStyleRow: View {

    let store: BoardStore
    let recents: StyleRecents
    let target: () -> StyleTarget

    /// A sentinel for "the current value is not one of these", so a mixed batch — or a background
    /// that has aged out of the recents — leaves the row unchecked rather than checking the wrong
    /// dot. It is never a rendered option, so it can never be picked.
    private enum Choice: Hashable {
        case value(String)
        case other
    }

    var body: some View {
        if !recents.backgrounds.isEmpty {
            Picker("Recent Colors", selection: selection) {
                ForEach(recents.backgrounds, id: \.self) { name in
                    Label {
                        Text(name)
                    } icon: {
                        Image(nsImage: PaletteSwatch.circleImage(for: name))
                    }
                    .tag(Choice.value(name))
                }
            }
            .pickerStyle(.palette)
            .disabled(!store.acceptsBoardMutations)
        }
    }

    private var selection: Binding<Choice> {
        Binding(
            get: {
                let state = StyleFieldState.resolve(store.styleSubjects(of: target()).map(\.background))
                guard case let .uniform(value) = state, recents.backgrounds.contains(value) else { return .other }
                return .value(value)
            },
            set: { picked in
                guard case let .value(name) = picked else { return }
                StyleCommand.apply(background: .set(name), to: target(), in: store, recents: recents)
            }
        )
    }
}

// MARK: - Presentation

/// The Style… popover's content: the editor, aimed at **the session's own target set**.
///
/// Reading the target from the session rather than re-deriving it from the selection is what makes
/// the settled lifecycle visible: the popover was aimed once, at what the gesture named, and from
/// then on it follows *that* set as members vanish — a selection change behind an open popover must
/// not silently re-aim it, and a right-click on an unselected card must keep styling that card.
struct StyleEditorPopover: View {

    let store: BoardStore
    let recents: StyleRecents

    var body: some View {
        // Empty for the frame between a session ending and the popover's own dismissal landing —
        // the binding is already `false`, so this is a formality rather than a state.
        if let session = store.transient.styleEditor {
            StyleEditorView(store: store, recents: recents, target: session.target)
        }
    }
}

/// Whether *this* anchor is the one showing the open Style… popover.
///
/// Every candidate surface — a card face, a lane header, the strip itself — binds its popover
/// through this, and `StyleEditorSession.presentationAnchor(in:)` answers for exactly one of them.
/// So the popover follows its target set across reloads (an anchor that vanishes hands it to the next
/// live target) and only an *emptied* set takes it down, which is the settled lifecycle.
///
/// The setter is narrowed to this anchor's own dismissal: a session that has moved to another anchor
/// must not be discarded by the surface it just left.
@MainActor
func styleEditorPresentation(_ store: BoardStore, anchor: ItemID?) -> Binding<Bool> {
    Binding(
        // Spelled with an explicit `guard let` rather than optional chaining: `nil == nil` is
        // `true`, so a chained comparison would tell the board strip (whose anchor *is* `nil`) to
        // present a popover nobody opened.
        get: {
            guard let session = store.transient.styleEditor else { return false }
            return session.presentationAnchor(in: store.snapshot) == anchor
        },
        set: { presented in
            guard !presented,
                  let session = store.transient.styleEditor,
                  session.presentationAnchor(in: store.snapshot) == anchor
            else { return }
            store.transient.discardStyleEditor()
        }
    )
}
