import SwiftUI

// MARK: - View ▸ Zoom In / Zoom Out / Actual Size

/// The board's three zoom rows (11-command-nexus.md ▸ View; behaviour in 03-board-ui.md ▸ Layout —
/// zoom).
///
/// They step one app-wide level (`BoardZoomStore`), which every open board window reads through the
/// environment, so this is deliberately *not* a per-window command: ⌘+ in one window zooms the board
/// in the other too, the way Show Comments checks in every card window at once.
///
/// ### Why they are scoped to a board window anyway
///
/// The level is app-wide but the *surface* it moves is not: only board windows zoom (the card window
/// is a later milestone). A ⌘+ that changed nothing visible while a card window was frontmost would
/// be a command that silently missed, so `@FocusedValue(\.boardStore)` scopes the rows the way every
/// other board command is scoped — present and live over a board, disabled everywhere else.
///
/// ### The three disabled states
///
/// Each end of the ladder disables its own direction and Actual Size disables at 100%: "an item whose
/// only outcome is a no-op reads better disabled than dead" (`LaneWidthCommands.canDecrease`).
///
/// **All three also disable while a drag session is in flight**, which is an invariant stated rather
/// than a defence. A card or lane drag freezes geometry the level feeds — the drag's frozen card
/// heights, and `RestingLayoutCache`, whose entry key does not include the point size — so a level
/// that moved underneath one would leave the proposal resolving against a layout the board is no
/// longer drawing. In practice an AppKit drag loop swallows key equivalents and no toolbar button can
/// be clicked with the mouse already down, so the guard should never fire; `BoardView` clears the
/// resting layouts on a level change regardless, which is what actually makes the case safe. This is
/// the honest statement of the rule, and the thing a test can hold
/// (`ShowCommentsCommand.isEnabled`'s pattern).
///
/// The read-only lock is deliberately absent, for `ShowTrashCommand`'s reason: zooming is a view
/// change, not a mutation, and a locked board is exactly when a user wants to read it more
/// comfortably.
struct ZoomCommands: View {

    /// The app-wide level. A plain `let` rather than an `@Environment` read because menu commands live
    /// in the menu bar, outside every scene's environment; it re-renders on a level change because
    /// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
    let appModel: AppModel

    @FocusedValue(\.boardStore) private var store

    var body: some View {
        Button("Zoom In") { appModel.zoom.step(.in) }
            .keyboardShortcut("+", modifiers: .command)
            .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomIn)

        Button("Zoom Out") { appModel.zoom.step(.out) }
            .keyboardShortcut("-", modifiers: .command)
            .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomOut)

        Button("Actual Size") { appModel.zoom.step(.actualSize) }
            .keyboardShortcut("0", modifiers: .command)
            .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || appModel.zoom.isActualSize)
    }

    /// Whether the rows apply at all — a board window in front, and no drag in flight.
    ///
    /// Extracted as a static function so the rule is assertable without driving a menu
    /// (`ShowCommentsCommand.isEnabled`, `SaveAsTemplateCommand.allowsSave`). It deliberately does
    /// *not* fold in the per-direction ladder ends: those are properties of the level, already stated
    /// on `BoardZoom`, and duplicating them here would be two answers to one question.
    static func isEnabled(store: BoardStore?, session: DragSession) -> Bool {
        store != nil && !session.isActive
    }
}

// MARK: - The move, and the one place it happens

/// Which way a zoom command goes — the three rows' whole difference from one another.
///
/// An enum rather than three methods so `BoardZoomStore.step(_:)` can be the single write path the
/// menu rows *and* the toolbar buttons share, the way `BoardStore.setTrashVisible` is Show Trash's
/// (03-board-ui.md ▸ Toolbar: a toolbar item is a menu command with a different face, never a second
/// implementation of it).
enum ZoomMove {
    case `in`
    case out
    case actualSize
}

extension BoardZoomStore {

    /// A zoom command's whole behaviour — move the level, animate the board under it, say the new
    /// level out loud.
    func step(_ move: ZoomMove) {
        // A user-initiated structural change, so it animates in the structural voice and goes instant
        // under Reduce Motion (03-board-ui.md § Motion: "everything the user does through the app …
        // lands in an animated transaction regardless of entry point").
        //
        // Reduce Motion read from AppKit rather than from `@Environment` for `setTrashVisible`'s
        // reason: a menu command's content is built outside any rendered hierarchy, and a toolbar item
        // has no environment at all.
        withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
            switch move {
            case .in: zoomIn()
            case .out: zoomOut()
            case .actualSize: actualSize()
            }
        }
        // Announced from here rather than from each row, so the two toolbar buttons say the same
        // sentence as the menu (10-accessibility.md ▸ Text scaling). Unconditional even when the level
        // did not move: a user who pressed ⌘+ at the top rung is owed the answer "still 200%", and a
        // silent no-op is the one response that reads as a broken command.
        AccessibilityAnnouncer.post(AccessibilityPhrases.zoomLevel(percentLabel))
    }
}
