import AppKit
import SwiftUI

/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
/// colour combo's own shape — a flat swatch of the current value filling almost the whole control,
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's
/// twelve palette colours, an off-palette current value stated verbatim when there is one, and
/// **Other…**, which hands off to that same panel — the swatch and **Other…** are two doors onto
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
///
/// It is the second surface `background`/`iconColor` can be set from, beside the well grid
/// (`StyleEditor.swift`'s `StyleEditorView`) — the well grid stays exactly as it is; this is a
/// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s
/// own labeled row).
///
/// ### Two halves, the same split every other file here draws
///
/// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model**
/// — item lists, selection matching, hex normalization, display-name casing — every rule a test can
/// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and
/// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`.
/// (Its collapsed face has its *own*, unrelated two-zone split — swatch versus trigger,
/// `ColorComboControl`'s own doc comment — which has nothing to do with this pure-model/view one.)

// MARK: - Role

/// Which of the two palettes a combo offers — `Palette.backgrounds` for `background`,
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which twelve it lists, never about
/// which values it can resolve.
enum ColorComboRole: Sendable, Equatable {
    case background
    case foreground

    /// The twelve rows this picker offers.
    var palette: [PaletteColor] {
        switch self {
        case .background: Palette.backgrounds
        case .foreground: Palette.foregrounds
        }
    }

    /// The *other* picker's twelve — consulted only to name a foreign palette value in the dynamic
    /// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
    /// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
    /// itself would happily resolve a foreground name.
    var otherPalette: [PaletteColor] {
        switch self {
        case .background: Palette.foregrounds
        case .foreground: Palette.backgrounds
        }
    }
}

// MARK: - Rows and matching

/// One row of a `ColorComboView`'s dropdown, in display order.
enum ColorComboItem: Equatable {
    /// Clears the field — the well grid's own leading None well, same removal.
    case none
    case separator
    /// One of `role`'s own twelve, by name. `ColorComboModel.displayName(_:)` is its title; the row
    /// is never built with anything the role's own palette doesn't list.
    case palette(String)
    /// The live value's own row — present only when the stored value matches neither `.none` nor a
    /// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a
    /// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is
    /// the display text `ColorComboModel.match` already worked out.
    case current(swatchValue: String, title: String)
    /// Opens `NSColorPanel.shared`.
    case other
}

/// Which row a stored value checks — computed once and shared by the item list (`ColorComboModel.
/// menu`) and by anything that just wants to know "what does this resolve to" without building
/// rows, which is most of what a test wants to assert.
enum ColorComboMatch: Equatable {
    case none
    case palette(String)
    case current(swatchValue: String, title: String)
}

/// The full dropdown for one role at one value: its rows, and the index of the checked one.
struct ColorComboMenu: Equatable {
    let items: [ColorComboItem]
    /// Always a valid index into `items` — the None row exists in every menu, so there is always at
    /// least one candidate to fall back to.
    let selectedIndex: Int
}

// MARK: - The pure model

/// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value —
/// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still.
enum ColorComboModel {

    // MARK: Display

    /// Kebab-case palette name → Title Case with hyphens as spaces: `"light-cayenne"` →
    /// `"Light Cayenne"`, `"smokey-rich-eggplant"` → `"Smokey Rich Eggplant"` — the one place a
    /// palette name becomes a row's title rather than its stored spelling.
    static func displayName(_ name: String) -> String {
        name.split(separator: "-")
            .map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() }
            .joined(separator: " ")
    }

    // MARK: Matching

    /// Which row `value` checks, given `role`:
    /// - `nil` → `.none`.
    /// - a name in `role`'s own palette → `.palette(name)`, matched exactly — `Palette`'s own
    ///   case-sensitive rule, unchanged here.
    /// - a hex that, normalized, equals one of `role`'s palette hexes → that colour's `.palette`
    ///   match, **by name** — a panel pick landing exactly on a palette colour selects the name, so
    ///   picking it again from the panel later re-emits the name rather than drifting to a hex.
    /// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) → `.current`,
    ///   titled with the other picker's display name when `value` is one of *its* twelve, else
    ///   `value` itself, uppercased when it looks like hex and left verbatim otherwise.
    static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
        guard let value else { return .none }
        if role.palette.contains(where: { $0.name == value }) {
            return .palette(value)
        }
        if let normalized = normalizedHex(value),
           let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) {
            return .palette(hit.name)
        }
        return .current(swatchValue: value, title: currentTitle(role: role, value: value))
    }

    /// The dynamic current-value row's title — the other table's display name when `value` is one
    /// of its twelve, the raw string otherwise (hex shown uppercase, matching `NSColor.
    /// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
    private static func currentTitle(role: ColorComboRole, value: String) -> String {
        if let foreign = role.otherPalette.first(where: { $0.name == value }) {
            return displayName(foreign.name)
        }
        return value.hasPrefix("#") ? value.uppercased() : value
    }

    // MARK: Item list

    /// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
    /// separator, the twelve, then — only when `match` lands on `.current` — that dynamic row,
    /// separator, **Other…**.
    static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
        var items: [ColorComboItem] = [.none, .separator]
        items.append(contentsOf: role.palette.map { .palette($0.name) })

        let selectedIndex: Int
        switch match(role: role, value: value) {
        case .none:
            selectedIndex = 0
        case let .palette(name):
            selectedIndex = items.firstIndex(of: .palette(name)) ?? 0
        case let .current(swatchValue, title):
            items.append(.current(swatchValue: swatchValue, title: title))
            selectedIndex = items.count - 1
        }

        items.append(.separator)
        items.append(.other)
        return ColorComboMenu(items: items, selectedIndex: selectedIndex)
    }

    // MARK: Hex normalization

    /// `#RRGGBB`/`#RRGGBBAA` → uppercase, alpha-`FF` collapsed to six digits — the string-side half
    /// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for
    /// **comparison**: two spellings of the same opaque colour normalize to the same string, so a
    /// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would.
    /// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value
    /// never accidentally matches a palette colour by coincidence.
    static func normalizedHex(_ value: String) -> String? {
        var upper = value.uppercased()
        guard upper.hasPrefix("#") else { return nil }
        let digits = upper.dropFirst()
        guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil }
        if digits.count == 8, digits.hasSuffix("FF") {
            upper.removeLast(2)
        }
        return upper
    }
}

// MARK: - View

/// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is
/// `ColorComboModel.menu(role:value:)` — built exactly as it always was, just handed to the control
/// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one
/// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one
/// hit zone for exactly one action.
struct ColorComboView: NSViewRepresentable {

    let role: ColorComboRole
    /// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field
    /// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
    let value: String?
    let isEnabled: Bool
    /// One discrete row picked — **None**, one of the twelve, or the dynamic current-value row.
    /// Fired once, synchronously; the call site commits it immediately.
    var onChange: @MainActor (String?) -> Void
    /// One tick of a live `NSColorPanel` drag opened from **Other…** or the swatch zone — fires
    /// repeatedly while the user is still adjusting the colour. Kept separate from `onChange`
    /// because the two halves of this control's contract differ at the call site
    /// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to
    /// debounce and never feeds style recents.
    var onPanelChange: @MainActor (String?) -> Void

    /// The width `sizeThatFits` hands back when SwiftUI has no concrete proposal to fill — an
    /// unconstrained measuring pass, not the normal case. The normal case is a finite proposal (this
    /// view sits under `.frame(maxWidth: .infinity)` in the sidebar row, `CardActionsSection`'s
    /// Delete button's own trick), which this default never has to stand in for.
    private static let defaultFaceWidth: CGFloat = 120

    func makeNSView(context: Context) -> ColorComboControl {
        let control = ColorComboControl(frame: .zero)
        // The swatch zone's one job: open the same panel takeover **Other…** does. A closure, not a
        // target/action pair — there is exactly one caller and no `NSMenuItem`-style Objective-C
        // boundary to cross for it.
        control.onSwatchClick = { [weak coordinator = context.coordinator] in
            coordinator?.openColorPanel()
        }
        return control
    }

    func updateNSView(_ control: ColorComboControl, context: Context) {
        context.coordinator.role = role
        context.coordinator.onChange = onChange
        context.coordinator.onPanelChange = onPanelChange
        control.isEnabled = isEnabled
        context.coordinator.rebuild(control, value: value)
    }

    /// Obeys whatever width SwiftUI proposes, like any other control — never the widest menu item,
    /// which is what the old caller-supplied `width` input existed to work around. Height is the
    /// control's own fitting height (`ColorComboControl.intrinsicContentSize`, about half the old
    /// regular `NSPopUpButton`'s — the whole point of this rework); width is the proposal's when it
    /// is an actual number, else `defaultFaceWidth`, since a `nil`/infinite proposal happens on an
    /// unconstrained measuring pass, not a sidebar row.
    func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? {
        let width: CGFloat
        if let proposed = proposal.width, proposed.isFinite {
            width = proposed
        } else {
            width = Self.defaultFaceWidth
        }
        return CGSize(width: width, height: nsView.intrinsicContentSize.height)
    }

    /// Detaches the colour panel's target/action if this coordinator still holds them — "last-writer
    /// wins" for any control that took the panel over afterward (this view's own doc comment).
    static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) {
        coordinator.detachColorPanel()
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange)
    }

    // MARK: Coordinator

    /// The one object every menu action and the colour panel's action target — a class because
    /// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later,
    /// and `@MainActor` because every AppKit call it makes has to be.
    @MainActor
    final class Coordinator: NSObject {
        fileprivate var role: ColorComboRole
        fileprivate var currentValue: String?
        fileprivate var onChange: @MainActor (String?) -> Void
        fileprivate var onPanelChange: @MainActor (String?) -> Void

        /// ~44×14pt — a menu row's swatch, wide enough beside its title to read as a colour sample
        /// rather than a bullet.
        private static let menuSwatchSize = NSSize(width: 44, height: 14)

        /// Whichever coordinator most recently took the shared panel over — `NSColorPanel` exposes
        /// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach"
        /// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching
        /// (a window closed from under it) does not keep the next owner from being collected either.
        private static weak var currentPanelOwner: Coordinator?

        init(
            role: ColorComboRole,
            onChange: @escaping @MainActor (String?) -> Void,
            onPanelChange: @escaping @MainActor (String?) -> Void
        ) {
            self.role = role
            self.onChange = onChange
            self.onPanelChange = onPanelChange
        }

        /// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the
        /// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its
        /// accessibility value), and the value its swatch zone should draw. Cheap enough — a dozen
        /// rows, a fistful of small menu-row images — to redo wholesale on every SwiftUI update
        /// rather than diffing against what was there before.
        func rebuild(_ control: ColorComboControl, value: String?) {
            currentValue = value
            let menu = NSMenu()
            let built = ColorComboModel.menu(role: role, value: value)
            var checkedItem: NSMenuItem?
            for (index, item) in built.items.enumerated() {
                if item == .separator {
                    menu.addItem(.separator())
                    continue
                }
                let menuItem = self.menuItem(for: item)
                let isChecked = index == built.selectedIndex
                menuItem.state = isChecked ? .on : .off
                menu.addItem(menuItem)
                if isChecked { checkedItem = menuItem }
            }
            control.comboMenu = menu
            control.checkedItem = checkedItem
            control.swatchValue = value
        }

        /// See `ColorComboView.dismantleNSView(_:coordinator:)`.
        func detachColorPanel() {
            guard Coordinator.currentPanelOwner === self else { return }
            let panel = NSColorPanel.shared
            panel.setTarget(nil)
            panel.setAction(nil)
            Coordinator.currentPanelOwner = nil
        }

        private func menuItem(for item: ColorComboItem) -> NSMenuItem {
            switch item {
            case .none:
                let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "")
                menuItem.target = self
                menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize)
                return menuItem

            case let .palette(name):
                let menuItem = NSMenuItem(
                    title: ColorComboModel.displayName(name),
                    action: #selector(selectValue(_:)),
                    keyEquivalent: ""
                )
                menuItem.target = self
                menuItem.representedObject = name
                menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize)
                return menuItem

            case let .current(swatchValue, title):
                let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "")
                menuItem.target = self
                menuItem.representedObject = swatchValue
                menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize)
                return menuItem

            case .other:
                let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "")
                menuItem.target = self
                return menuItem

            case .separator:
                // Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch
                // stays total against a case list a future row could still grow.
                return NSMenuItem.separator()
            }
        }

        @objc private func selectNone() {
            onChange(nil)
        }

        @objc private func selectValue(_ sender: NSMenuItem) {
            onChange(sender.representedObject as? String)
        }

        /// Seeds the shared panel with the current resolved colour (black when there isn't one),
        /// takes it over — "don't fight over the panel if something else takes it later" (this
        /// view's own doc comment) — and asks for continuous updates, which is what makes a drag on
        /// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release.
        ///
        /// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above)
        /// and `ColorComboControl`'s swatch-zone click (wired in `ColorComboView.makeNSView`) —
        /// Xcode's own two-zone combo opens the same panel from either half, and this is the one
        /// place that happens.
        @objc func openColorPanel() {
            let panel = NSColorPanel.shared
            panel.showsAlpha = true
            panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black
            panel.setTarget(self)
            panel.setAction(#selector(changeColor(_:)))
            Coordinator.currentPanelOwner = self
            panel.makeKeyAndOrderFront(nil)
        }

        /// The panel's own action, continuous while the user drags: normalizes what it picked to
        /// this app's stored-value vocabulary and hands it to `onPanelChange` — the palette name
        /// when the colour lands exactly on one of `role`'s twelve, the hex otherwise. The name-wins
        /// rule is the same one `ColorComboModel.match` applies to a value already on disk.
        @objc private func changeColor(_ sender: NSColorPanel) {
            guard let hex = sender.color.paletteHexString else { return }
            onPanelChange(Palette.name(forHex: hex, in: role.palette) ?? hex)
        }
    }
}

// MARK: - Two-zone NSControl

/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape — a flat
/// swatch filling almost the whole control, and a fixed-width chevron trigger at the trailing edge.
/// The swatch zone opens the Colors panel directly (`ColorComboView.Coordinator.openColorPanel()`);
/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither
/// zone owns a bezel or a cell of its own — everything both draw and hit-test is computed straight
/// from `bounds` on every pass, so there is nothing cached here the way the face image the
/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw).
///
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) — same
/// reasoning as the un-modified-access `Coordinator` a few lines up.
final class ColorComboControl: NSControl {

    /// The value the swatch zone currently draws — a palette name or a hand-written hex, exactly as
    /// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
    var swatchValue: String? {
        didSet {
            guard swatchValue != oldValue else { return }
            needsDisplay = true
        }
    }

    /// The dropdown the trigger zone pops, and the row within it that should read as checked — both
    /// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem`
    /// is always one of `comboMenu`'s own items). This control never builds a row itself; it only
    /// positions and pops what it is given.
    var comboMenu: NSMenu?
    var checkedItem: NSMenuItem?

    /// Fired by a click anywhere in the swatch zone — wired once, in `ColorComboView.makeNSView`, to
    /// the coordinator's `openColorPanel()`. `@MainActor`, this file's own established convention for
    /// a stored closure an AppKit callback fires (`onChange`/`onPanelChange` above), even though this
    /// control's own methods are already implicitly MainActor-isolated as an `NSResponder` subclass.
    var onSwatchClick: (@MainActor () -> Void)?

    override var isEnabled: Bool {
        get { super.isEnabled }
        set {
            super.isEnabled = newValue
            needsDisplay = true
        }
    }

    /// About half the old regular `NSPopUpButton`'s height — the whole point of this rework. Width
    /// is `NSView.noIntrinsicMetric`: this control obeys whatever SwiftUI proposes, exactly as the
    /// button it replaces did.
    override var intrinsicContentSize: NSSize {
        NSSize(width: NSView.noIntrinsicMetric, height: 14)
    }

    /// The fixed-width trigger strip at the trailing edge, full height — the geometry this whole
    /// control exists to draw: "a flat swatch occupying the control, a chevron trigger at the
    /// trailing edge."
    private static let triggerWidth: CGFloat = 16
    /// The swatch zone's padding before its rounded rect — two points rather than a bare hairline,
    /// so the control's own field (`drawField()`) reads as a visible ring around the colour instead
    /// of being covered by it.
    private static let swatchPadding: CGFloat = 2
    private static let cornerRadius: CGFloat = 3
    /// The field's own radius — a point more than the swatch's, so the two rounded rects run
    /// concentric instead of pinching at the corners.
    private static let fieldRadius: CGFloat = 4

    private var triggerRect: NSRect {
        NSRect(x: bounds.maxX - Self.triggerWidth, y: bounds.minY, width: Self.triggerWidth, height: bounds.height)
    }

    private var swatchZone: NSRect {
        NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width - Self.triggerWidth, height: bounds.height)
    }

    // MARK: Drawing

    override func draw(_ dirtyRect: NSRect) {
        guard let context = NSGraphicsContext.current?.cgContext else { return }
        context.saveGState()
        defer { context.restoreGState() }
        // A transparency layer, not a flat `setAlpha` around each shape: the swatch's underlay,
        // fill and stroke overlap, and drawing each at reduced alpha independently would let the
        // stroke double up over the fill beneath it. Compositing the whole disabled face as one
        // layer avoids that.
        if !isEnabled {
            context.setAlpha(0.35)
            context.beginTransparencyLayer(auxiliaryInfo: nil)
        }
        drawField()
        drawSwatch()
        drawTrigger()
        if !isEnabled {
            context.endTransparencyLayer()
        }
    }

    /// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
    /// zones — what makes the swatch and the trigger read as one control rather than two shapes
    /// floating beside each other. Standard control materials: `controlBackgroundColor` fill,
    /// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
    private func drawField() {
        let path = NSBezierPath(
            roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
            xRadius: Self.fieldRadius,
            yRadius: Self.fieldRadius
        )
        NSColor.controlBackgroundColor.setFill()
        path.fill()
        NSColor.separatorColor.setStroke()
        path.lineWidth = 1
        path.stroke()
    }

    /// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor`
    /// underlay so a translucent stored colour composites the same way in light and dark, the
    /// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value →
    /// underlay + stroke only, the same "there is no colour, so show none" rule.
    private func drawSwatch() {
        let inset = swatchZone.insetBy(dx: Self.swatchPadding, dy: Self.swatchPadding)
        let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
        NSColor.textBackgroundColor.setFill()
        path.fill()
        if let swatchValue, let color = Palette.nsColor(for: swatchValue) {
            color.setFill()
            path.fill()
        }
        NSColor.separatorColor.setStroke()
        path.lineWidth = 1
        path.stroke()
    }

    /// The trigger: a small vertically-centred rounded **square**, `controlAccentColor`-filled, with
    /// a white `chevron.up.chevron.down` centred inside — the standard `NSPopUpButton` indicator's
    /// own look, redrawn here since this control has no bezel of its own to borrow one from.
    private func drawTrigger() {
        let side = triggerRect.height - 2 * Self.swatchPadding
        let square = NSRect(
            x: triggerRect.midX - side / 2,
            y: triggerRect.midY - side / 2,
            width: side,
            height: side
        )
        let path = NSBezierPath(roundedRect: square, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
        NSColor.controlAccentColor.setFill()
        path.fill()

        let config = NSImage.SymbolConfiguration(pointSize: 7, weight: .bold)
            .applying(.init(paletteColors: [.white]))
        guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
            .withSymbolConfiguration(config)
        else { return }
        let size = chevron.size
        chevron.draw(in: NSRect(
            x: square.midX - size.width / 2,
            y: square.midY - size.height / 2,
            width: size.width,
            height: size.height
        ))
    }

    // MARK: Events

    /// Point-in-trigger-zone pops the dropdown; anywhere else in the control fires the swatch click
    /// — the two-zone split this whole rework exists for. A disabled control answers neither.
    override func mouseDown(with event: NSEvent) {
        guard isEnabled else { return }
        let point = convert(event.locationInWindow, from: nil)
        if triggerRect.contains(point) {
            popUpMenu()
        } else {
            onSwatchClick?()
        }
    }

    override var acceptsFirstResponder: Bool { isEnabled }

    /// Space and Return pop the dropdown — the one keyboard path into this control. There is
    /// currently no keyboard equivalent for the swatch zone's direct panel launch; see this class's
    /// own doc comment and the file's top-level report for what a full accessibility pass would add.
    override func keyDown(with event: NSEvent) {
        guard isEnabled else {
            super.keyDown(with: event)
            return
        }
        switch event.keyCode {
        case 49, 36, 76: // Space, Return, keypad Enter
            popUpMenu()
        default:
            super.keyDown(with: event)
        }
    }

    /// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
    /// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
    /// where the control's own face is rather than wherever the pointer happened to be.
    private func popUpMenu() {
        guard let comboMenu else { return }
        comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
    }

    // MARK: Accessibility

    override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }

    /// The checked row's own title — "Light Cayenne", "None", a bare hex — exactly what the dropdown
    /// itself would show ticked, since `Coordinator.rebuild(_:value:)` hands this control the very
    /// item it built the menu from rather than a copy.
    override func accessibilityValue() -> Any? { checkedItem?.title }
}
