import AppKit
import Observation
import SwiftUI

// MARK: - The focused stack

/// **The stack the frontmost window's ⌘Z crosses**, published into the focus system by whichever
/// host owns it: `BoardWindowHost` publishes its session's manager, `CardWindowHost` the window's own
/// (`CardWindowUndo.manager`).
///
/// One key serves both levels, and that is the two-level model's own shape rather than a shortcut
/// (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31): a card window's stack is the *same*
/// face type over the same seam, so a second key would only ask the rows to decide which of two
/// answers is in front — which is exactly the question the focus system already answers. Crossing
/// between the two levels is therefore impossible by construction here, which is 13 ▸ Undo routing's
/// "no fall-through" holding as a fact about the value rather than as a rule someone applies.
///
/// `FocusedBoardStoreKey` is the precedent and its note carries over verbatim: `focusedSceneValue`
/// rather than `focusedValue`, because the value is the *window's* and not any particular control's,
/// so it stays available whatever inside the window holds the keyboard — which is precisely what a
/// row that then routes on the first responder needs to read.
struct FocusedUndoStackKey: FocusedValueKey {
    typealias Value = BoardUndoManager
}

extension FocusedValues {
    var undoStack: BoardUndoManager? {
        get { self[FocusedUndoStackKey.self] }
        set { self[FocusedUndoStackKey.self] = newValue }
    }
}

// MARK: - Routing

/// **What an Undo/Redo row answers with**, given the focused stack and what holds the keyboard —
/// 13-native-undo.md ▸ Undo routing's predicate, relocated from the window delegate to the command
/// layer (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08; the rule itself is
/// unchanged, only where it is enacted).
///
/// ### Why the rule had to move
///
/// The predicate used to be the platform's to apply: the system's nil-target `undo:`/`redo:` rows
/// resolve to `NSWindow`, which reads the manager its delegate hands back
/// (`windowWillReturnUndoManager`, still wired in `HostedWindowController` and still the right answer
/// wherever AppKit itself asks). On a SwiftUI window that hook is never consulted — `NSWindow` reads
/// and **permanently latches** an undo manager of its own during window creation, before any app code
/// can install a delegate (diagnosed 2026-08-07 by live probe) — so the whole command surface became
/// the app's own, and the routing came with it.
///
/// ### Pure, and free of both windows and SwiftUI
///
/// Everything a row needs is two references it is handed. The first responder is the one part a test
/// cannot conjure from the focus system, so it is a parameter rather than a read of `NSApp` — the
/// same posture `BoardUndoRouting` takes one layer down, and the reason that enum's decision is
/// reusable here verbatim rather than restated.
@MainActor
enum UndoCommandRouting {

    /// The manager a row answers with — the focused stack, or, while a text surface holds the
    /// keyboard, that responder's own manager.
    ///
    /// The text case is where its typing undo actually registered: an editor that vends a manager
    /// through its delegate (`CardBodySurface`, `CardRawSourceView`) hands back that one, and a field
    /// editor — which vends none — resolves up the responder chain to the window's latched manager,
    /// which is exactly where AppKit put its typing undo. Either way the answer is the responder's,
    /// which is what "a reflexive undo over a typo must never become a board-level restore"
    /// (13 ▸ Undo routing) means once the app is the one choosing.
    ///
    /// `nil` where there is nothing to cross at all: no stack in front and no text surface focused, or
    /// a text surface whose own manager is `nil` (a view in no window, with no delegate to ask). A row
    /// over `nil` disables and keeps the bare verb.
    static func routedManager(stack: BoardUndoManager?, firstResponder: NSResponder?) -> UndoManager? {
        guard BoardUndoRouting.isTextEditing(firstResponder) else { return stack }
        guard let text = firstResponder?.undoManager else { return nil }
        return BoardUndoRouting.undoManager(isTextEditing: true, board: stack, textFallback: text)
    }

    // MARK: Titles

    /// "Undo Move 3 Cards" — composed and localized by the platform over the bare 13-vocabulary
    /// phrase, so the app never spells the verb (`BoardUndoManager.undoMenuItemTitle`).
    ///
    /// The trim is for the nameless case on a manager that is *not* this app's adapter: a plain
    /// `NSUndoManager` — a text view's, a field editor's — composes `Undo %@` over an empty name and
    /// leaves the trailing space standing. The bare verb is also the answer for no manager at all,
    /// which is a row with nothing in front of it rather than a row that lost its name.
    static func undoTitle(of manager: UndoManager?) -> String {
        title(manager?.undoMenuItemTitle, bare: "Undo")
    }

    static func redoTitle(of manager: UndoManager?) -> String {
        title(manager?.redoMenuItemTitle, bare: "Redo")
    }

    private static func title(_ composed: String?, bare: String) -> String {
        let trimmed = composed?.trimmingCharacters(in: .whitespaces) ?? ""
        return trimmed.isEmpty ? bare : trimmed
    }

    // MARK: Enablement

    /// Whether the row is live — the routed manager's own answer, which for this app's adapter is
    /// "steps, and no read-only lock" (`BoardUndoManager.canUndo`, where 13 ▸ Rules' lock clause
    /// lives) and for a text manager is the editor's session. `false` with no manager, which is the
    /// same disablement an empty stack gets: exhausting what the row reaches never reaches anything
    /// else (13 ▸ Undo routing, no fall-through).
    static func canUndo(_ manager: UndoManager?) -> Bool {
        manager?.canUndo == true
    }

    static func canRedo(_ manager: UndoManager?) -> Bool {
        manager?.canRedo == true
    }
}

// MARK: - The revalidation ticker

/// **What makes a rendered Undo/Redo row notice the world moved.**
///
/// The rows read three things that are not SwiftUI state: the routed manager's enablement, its
/// composed title, and — through the first responder — which of the two it is routing to at all.
/// `BoardUndoManager` re-derives from an `@Observable` provider, so a board or card step lands on the
/// row for free; a *text* manager is a plain `NSUndoManager` with no observation in it, and neither
/// is `NSApp.keyWindow?.firstResponder`. This is the subscription those two get.
///
/// A bumped counter rather than published state: nothing here knows what any row's answer is, only
/// that it may have changed, and the rows are cheap to re-derive. Reading `revision` inside a row's
/// `body` is what enrolls it.
///
/// ### The notifications, and why each one
///
/// `NSUndoManagerCheckpoint` with `object: nil` is the load-bearing one: it catches **every**
/// manager's stack changes, which is how typing into a field editor retitles the row that is routing
/// to it. The did-undo/did-redo pair covers a crossing that changes which direction is live, the
/// `NSText` editing pair covers the field editor arriving and leaving, and `NSMenu`'s did-begin-
/// tracking is a re-derive a moment before the Edit menu draws itself. The checkpoint is also what a
/// text manager posts back when a row merely *reads* it — an echo the rows silence, because heard it
/// is a livelock (`silencingReadEchoes`).
///
/// ### The known residual, which is cosmetic
///
/// Focus moving in or out of a text surface *without any typing* — clicking into an empty search
/// field, tabbing away from an untouched one — bumps nothing, because AppKit posts no editing
/// notification until an edit begins. A row rendered across that moment can therefore be titled or
/// enabled off the stack it is no longer routing to. Behaviour stays correct regardless: the action
/// re-derives the routed manager at fire time and beeps rather than crossing the wrong stack, which
/// is 13 ▸ Undo routing's own answer for an exhausted focus.
@MainActor
@Observable
final class UndoCommandTicker {

    /// One instance, read by both rows: the rows differ in direction, never in when they are stale.
    static let shared = UndoCommandTicker()

    /// Bumped, never read for its value — a row reads it to subscribe, and the number itself means
    /// nothing.
    private(set) var revision = 0

    /// Runs a row's derivation with the ticker deaf to it.
    ///
    /// Reading a plain `NSUndoManager`'s enablement or composed title is not passive: `canRedo` —
    /// and the composed redo title through it — posts `NSUndoManagerCheckpoint` synchronously as a
    /// side effect (documented `NSUndoManager` behavior; `BoardUndoManager`'s overrides answer from
    /// the provider and post nothing, so a board-routed read never echoes — only a *text* manager's
    /// can). Un-silenced, that echo closes a feedback loop through this ticker: the row's `body`
    /// reads a title, the read posts a checkpoint, `bump()` lands mid-`body`, the observation
    /// invalidates the row, and the re-derived `body` reads the title again — the main thread never
    /// returns to the event loop. Diagnosed 2026-08-08 by sampling a frozen instance; entered
    /// whenever the rows rendered while a text surface held the keyboard, which is why the board-only
    /// live probe never saw it. A read cannot change the state it reads, so the echo carries no
    /// information and dropping it loses nothing.
    ///
    /// The window is synchronous and main-actor, so nothing else can slip a *genuine* checkpoint
    /// into it — and one posted from off the main thread hops through `Task` and lands after it
    /// closes.
    func silencingReadEchoes<T>(_ read: () -> T) -> T {
        let outer = isSilenced
        isSilenced = true
        defer { isSilenced = outer }
        return read()
    }

    /// See `silencingReadEchoes` — bookkeeping about observation, never state a view reads.
    @ObservationIgnored private var isSilenced = false

    private var observers: [any NSObjectProtocol] = []

    private init() {
        let names: [Notification.Name] = [
            .NSUndoManagerCheckpoint,
            .NSUndoManagerDidUndoChange,
            .NSUndoManagerDidRedoChange,
            NSText.didBeginEditingNotification,
            NSText.didEndEditingNotification,
            NSMenu.didBeginTrackingNotification,
        ]
        observers = names.map { name in
            NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in
                // All six are posted on the main thread, and the synchronous path is the one that
                // matters: a menu that has begun tracking is about to draw, so a hop onto the next
                // turn would re-derive the row after the user is already looking at it. The
                // asynchronous branch is the promise-keeping half, never the expected one.
                if Thread.isMainThread {
                    MainActor.assumeIsolated { self?.bump() }
                } else {
                    Task { @MainActor in self?.bump() }
                }
            }
        }
    }

    private func bump() {
        guard !isSilenced else { return }
        revision &+= 1
    }
}

// MARK: - The rows

/// **Edit ▸ Undo and Edit ▸ Redo, as the app's own rows** (13-native-undo.md ▸ Rules ▸ the
/// command-surface bullet, ruled 2026-08-08).
///
/// `CommandGroup(replacing: .undoRedo)` takes the system's nil-target pair out of the menu, because
/// on a SwiftUI window they are unreachable: `NSWindow` latches an empty undo manager of its own
/// during window creation, before `HostedWindowController` can install the delegate that would have
/// vended the board's, so `windowWillReturnUndoManager` is never consulted and the rows validate
/// against a stack nothing ever registers into. Everything the pair used to get from the platform —
/// enablement, the dynamic title, the crossing — the rows below now ask for by name, off the same
/// `BoardUndoManager` that surface always meant to be reading.
///
/// The alternatives are recorded in 13 and both re-open decisions this app already made: registering
/// steps into the latched manager needs an `NSUndoManager` substrate, which `HistoryStepOutcome
/// .failed` rules out (`NativeHistoryProvider` ▸ two arrays), and intercepting nil-target `undo:`
/// from the responder chain is preempted by `NSWindow` handling the action itself, ahead of its own
/// delegate.
struct UndoRedoCommands: Commands {

    var body: some Commands {
        CommandGroup(replacing: .undoRedo) {
            UndoMenuRow()
            RedoMenuRow()
        }
    }
}

/// Edit ▸ Undo (⌘Z).
///
/// **The whole row routes, not just its action** (13 ▸ Rules ▸ the command-surface bullet, re-ruled
/// 2026-08-08). A row left titled and enabled off the board stack while a text field held the
/// keyboard would fail in one of two ways, and both are worse than a re-derived title: enabled, it
/// advertises a board step it will not cross once the action routes elsewhere; disabled — because
/// the board stack happened to be empty — it *swallows* ⌘Z outright, since a menu item owns its key
/// equivalent whether or not it is live and nothing downstream ever sees the chord. Routing title,
/// enablement and action together is what keeps 13 ▸ Undo routing's no-fall-through honest from the
/// user's side of the menu.
private struct UndoMenuRow: View {

    @FocusedValue(\.undoStack) private var stack

    var body: some View {
        // Read for the subscription, never for the value — see `UndoCommandTicker`, which is what
        // makes a text manager's stack changes and a focus move reach a row that has already
        // rendered. It has to be read *in* `body`, which is the only scope SwiftUI tracks.
        let _ = UndoCommandTicker.shared.revision
        let manager = UndoCommandRouting.routedManager(
            stack: stack,
            firstResponder: NSApp.keyWindow?.firstResponder
        )
        // Silenced because these reads *post*: a text manager answers its title and enablement by
        // firing a checkpoint, and a checkpoint bumping the ticker from inside `body` is the row
        // invalidating itself, forever (`UndoCommandTicker.silencingReadEchoes`).
        let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
            (UndoCommandRouting.undoTitle(of: manager), UndoCommandRouting.canUndo(manager))
        }
        Button(title) {
            cross()
        }
        .keyboardShortcut("z", modifiers: .command)
        .disabled(!isLive)
    }

    /// **Routed again here**, rather than closing over what the row rendered with: focus and stacks
    /// both move between a menu's display and its click, and the manager the crossing reaches must be
    /// the one that holds the keyboard *now*. The beep is 13 ▸ Undo routing's "exhausting a focused
    /// editor's stack beeps; it never reaches board history", answering the window where the row's
    /// rendered enablement was a moment stale.
    private func cross() {
        let manager = UndoCommandRouting.routedManager(
            stack: stack,
            firstResponder: NSApp.keyWindow?.firstResponder
        )
        guard let manager, manager.canUndo else {
            NSSound.beep()
            return
        }
        manager.undo()
    }
}

/// Edit ▸ Redo (⇧⌘Z) — `UndoMenuRow`'s twin in every respect, which is why its reasoning is not
/// repeated here.
private struct RedoMenuRow: View {

    @FocusedValue(\.undoStack) private var stack

    var body: some View {
        let _ = UndoCommandTicker.shared.revision
        let manager = UndoCommandRouting.routedManager(
            stack: stack,
            firstResponder: NSApp.keyWindow?.firstResponder
        )
        let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
            (UndoCommandRouting.redoTitle(of: manager), UndoCommandRouting.canRedo(manager))
        }
        Button(title) {
            cross()
        }
        .keyboardShortcut("z", modifiers: [.command, .shift])
        .disabled(!isLive)
    }

    private func cross() {
        let manager = UndoCommandRouting.routedManager(
            stack: stack,
            firstResponder: NSApp.keyWindow?.firstResponder
        )
        guard let manager, manager.canRedo else {
            NSSound.beep()
            return
        }
        manager.redo()
    }
}
