import AppKit

// MARK: - BoardUndoManager

/// The face a board's undo wears everywhere it is asked about: an `UndoManager` that owns no stack
/// and answers every question from the session's `HistoryProviding`.
///
/// ### Why an adapter exists at all
///
/// The command surface speaks one currency. Enablement, the composed menu title, and the crossing
/// itself are all `UndoManager` questions — `canUndo`, `undoMenuItemTitle`, `undo()` — and every
/// surface that offers ⌘Z asks them of *something*: the app's own Edit ▸ Undo/Redo rows read this
/// object through `FocusedValues.undoStack` (`UndoCommands.swift`), the board toolbar's pair carries
/// it as an explicit target (`BoardToolbar`), and AppKit itself still asks the window's delegate for
/// it (`HostedWindowController.windowWillReturnUndoManager`, wired and correct wherever the platform
/// is the one asking). One object answers all of them, which is what keeps them from disagreeing.
///
/// (Those first two used to be the same fact as the third: the rows and the toolbar pair were the
/// system's nil-target `undo:`/`redo:`, validated by `NSWindow` against whatever its delegate vended.
/// That route is unreachable on a SwiftUI window — the window latches an empty manager of its own
/// during creation, before any delegate of ours installs — so the surface became the app's own,
/// reading this object directly: 13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled
/// 2026-08-08. What it reads did not change, only how it gets here.)
///
/// The seam, though, must not be an `NSUndoManager`: a protocol that vended one could only ever have
/// had a single implementation, and the substrate stays behind `HistoryProviding` so a future
/// provider can bind without touching this class (`strategy/01-git-excision.md` ▸ Reversibility). So
/// *this* object is the translation — one per board session, over whichever provider that session
/// was composed with. (Once two providers shared the seam this way — a gitless board's native stack,
/// a Pro git board's git history, 12-editions.md ▸ The provider seam — with whichever bound
/// inheriting the whole command surface, enablement, dynamic titles, ⌘Z, the toolbar pair, by
/// binding and changing nothing here. App-managed git is gone now, 2026-08-08, and native is the
/// only one left.)
///
/// **And one per open card window**, over that window's own stack (13 ▸ Rules ▸ two levels, re-ruled
/// 2026-07-31 — `CardWindowUndo.manager`): the second level needs precisely the same translation, so
/// it gets the same object rather than a second one shaped like it. The name is now half-right and
/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not.
///
/// ### It deliberately keeps its inherited stack empty
///
/// Every question is overridden, so anything that *did* register into this manager by accident (a
/// stray AppKit client finding it through the window) would be invisible rather than half-live: it
/// could never enable Undo, retitle it, or be crossed by ⌘Z. Nothing registers here today; the
/// overrides are what make that safe rather than lucky.
///
/// `removeAllActions()` is deliberately **not** forwarded to `HistoryProviding.clear()`: AppKit
/// clears a window's undo manager on paths this app does not control, and a card window closing must
/// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and
/// its one clearing point is that session's teardown).
///
/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its
/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that
/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded
/// clear would silently make every close a no-net-change close.
///
/// ### The read-only lock disables Undo and Redo here
///
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
/// disables Undo/Redo with the other mutating commands; **the stack itself survives the lock and
/// resumes when it clears**" (13 ▸ Rules). This is the right place for it and the only one: every
/// surface that offers ⌘Z — the Edit menu's own rows over the focused stack, the board toolbar's
/// pair over the session's, a card window's over its own — validates through this object, so
/// answering `false` here disables all of them at once, exactly as the lock's other victims disable
/// through menu validation (02-architecture.md
/// § "The lock's scope"). Putting it in the *provider* would have been the same answer in the wrong
/// place: the stack is not the thing that is locked, the board is, and whatever provider a session
/// binds — a future one included — must inherit the rule without reimplementing it.
public final class BoardUndoManager: UndoManager {

    /// The substrate this manager is a face for. Strong: the session owns both, and the manager is
    /// only ever reachable while the session that made it is alive.
    ///
    /// ### `nil` is a board with **no undo provider**, and it is a real state
    ///
    /// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
    /// only under locks and on empty stacks — the provider follows the board, so boards without
    /// app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every**
    /// tier" (03-board-ui.md ▸ Toolbar ▸ Catalog). The repo-nested board was the last holder of this
    /// state and no longer is: that rule was about *git*, and this stack never touches git. (The
    /// distinction the quote draws collapsed entirely with the 2026-08-08 excision,
    /// `strategy/01-git-excision.md`: every board is "without app-managed git" now, not just
    /// repo-nested ones.)
    ///
    /// The state stays modelled because the seam still admits it — a test binds a substrate-less
    /// session through `AppModel.makeHistoryProvider` — and because an absent substrate is the honest
    /// shape for one: every question below answers the empty way, so the Edit menu's rows, the
    /// toolbar pair, and ⌘Z itself go quiet together through the same validation path a lock uses,
    /// and nothing can accidentally accumulate in a stack that is not there.
    ///
    /// ### Settable, once for exactly one event — now for none
    ///
    /// **Add-git** (06-history-undo.md ▸ Rules ▸ Detection, retired) was the design's one sanctioned
    /// mid-session mode flip: "clicking it flips the open board into git mode immediately", swapping
    /// the substrate — the mode-none board's native stack discarded for the git trail seeded from the
    /// root commit. The composition root wrote the new provider into this property rather than
    /// rebuilding this object, so AppKit kept the identical manager it had already been handed by
    /// `windowWillReturnUndoManager` and simply revalidated over a different stack.
    ///
    /// App-managed git left the app entirely on 2026-08-08 (`strategy/01-git-excision.md`), and
    /// add-git went with it: nothing left in the app ever assigns this property after composition. It
    /// stays a `var` rather than a `let` for the same reason `HistoryProviding` stays a protocol — a
    /// future substrate swap, mid-session or not, re-binds without re-plumbing this class.
    var history: (any HistoryProviding)?

    /// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
    /// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
    /// and Pro binds a different substrate behind the same one), and a closure is what lets the
    /// composition root wire the board's own truth in without this file learning what a `BoardStore`
    /// is. The default answers "writable", which is what a manager built without a board — a test of
    /// the adapter's own grammar — should have.
    private let isReadOnly: @MainActor () -> Bool

    public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
        self.history = history
        self.isReadOnly = isReadOnly
        super.init()
    }

    // MARK: Enablement

    /// **False under the lock, whatever the stack holds.** The steps are still there — this is an
    /// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
    /// would have crossed before it landed. False with no substrate at all, for the reason
    /// `history` records.
    public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true }

    public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }

    // MARK: Crossing

    /// Not gated on the lock, deliberately: `undo:` reaches a manager only through a menu item or
    /// toolbar button that has already validated against `canUndo`, and a crossing that somehow
    /// started anyway is refused one layer down by `performWrite` — which leaves the step on the
    /// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
    /// guard here would be a second answer to one question.
    public override func undo() { history?.undo() }

    public override func redo() { history?.redo() }

    // MARK: Titles

    /// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
    /// nothing to cross, which is what its menu-title composition expects.
    public override var undoActionName: String { history?.undoActionName ?? "" }

    public override var redoActionName: String { history?.redoActionName ?? "" }

    /// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
    /// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
    /// never spells the word "Undo" in a title.
    ///
    /// The trim is for the nameless case: the pattern is `Undo %@`, so an empty action name would
    /// otherwise leave a trailing space where `NSUndoManager` itself answers a bare "Undo".
    public override var undoMenuItemTitle: String {
        undoMenuTitle(forUndoActionName: undoActionName).trimmingCharacters(in: .whitespaces)
    }

    public override var redoMenuItemTitle: String {
        redoMenuTitle(forUndoActionName: redoActionName).trimmingCharacters(in: .whitespaces)
    }
}

// MARK: - BoardUndoRouting

/// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md
/// ▸ Undo routing, which 12 records as a fact about *focus* and never about the substrate: "focus
/// decides text-undo vs board-undo; only the substrate behind board-undo differs".
///
/// ### Most of the rule is AppKit's, and is not here
///
/// "While a text-editing surface is focused ... ⌘Z/⇧⌘Z are that editor's own text undo" is the
/// platform's own behaviour for a text view that vends a manager: `NSWindow.undo:` crosses the
/// *first responder's* `undoManager`, and both of this app's real editors vend their own through the
/// text-view delegate (`CardBodySurface`, `CardRawSourceView` — each with an `UndoManager` created
/// per editor, emptied when the session ends). Those never reach this file.
///
/// ### What is here is the case AppKit gets wrong
///
/// A **field editor** — the shared `NSTextView` that appears inside every focused `NSTextField` and
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
/// window-level answer is the board's stack **only when no text-editing surface holds the
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
/// have created for such a window on its own, so nothing about typing in a field changes.
///
/// Pure and free of windows on purpose: the decision is three lines that are invisible until they
/// are wrong, and the responder it reads is the only part a test cannot conjure.
public enum BoardUndoRouting {

    /// Whether `responder` is a text-editing surface — every `NSTextView`, field editors included,
    /// which `NSText` is precisely the ancestor of. An `NSTextField` that has focus without editing
    /// is *not* one: AppKit installs the field editor the moment editing begins, and until then the
    /// board is what the keyboard is acting on.
    public static func isTextEditing(_ responder: NSResponder?) -> Bool {
        responder is NSText
    }

    /// The manager a window's delegate should answer with.
    ///
    /// `board` is `nil` for every window that is not showing a board — welcome, the restore
    /// bootstrap, the template chooser, and a board or card window whose session has already been
    /// torn down. Those get the text manager too, which is the platform default they had before this
    /// seam existed.
    public static func undoManager(
        isTextEditing: Bool,
        board: UndoManager?,
        textFallback: UndoManager
    ) -> UndoManager {
        guard !isTextEditing, let board else { return textFallback }
        return board
    }
}
