import AppKit
import Observation

// MARK: - The vocabulary

/// **A toolbar item's label is its menu row's title** (03-board-ui.md ▸ Toolbar): "Toolbar item
/// labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any
/// trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary
/// everywhere, and the customize palette self-documents against the menus."
///
/// One function rather than a hand-written second spelling per item, because the failure this rule
/// guards against is drift: a menu row renamed without its toolbar item is two names for one
/// function, and the customize palette is precisely where a user compares the two.
///
/// The **one exception is Undo/Redo**, which is not expressible here and is not meant to be:
/// `NSUndoManager` rewrites their menu titles as the stack changes ("Undo Move Card"), and a toolbar
/// label does not track that — so those two items are built from a static label instead of from a
/// menu title (`ToolbarItemSpec.staticLabel`).
enum ToolbarVocabulary {

    /// `menuTitle` minus one trailing ellipsis — the "…" character and the three-period spelling
    /// alike, since a title is only ever written one of those two ways and both mean the same thing.
    static func label(menuTitle: String) -> String {
        var title = Substring(menuTitle)
        if title.hasSuffix("…") {
            title = title.dropLast()
        } else if title.hasSuffix("...") {
            title = title.dropLast(3)
        }
        while title.last == " " {
            title = title.dropLast()
        }
        return String(title)
    }
}

// MARK: - One item

/// One toolbar item, as a value: its identity, its vocabulary, and what it does.
///
/// **It is a description, not a view.** The item's `NSToolbarItem` is built from this by
/// `WindowToolbarController` — twice, in fact, since the customization palette holds its own copy of
/// every item — so nothing here may be a control. Keeping it a value is also what makes the two
/// things worth testing testable without a window: the label the vocabulary rule derives, and the
/// predicates the item mirrors from its menu row (03 ▸ Toolbar: "every function they host already has
/// a menu item + shortcut ... nothing below is anyone's only path").
///
/// The predicates are **the menu rows' own**, passed in by the catalogs — `EditBodyCommand.isEnabled`
/// and `AddAttachmentCommand.isEnabled` are handed here verbatim rather than re-derived, which is the
/// only way "the toolbar mirrors the menu" can stay true of code as well as of prose.
@MainActor
struct ToolbarItemSpec {

    let identifier: NSToolbarItem.Identifier
    let label: String
    /// The SF Symbol the item draws. `nil` for the search item, whose field draws itself.
    let symbol: String?
    let behavior: Behavior

    /// What the item *is*, which is also what kind of `NSToolbarItem` it becomes.
    @MainActor
    enum Behavior {
        /// A push button: an action, and the predicate its menu row validates against.
        case button(isEnabled: () -> Bool, perform: () -> Void)
        /// A toggle showing on-state — Show Trash, Edit Body, Raw Source (03 ▸ Toolbar).
        case toggle(isEnabled: () -> Bool, isOn: () -> Bool, setOn: (Bool) -> Void)
        /// A search field in AppKit's own `NSSearchToolbarItem` — the board's search
        /// (03-board-ui.md ▸ Toolbar). The item owns the field's layout, so `focusedWidth` is a
        /// preference rather than a constraint: it is the width the field takes *when it has the
        /// keyboard*, the resting width being the item's own.
        ///
        /// `make` is handed `true` when the field is bound for the toolbar itself and `false` when
        /// it is the customization palette's copy, so only the real one claims window-scoped
        /// wiring. `install` runs for that real item alone, and is where a caller wires the things
        /// that need the *item* rather than the field — expanding it and putting the keyboard in it
        /// is one call on `NSSearchToolbarItem`, and no field can make it.
        case searchField(
            focusedWidth: CGFloat,
            make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
            install: (NSSearchToolbarItem) -> Void
        )
        /// A pull-down of mutually exclusive choices — **Appearance** (03-board-ui.md ▸ Toolbar): an
        /// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order.
        /// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
        /// opens the menu rather than polled — the same freshness every other menu row in the app
        /// gets (`validateMenuItem(_:)`) — and `select(_:)` is a chosen row's whole action. The one
        /// behavior with no `activate()` of its own: firing lives in the dropdown's rows rather than
        /// in the item's primary action.
        case picker(
            options: [(title: String, symbol: String?)],
            selected: () -> Int?,
            select: (Int) -> Void
        )
    }

    /// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
    /// trailing ellipsis.
    static func mirroring(
        menuTitle: String,
        identifier: NSToolbarItem.Identifier,
        symbol: String?,
        behavior: Behavior
    ) -> Self {
        Self(
            identifier: identifier,
            label: ToolbarVocabulary.label(menuTitle: menuTitle),
            symbol: symbol,
            behavior: behavior
        )
    }

    /// The Undo/Redo exception, and the search field (a control, not a command): a label written
    /// here because there is no menu title to derive it from.
    static func staticLabel(
        _ label: String,
        identifier: NSToolbarItem.Identifier,
        symbol: String?,
        behavior: Behavior
    ) -> Self {
        Self(identifier: identifier, label: label, symbol: symbol, behavior: behavior)
    }

    // MARK: State

    /// The item's live enablement. The search item and the picker answer `true`: neither has an
    /// enablement of its own — the field is a control rather than a command, and the picker's
    /// choices are always available.
    var isEnabled: Bool {
        switch behavior {
        case let .button(isEnabled, _): isEnabled()
        case let .toggle(isEnabled, _, _): isEnabled()
        case .searchField, .picker: true
        }
    }

    /// The item's on-state, or `nil` for the items that have none.
    var isOn: Bool? {
        switch behavior {
        case let .toggle(_, isOn, _): isOn()
        case .button, .searchField, .picker: nil
        }
    }

    /// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself
    /// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown
    /// rows).
    func activate() {
        switch behavior {
        case let .button(_, perform): perform()
        case let .toggle(_, isOn, setOn): setOn(!isOn())
        case .searchField, .picker: break
        }
    }
}

// MARK: - The toolbar

/// A window's toolbar: **customizable, macOS-native, and never a function's only home**
/// (03-board-ui.md ▸ Toolbar).
///
/// ### Why `NSToolbar` rather than SwiftUI's `.toolbar(id:)`
///
/// SwiftUI's customizable toolbar would answer most of 03's clauses, and it was the first choice.
/// Four requirements sent this to AppKit instead, and each was normative rather than aesthetic. Three
/// of them still hold; the fourth is kept below because a retired reason is worth more written down
/// than deleted, and because the three that remain are what the answer now rests on:
///
/// - **⌘F has to know whether the search item is installed.** "With the field removed from the
///   toolbar, invoking it surfaces the field transiently" (03) — a decision that needs to read the
///   toolbar's *current* contents. `NSToolbar` publishes them (`items`, plus the will-add/did-remove
///   delegate callbacks); SwiftUI's toolbar API has no such query, and reaching around it into the
///   `NSToolbar` it happens to own means matching identifiers SwiftUI derives rather than ones this
///   app spells.
/// - ~~**Undo and Redo have to reach the responder chain.**~~ **Retired 2026-08-08** with the
///   mechanism it named (13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled that day):
///   the pair's nil-target `undo:`/`redo:` resolved to `NSWindow`, which reads the manager its
///   delegate vends — and a SwiftUI window latches an empty manager of its own during creation,
///   before `HostedWindowController` installs, so that route reached a stack nothing registers into.
///   The pair now carries an **explicit target** over the focused session's `BoardUndoManager`
///   (`BoardToolbar`), which is the same object the app's own Edit ▸ Undo/Redo rows read through the
///   focus system — "matching their menu items" (03) by sharing the manager rather than by sharing a
///   lookup. Nothing about that needs AppKit; the other three bullets are why this is still an
///   `NSToolbar`.
/// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting a real `NSSearchField` with
///   explicit first-responder control, settled in m5 for reasons `BoardSearchFieldController`
///   records (⌘F must focus it from a menu item; Escape in an empty field must hand the keyboard
///   back to the strip). The item is where grow-on-focus, the cancel button's staging, and the
///   overflow row all live, and it exists only in AppKit; a toolbar that already speaks AppKit
///   hosts it directly.
/// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate
///   and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the
///   same window, through the same install-once seam.
///
/// What the platform gives back, and what 03 asks for by name: right-click ▸ Customize Toolbar…,
/// drag to rearrange, the system overflow, and the icon/text display options —
/// `allowsUserCustomization` and `allowsDisplayModeCustomization` below are those two sentences.
/// The user's arrangement persists through `autosavesConfiguration`, keyed on the toolbar's
/// identifier, so it is per *window kind* (every board window shares one arrangement) exactly as
/// Finder's is.
///
/// ### Validation is observed, not polled
///
/// AppKit validates visible items on its own schedule (`NSToolbarItem.autovalidates`), which is tied
/// to user events. The state these items mirror can change without one — a foreign reload flipping
/// the read-only lock, a raw-source Apply landing — so the controller *observes* every predicate it
/// was given (`withObservationTracking`) and re-validates when any of them would answer differently.
/// The two mechanisms are complementary: AppKit's covers the ordinary case, this covers the case
/// where nothing the user did caused the change.
@MainActor
final class WindowToolbarController: NSObject, NSToolbarDelegate {

    let toolbar: NSToolbar

    /// The catalog, in palette order — every item the user may install (03: "Catalog (available via
    /// Customize)").
    private let specs: [NSToolbarItem.Identifier: ToolbarItemSpec]
    private let catalog: [NSToolbarItem.Identifier]
    private let defaults: [NSToolbarItem.Identifier]

    /// Called with the identifiers the toolbar currently carries, whenever that set changes — the
    /// board's search item is the one consumer (`BoardToolbar`), and 03's transient-⌘F clause is the
    /// reason it exists.
    var onInstalledItemsChanged: (([NSToolbarItem.Identifier]) -> Void)?

    /// Set while a report is already scheduled, so a customization that removes and re-adds a dozen
    /// items reports once.
    private var isReportScheduled = false

    init(identifier: String, specs: [ToolbarItemSpec], defaults: [NSToolbarItem.Identifier]) {
        toolbar = NSToolbar(identifier: identifier)
        catalog = specs.map(\.identifier)
        self.specs = Dictionary(uniqueKeysWithValues: specs.map { ($0.identifier, $0) })
        self.defaults = defaults
        super.init()

        toolbar.delegate = self
        // 03's three customization sentences, in three lines: the palette and its drag-rearrange,
        // the Show ▸ Icon and Text / Icon Only / Text Only popup, and the arrangement remembered
        // across launches.
        toolbar.allowsUserCustomization = true
        toolbar.allowsDisplayModeCustomization = true
        toolbar.autosavesConfiguration = true

        trackValidationState()
    }

    /// Re-reads every item's state and pushes it into the toolbar. Called by the observation above
    /// and worth calling directly after anything that installs the toolbar.
    func revalidate() {
        toolbar.validateVisibleItems()
    }

    // MARK: - Installed items

    /// Reports the toolbar's current contents, coalesced onto the next turn: the delegate callbacks
    /// fire *around* a change rather than after it (`toolbarWillAddItem` runs before the item joins
    /// `items`), so the honest answer is only available once the run loop has come back around.
    private func scheduleInstalledItemsReport() {
        guard !isReportScheduled else { return }
        isReportScheduled = true
        Task { @MainActor [weak self] in
            guard let self else { return }
            isReportScheduled = false
            onInstalledItemsChanged?(toolbar.items.map(\.itemIdentifier))
        }
    }

    /// Reports the contents now that the toolbar is on a window — the initial answer, which no
    /// delegate callback provides when the saved configuration happens to be empty.
    func reportInstalledItems() {
        scheduleInstalledItemsReport()
    }

    // MARK: - NSToolbarDelegate

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        catalog + [.space, .flexibleSpace]
    }

    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        defaults
    }

    func toolbar(
        _ toolbar: NSToolbar,
        itemForItemIdentifier identifier: NSToolbarItem.Identifier,
        willBeInsertedIntoToolbar flag: Bool
    ) -> NSToolbarItem? {
        guard let spec = specs[identifier] else { return nil }
        return makeItem(spec, willBeInsertedIntoToolbar: flag)
    }

    func toolbarWillAddItem(_ notification: Notification) {
        scheduleInstalledItemsReport()
    }

    func toolbarDidRemoveItem(_ notification: Notification) {
        scheduleInstalledItemsReport()
    }

    // MARK: - Item construction

    private func makeItem(_ spec: ToolbarItemSpec, willBeInsertedIntoToolbar: Bool) -> NSToolbarItem {
        switch spec.behavior {
        case .button:
            return makeButtonItem(spec)
        case .toggle:
            return makeToggleItem(spec)
        case let .searchField(focusedWidth, make, install):
            return makeSearchItem(
                spec,
                focusedWidth: focusedWidth,
                field: make(willBeInsertedIntoToolbar),
                // The palette's copy is a picture of the item, not a second live one: it claims no
                // window-scoped wiring, so it is handed none.
                install: willBeInsertedIntoToolbar ? install : nil
            )
        case let .picker(options, _, _):
            return makePickerItem(spec, options: options)
        }
    }

    /// A plain bordered item, validated against its menu row's predicate.
    private func makeButtonItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
        let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
        decorate(item, with: spec)
        item.isBordered = true
        item.target = self
        item.action = #selector(itemFired(_:))
        item.onValidate = { [weak item] in
            item?.isEnabled = spec.isEnabled
        }
        return item
    }

    /// A toggle button showing on-state. A hosted `NSButton` rather than a plain item because
    /// `NSToolbarItem` has no state of its own, and 03 asks for one explicitly ("Show Trash (toggle
    /// state matching the View menu checkmark)", "Edit Body is a single toggle button (on-state in
    /// Edit)").
    private func makeToggleItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
        let button = NSButton(frame: NSRect(x: 0, y: 0, width: 38, height: 24))
        button.setButtonType(.pushOnPushOff)
        button.bezelStyle = .toolbar
        button.title = ""
        button.imagePosition = .imageOnly
        if let symbol = spec.symbol {
            button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
        }
        button.identifier = NSUserInterfaceItemIdentifier(spec.identifier.rawValue)
        button.target = self
        button.action = #selector(toggleFired(_:))
        button.state = spec.isOn == true ? .on : .off
        // A custom view carries its own accessibility, where a plain item inherits the toolbar
        // item's label (10-accessibility.md: every control is named).
        button.setAccessibilityLabel(spec.label)
        button.toolTip = spec.label

        let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
        decorate(item, with: spec)
        item.view = button
        // A custom-view item is blank in the overflow menu without this, and the overflow is one of
        // 03's clauses ("the system overflow").
        item.menuFormRepresentation = menuFormRepresentation(for: spec)
        item.onValidate = { [weak button] in
            button?.isEnabled = spec.isEnabled
            button?.state = spec.isOn == true ? .on : .off
        }
        return item
    }

    /// The board's search field in the item AppKit wrote for it.
    ///
    /// **No width constraint here.** `NSSearchToolbarItem` manages the field's layout, and its
    /// header says custom width constraints "should not conflict with" the preferred width — so the
    /// em-based figure is handed over as a *preference*, which the item applies "whenever it gets
    /// the keyboard focus". The resting width is the item's own, and grow-on-focus is what that
    /// pair of facts means on screen.
    ///
    /// **Neither the overflow row nor the visibility priority is written here, and both omissions
    /// are decisions.** The item ships its own `menuFormRepresentation` — a row titled from `label`
    /// carrying a live AppKit action that widens the window until the field is usable — where a
    /// custom-view item ships a blank one that has to be replaced; assigning here (`nil` included)
    /// destroys it. And its `visibilityPriority` already starts one step *above* `.high`, so the
    /// nudge a custom-view item needs to stay out of the overflow would be a demotion here.
    private func makeSearchItem(
        _ spec: ToolbarItemSpec,
        focusedWidth: CGFloat,
        field: NSSearchField,
        install: ((NSSearchToolbarItem) -> Void)?
    ) -> NSToolbarItem {
        let item = NSSearchToolbarItem(itemIdentifier: spec.identifier)
        decorate(item, with: spec)
        // Configured before assignment, as the item's header asks — with one exception the header
        // does not name: assignment stamps the *item's* enablement onto the field, so a field the
        // caller made inert (the customization palette's copy) comes back live. The caller's answer
        // is the one that counts, so it is put back.
        let isFieldEnabled = field.isEnabled
        item.searchField = field
        field.isEnabled = isFieldEnabled
        item.preferredWidthForSearchField = focusedWidth
        // **Escape is staged** (04-interactions.md ▸ Search, settled: "in a non-empty field it
        // clears the query, focus staying in the field; in an empty field it returns focus to the
        // board"). AppKit's default is for the cancel button to clear *and* resign, which collapses
        // the first two steps of that staircase into one — so the field keeps the keyboard, and the
        // second press is what hands it back (`BoardSearchFieldController`).
        item.resignsFirstResponderWithCancel = false
        install?(item)
        return item
    }

    /// A pull-down of mutually exclusive options — **Appearance**, so far the one item of this shape.
    ///
    /// `selected`/`select` are deliberately not captured here: every row's action and every row's
    /// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`,
    /// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use
    /// for their own specs — so a spec rebuilt between two menu presentations is never read stale.
    private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem {
        let item = NSMenuToolbarItem(itemIdentifier: spec.identifier)
        decorate(item, with: spec)
        // "Pull-down: item image + indicator" — the item's own glyph draws at rest, the indicator
        // chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark.
        item.showsIndicator = true

        let menu = NSMenu()
        for (index, option) in options.enumerated() {
            let menuItem = NSMenuItem(
                title: option.title,
                action: #selector(pickerItemFired(_:)),
                keyEquivalent: ""
            )
            menuItem.target = self
            // The row's position in `options`, not an identifier of its own — `select(_:)` and
            // `selected()` both speak in this same index, which is what lets one closure pair stand
            // for every row rather than one closure per option.
            menuItem.tag = index
            menuItem.representedObject = spec.identifier.rawValue
            if let symbol = option.symbol {
                menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title)
            }
            menu.addItem(menuItem)
        }
        item.menu = menu
        return item
    }

    /// The three strings every item carries: the toolbar label, the palette label (the same string —
    /// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
    private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
        item.label = spec.label
        item.paletteLabel = spec.label
        item.toolTip = spec.label
        if let symbol = spec.symbol {
            item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
        }
    }

    private func menuFormRepresentation(for spec: ToolbarItemSpec) -> NSMenuItem {
        let menuItem = NSMenuItem(title: spec.label, action: #selector(menuFired(_:)), keyEquivalent: "")
        menuItem.target = self
        menuItem.representedObject = spec.identifier.rawValue
        return menuItem
    }

    // MARK: - Actions

    @objc private func itemFired(_ sender: NSToolbarItem) {
        specs[sender.itemIdentifier]?.activate()
    }

    @objc private func toggleFired(_ sender: NSButton) {
        guard let raw = sender.identifier?.rawValue else { return }
        specs[NSToolbarItem.Identifier(raw)]?.activate()
        // The state is the model's, never the click's: a toggle whose setter refuses (a validation
        // failure keeping source mode open, say) must not look like it succeeded.
        revalidate()
    }

    @objc private func menuFired(_ sender: NSMenuItem) {
        guard let raw = sender.representedObject as? String else { return }
        specs[NSToolbarItem.Identifier(raw)]?.activate()
        revalidate()
    }

    /// A row in a `.picker` item's own dropdown — **Appearance**'s Auto/Light/Dark, fired straight
    /// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its
    /// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`).
    @objc private func pickerItemFired(_ sender: NSMenuItem) {
        guard let raw = sender.representedObject as? String,
              let spec = specs[NSToolbarItem.Identifier(raw)],
              case let .picker(_, _, select) = spec.behavior
        else { return }
        select(sender.tag)
        revalidate()
    }

    /// The overflow menu's copy of an item validates like the item itself — including the checkmark,
    /// which is where a toggle's on-state goes when the menu is its face (03 ▸ Toolbar: "the system
    /// overflow") — and where a `.picker` row's checkmark goes too, against its own index rather than
    /// against `isOn` (which answers `nil` for the whole item, having no single on-state to give).
    @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
        guard let raw = menuItem.representedObject as? String,
              let spec = specs[NSToolbarItem.Identifier(raw)]
        else { return true }
        if case let .picker(_, selected, _) = spec.behavior {
            menuItem.state = selected() == menuItem.tag ? .on : .off
            return true
        }
        if let isOn = spec.isOn {
            menuItem.state = isOn ? .on : .off
        }
        return spec.isEnabled
    }

    // MARK: - Observed validation

    /// Re-arms itself on every change: `withObservationTracking` is one-shot by design, so the
    /// tracking closure reads every predicate again after each notification and starts a fresh
    /// observation over whatever it read this time.
    private func trackValidationState() {
        withObservationTracking {
            for spec in specs.values {
                _ = spec.isEnabled
                _ = spec.isOn
            }
        } onChange: { [weak self] in
            // The notification arrives *before* the change lands, so the re-read is deferred by a
            // turn — which is also what puts it back on the main actor.
            Task { @MainActor in
                guard let self else { return }
                self.revalidate()
                self.trackValidationState()
            }
        }
    }
}

// MARK: - The validated item

/// An `NSToolbarItem` whose validation is a closure over the predicate its menu row uses.
///
/// `NSToolbarItem`'s own `validate()` does nothing for an item with a custom view and asks the
/// target for `validateToolbarItem:` otherwise; overriding it outright is what lets one mechanism
/// serve both kinds — and what lets the two toggle-state reads (enabled, on) happen in the same
/// pass, so an item can never show one of them stale.
final class ValidatingToolbarItem: NSToolbarItem {

    var onValidate: (() -> Void)?

    override func validate() {
        onValidate?()
    }
}
