import AppKit

// MARK: - Identifiers

extension NSToolbarItem.Identifier {
    static let boardSearch = Self("board.search")
    static let boardNewCard = Self("board.newCard")
    static let boardNewLane = Self("board.newLane")
    static let boardUndo = Self("board.undo")
    static let boardRedo = Self("board.redo")
    static let boardShowTrash = Self("board.showTrash")
    static let boardZoomIn = Self("board.zoomIn")
    static let boardZoomOut = Self("board.zoomOut")
    static let boardAppearance = Self("board.appearance")
}

// MARK: - The board window's toolbar

/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
///
/// ### The default is the search field and Appearance, and the catalog is the rest
///
/// "**Board window default: the search field and Appearance** — both **centered**, the titlebar's
/// view-controls cluster." The flexible space ahead of the pair is what keeps them off the leading
/// edge before `centeredItemIdentifiers` takes over their placement.
///
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
///
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
///
/// ### Undo and Redo carry the session's own manager, exactly as the menu's rows do
///
/// **The command surface is the app's** (13-native-undo.md ▸ Rules ▸ the command-surface bullet,
/// re-ruled 2026-08-08): Edit ▸ Undo/Redo are the app's own rows reading the focused session's
/// `BoardUndoManager` through the focus system (`UndoCommands.swift`), and the two items below carry
/// an explicit target over **that same manager** — the session's, handed in by the window's host
/// (`BoardWindowHost.configureWindow`). "Matching their menu items" (03) is therefore one *object*
/// rather than one lookup: enablement is `canUndo`/`canRedo` on both surfaces, so a step landing, an
/// empty stack, and the read-only lock reach the pair and the rows together and cannot disagree
/// (`BoardUndoManager` answers all three, and is the only place any of them is decided).
///
/// The pair was nil-target `undo:`/`redo:` until that re-ruling, resolving up the responder chain to
/// the board window's `windowWillReturnUndoManager` — a route a SwiftUI window makes unreachable by
/// latching an empty undo manager of its own before any delegate of ours installs (diagnosed
/// 2026-08-07). Nothing about the *design* changed: the same manager, the same predicates, reached
/// by being handed it instead of by looking it up.
///
/// **Every board has undo**, so there is no substrate-shaped disablement to write: 03's
/// parenthetical about boards without undo was the *git* substrate, which a board once bound by being
/// in git mode — on any tier since 12-editions.md ▸ PIVOT 2026-08-07, and under a Pro subscription
/// before it, until app-managed git left the app entirely (`strategy/01-git-excision.md`,
/// 2026-08-08) and there stopped being a substrate to bind.
///
/// **Toolbar clicks are never text-routed.** The menu rows route ⌘Z to the first responder's own
/// manager while a text surface holds the keyboard (13 ▸ Undo routing); clicking a toolbar button is
/// not a keystroke aimed at an editor, so these two act on the board stack unconditionally — which is
/// also the only stack the item can see.
///
/// Their labels are the design's one exception to the menu-title rule: the *menu* titles are rewritten
/// as the stack changes ("Undo Move Card"), which a toolbar label does not track, so these two are
/// built from static labels (`ToolbarItemSpec.staticLabel`).
@MainActor
enum BoardToolbar {

    /// Shared by every board window, which is what makes the user's arrangement the *app's* rather
    /// than one window's — Finder's behaviour, and the reason the identifier is a constant.
    static let identifier = "dev.rzen.indie.Kanban.board"

    /// "The search field and Appearance — both centered, immediately after the field
    /// (03-board-ui.md ▸ Toolbar, the view-controls cluster)."
    static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch, .boardAppearance]

    /// The Appearance picker's rows, in menu order — the one place index and meaning are joined, so
    /// `specs(...)`'s `selected`/`select` closures and this array can never name two different
    /// orderings of the same three choices.
    private static let appearanceOptions: [AppAppearance?] = [nil, .light, .dark]

    /// - Parameters:
    ///   - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
    ///     store's, unlike every other predicate here, because the level is not a board's
    ///     (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from
    ///     `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
    ///     observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
    ///     at the top rung.
    ///   - appearance: the app-wide appearance override, `zoom`'s reason exactly — not the store's,
    ///     and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
    ///   - session: the app's drag session, for the same guard the menu rows carry
    ///     (`ZoomCommands.isEnabled`).
    ///   - undo: **the board session's stack**, for the Undo/Redo pair — the same `BoardUndoManager`
    ///     the Edit menu's rows read through the focus system (13-native-undo.md ▸ Rules ▸ the
    ///     command-surface bullet, re-ruled 2026-08-08). `nil` for a window whose session has gone,
    ///     which the pair reads as an empty stack: disabled, and crossing nothing.
    static func specs(
        store: BoardStore,
        search: BoardSearchPresentation,
        zoom: BoardZoomStore,
        appearance: AppearanceStore,
        session: DragSession,
        undo: BoardUndoManager?
    ) -> [ToolbarItemSpec] {
        [
            .mirroring(
                menuTitle: "New Card",
                identifier: .boardNewCard,
                symbol: "doc.badge.plus",
                behavior: .button(
                    isEnabled: { [weak store] in store?.newCardTarget != nil },
                    perform: { [weak store] in store?.beginNewCard() }
                )
            ),
            .mirroring(
                menuTitle: "New Lane",
                identifier: .boardNewLane,
                symbol: "rectangle.stack.badge.plus",
                behavior: .button(
                    isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
                    perform: { [weak store] in store?.createLane() }
                )
            ),
            // The zoom pair — plain buttons, because that is what the menu rows are. There is no
            // percentage readout and no popup: a control that *displays* the level would need a
            // custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a
            // spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself).
            .mirroring(
                menuTitle: "Zoom In",
                identifier: .boardZoomIn,
                symbol: "plus.magnifyingglass",
                behavior: .button(
                    isEnabled: { [weak store, weak zoom, weak session] in
                        guard let zoom, let session else { return false }
                        return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn
                    },
                    perform: { [weak zoom] in zoom?.step(.in) }
                )
            ),
            .mirroring(
                menuTitle: "Zoom Out",
                identifier: .boardZoomOut,
                symbol: "minus.magnifyingglass",
                behavior: .button(
                    isEnabled: { [weak store, weak zoom, weak session] in
                        guard let zoom, let session else { return false }
                        return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut
                    },
                    perform: { [weak zoom] in zoom?.step(.out) }
                )
            ),
            // The one pair whose predicate is not a menu row's *expression* but the menu row's own
            // object: `canUndo`/`canRedo` on the session's manager, which is where the empty stack
            // and the read-only lock are both already decided (`BoardUndoManager`). Weak like every
            // other capture here — production hands these app-lived objects, and a spec must not be
            // what keeps a torn-down session's stack alive.
            .staticLabel(
                "Undo",
                identifier: .boardUndo,
                symbol: "arrow.uturn.backward",
                behavior: .button(
                    isEnabled: { [weak undo] in undo?.canUndo == true },
                    perform: { [weak undo] in undo?.undo() }
                )
            ),
            .staticLabel(
                "Redo",
                identifier: .boardRedo,
                symbol: "arrow.uturn.forward",
                behavior: .button(
                    isEnabled: { [weak undo] in undo?.canRedo == true },
                    perform: { [weak undo] in undo?.redo() }
                )
            ),
            .mirroring(
                menuTitle: "Show Trash",
                identifier: .boardShowTrash,
                symbol: "trash",
                behavior: .toggle(
                    isEnabled: { [weak store] in store != nil },
                    isOn: { [weak store] in store?.transient.isTrashVisible == true },
                    setOn: { [weak store] shown in store?.setTrashVisible(shown) }
                )
            ),
            // A pull-down rather than a toggle: Auto/Light/Dark is a three-way exclusive choice, not
            // an on/off bit. The one default (and centered) catalog item beside the field
            // (`defaultItems`, `controller(...)`'s `centeredItemIdentifiers`), always enabled — an
            // appearance override needs no board state, exactly as the View-menu row needs no board
            // window (`AppearanceCommands`).
            .mirroring(
                menuTitle: "Appearance",
                identifier: .boardAppearance,
                symbol: "circle.lefthalf.filled",
                behavior: .picker(
                    options: [
                        (title: "Auto", symbol: nil),
                        (title: "Light", symbol: nil),
                        (title: "Dark", symbol: nil),
                    ],
                    selected: { [weak appearance] in
                        guard let appearance else { return nil }
                        return appearanceOptions.firstIndex(of: appearance.override)
                    },
                    select: { [weak appearance] index in
                        guard appearanceOptions.indices.contains(index) else { return }
                        appearance?.setOverride(appearanceOptions[index])
                    }
                )
            ),
            .staticLabel(
                "Search",
                identifier: .boardSearch,
                symbol: nil,
                // The width in *characters* rather than points (10-accessibility.md ▸ Text scaling:
                // "no fixed point sizes") — the same 17 ems the transient bar's field takes
                // (`BoardSearchBar`). It is the **focused** width here: `NSSearchToolbarItem` grows
                // the field to its preferred width when the keyboard arrives and lets it settle
                // back to the item's natural width when the keyboard leaves, so the two homes match
                // once the user is typing rather than at rest.
                behavior: .searchField(
                    focusedWidth: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize),
                    make: { [weak store] willBeInserted in
                        guard willBeInserted, let store else {
                            return BoardSearchFieldController.makePaletteField()
                        }
                        return BoardSearchFieldController.makeField(
                            store: store,
                            presentation: search,
                            home: .toolbar
                        )
                    },
                    // The toolbar half of the field's home that only the *item* can answer: ⌘F's
                    // expand-and-focus, and the collapse Escape's second step asks for.
                    install: { item in
                        BoardSearchFieldController.adopt(item, presentation: search)
                    }
                )
            ),
        ]
    }

    /// The window's toolbar, wired to tell the search presentation where its field currently lives —
    /// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
    /// toolbar, invoking it surfaces the field transiently until the search clears").
    static func controller(
        store: BoardStore,
        search: BoardSearchPresentation,
        zoom: BoardZoomStore,
        appearance: AppearanceStore,
        session: DragSession,
        undo: BoardUndoManager?
    ) -> WindowToolbarController {
        let controller = WindowToolbarController(
            identifier: identifier,
            specs: specs(
                store: store,
                search: search,
                zoom: zoom,
                appearance: appearance,
                session: session,
                undo: undo
            ),
            defaults: defaultItems
        )
        // Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
        // grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
        // and sits outside the autosaved configuration, so it reaches machines that saved an
        // arrangement under the old trailing default. `defaultItems` is untouched. Appearance joined
        // the cluster after search (`defaultItems`'s own order), so the two center together as one
        // group — search first, Appearance beside it — rather than as two independently-placed items.
        controller.toolbar.centeredItemIdentifiers = [.boardSearch, .boardAppearance]
        controller.onInstalledItemsChanged = { [weak search] identifiers in
            search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
        }
        return controller
    }
}
