import AppKit
import Testing
@testable import Kanban

// MARK: - Fixtures

/// A board stack with a real substrate behind it — the pair a session composes (`AppModel`).
@MainActor
private func makeStack(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) {
    let provider = NativeHistoryProvider()
    return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider)
}

/// A step that applies in both directions and does nothing else — this suite's subject is which
/// manager a row *reaches*, never what crossing it does to disk.
@MainActor
private func step(_ name: String) -> HistoryStep {
    HistoryStep(name: name, undo: { _ in .applied }, redo: { _ in .applied })
}

/// A text view's delegate vending a manager of its own — `CardBodySurface` and `CardRawSourceView`
/// in one line, which is the shape 13-native-undo.md ▸ Undo routing calls "an editor's
/// delegate-vended manager".
@MainActor
private final class UndoVendingDelegate: NSObject, NSTextViewDelegate {

    let manager = UndoManager()

    func undoManager(for view: NSTextView) -> UndoManager? { manager }
}

// MARK: - Routing

/// **Which manager the app's own Undo/Redo rows answer with** (13-native-undo.md ▸ Undo routing, the
/// predicate; ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08 — the rule unchanged, its
/// enactment moved from the window delegate to the command layer).
///
/// The decision is three lines that are invisible until they are wrong, which is `BoardUndoRouting`'s
/// own reason for being a pure enum and this suite's for pinning every branch of the one above it.
@MainActor
@Suite("Undo commands ▸ routing")
struct UndoCommandRoutingTests {

    @Test("With nothing holding the keyboard, a row answers with the focused stack")
    func noResponderAnswersTheStack() {
        let (manager, _) = makeStack()

        #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: nil) === manager)
        #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: nil) == nil)
    }

    @Test("A responder that is not a text surface is not one — the stack answers")
    func nonTextResponderAnswersTheStack() {
        let (manager, _) = makeStack()

        #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSResponder()) === manager)
        #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSView()) === manager)
        // A field that has focus without editing is the board's, not the field editor's: AppKit
        // installs the field editor only when editing begins (`BoardUndoRouting.isTextEditing`).
        #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: NSTextField()) === manager)
        #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: NSView()) == nil)
    }

    @Test("An editor's own manager wins the keyboard, whatever the board stack holds")
    func aFocusedEditorAnswersWithItsOwnManager() {
        let (manager, provider) = makeStack()
        provider.register(step("Move 3 Cards"))
        let delegate = UndoVendingDelegate()
        let editor = NSTextView()
        editor.allowsUndo = true
        editor.delegate = delegate

        let routed = UndoCommandRouting.routedManager(stack: manager, firstResponder: editor)

        #expect(routed === delegate.manager)
        // **No fall-through** (13 ▸ Undo routing): the editor's stack is empty and the board's is
        // not, and the row still answers with the editor's — "exhausting a focused editor's stack
        // beeps; it never reaches board history". The row that renders over this is disabled, and
        // the ⌘Z that fires against it beeps.
        #expect(manager.canUndo, "the board has a step to cross, and the row will not cross it")
        #expect(UndoCommandRouting.canUndo(routed) == false)
        #expect(UndoCommandRouting.undoTitle(of: routed) == "Undo", "the editor's bare verb, not the board's phrase")
    }

    @Test("A text surface with no manager of its own answers nothing at all")
    func aTextSurfaceWithNoManagerAnswersNil() {
        let (manager, provider) = makeStack()
        provider.register(step("Move 3 Cards"))
        let editor = NSTextView()
        editor.allowsUndo = true

        // No delegate to vend one and no window to inherit one from: the responder chain ends here.
        // `nil` rather than the board's stack, which is the same no-fall-through rule read from its
        // other end — a text surface holding the keyboard is never a reason to reach past it.
        #expect(editor.undoManager == nil)
        #expect(UndoCommandRouting.routedManager(stack: manager, firstResponder: editor) == nil)
        #expect(UndoCommandRouting.routedManager(stack: nil, firstResponder: editor) == nil)
    }
}

// MARK: - Titles and enablement

/// **What a rendered row says and whether it is live** — the two halves the rows route along with the
/// action, because a row that advertised a step it would not cross, or swallowed ⌘Z while dimmed off
/// the wrong stack, is exactly how no-fall-through fails from the user's side (13-native-undo.md
/// ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08).
@MainActor
@Suite("Undo commands ▸ titles and enablement")
struct UndoCommandTitleTests {

    @Test("No manager is the bare verb, disabled — a row with nothing in front of it")
    func noManagerIsTheBareVerb() {
        #expect(UndoCommandRouting.undoTitle(of: nil) == "Undo")
        #expect(UndoCommandRouting.redoTitle(of: nil) == "Redo")
        #expect(UndoCommandRouting.canUndo(nil) == false)
        #expect(UndoCommandRouting.canRedo(nil) == false)
    }

    @Test("A named step composes the platform's title, and lights the row")
    func aNamedStepComposesTheTitle() {
        let (manager, provider) = makeStack()

        #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo", "an empty stack keeps the bare verb")
        #expect(UndoCommandRouting.canUndo(manager) == false)

        provider.register(step("Move 3 Cards"))

        #expect(UndoCommandRouting.canUndo(manager))
        #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move 3 Cards")
        #expect(UndoCommandRouting.canRedo(manager) == false, "nothing crossed yet")

        provider.undo()

        #expect(UndoCommandRouting.canUndo(manager) == false)
        #expect(UndoCommandRouting.canRedo(manager))
        #expect(UndoCommandRouting.redoTitle(of: manager) == "Redo Move 3 Cards")
    }

    /// The trim is why these helpers exist at all rather than the rows reading `undoMenuItemTitle`
    /// directly: `BoardUndoManager` trims its own nameless composition, and a plain `NSUndoManager` —
    /// a text view's, a field editor's — does not, leaving "Undo " with a trailing space on the row.
    @Test("A nameless plain NSUndoManager still reads as the bare verb")
    func aNamelessTextManagerTrimsToTheBareVerb() {
        let text = UndoManager()

        #expect(UndoCommandRouting.undoTitle(of: text) == "Undo")
        #expect(UndoCommandRouting.redoTitle(of: text) == "Redo")
    }

    /// The read-only lock reaches the rows through the very object they read — one answer, wherever
    /// it is asked from (13 ▸ Rules ▸ locks; `BoardUndoManager.canUndo`).
    @Test("The read-only lock dims the row and leaves its name standing")
    func theLockDimsTheRow() {
        final class Lock { var isOn = false }
        let lock = Lock()
        let (manager, provider) = makeStack(isReadOnly: { lock.isOn })
        provider.register(step("Move Card"))

        #expect(UndoCommandRouting.canUndo(manager))

        lock.isOn = true

        #expect(UndoCommandRouting.canUndo(manager) == false)
        #expect(UndoCommandRouting.undoTitle(of: manager) == "Undo Move Card", "the stack survives the lock")

        lock.isOn = false
        #expect(UndoCommandRouting.canUndo(manager), "and resumes when it clears")
    }
}

// MARK: - The ticker's silenced window

/// **The reads that post, kept out of the ticker they would otherwise re-enter** — the livelock
/// regression (diagnosed 2026-08-08 by sampling a frozen instance). A plain `NSUndoManager` fires
/// `NSUndoManagerCheckpoint` synchronously from `canRedo` — and from the composed redo title through
/// it (`canUndo` posts nothing, an asymmetry pinned below); the ticker subscribes to exactly that
/// notification; and a bump landing mid-`body` re-invalidates the row whose read posted it, forever
/// (`UndoCommandTicker.silencingReadEchoes`). `BoardUndoManager` never echoes — its overrides answer
/// from the provider — which is why every fixture here is the plain text-manager shape the rows
/// route to while an editor holds the keyboard.
@MainActor
@Suite("Undo commands ▸ ticker")
struct UndoCommandTickerTests {

    @Test("A checkpoint reaches the ticker — the subscription the silence guards against is live")
    func aGenuineCheckpointBumps() {
        let before = UndoCommandTicker.shared.revision

        // `canUndo` answers without posting — the asymmetry that made the redo side the loop's
        // fuel in the wild, pinned so a platform change is noticed here first.
        _ = UndoManager().canUndo
        #expect(UndoCommandTicker.shared.revision == before)

        // `canRedo` posts the checkpoint synchronously before answering — the very side effect the
        // silenced window exists for, here arriving unsilenced.
        _ = UndoManager().canRedo
        #expect(UndoCommandTicker.shared.revision > before)
    }

    @Test("A row's own derivation leaves the ticker still — the livelock regression")
    func silencedReadsDoNotBump() {
        let text = UndoManager()
        let before = UndoCommandTicker.shared.revision

        let answers = UndoCommandTicker.shared.silencingReadEchoes {
            (undo: UndoCommandRouting.undoTitle(of: text),
             redo: UndoCommandRouting.redoTitle(of: text),
             canUndo: UndoCommandRouting.canUndo(text),
             canRedo: UndoCommandRouting.canRedo(text))
        }

        #expect(UndoCommandTicker.shared.revision == before, "the reads' own echoes never land")
        #expect(answers == (undo: "Undo", redo: "Redo", canUndo: false, canRedo: false),
                "and the answers themselves are untouched by the silence")
    }

    @Test("The silence is scoped to the read, and nests")
    func silenceIsScopedAndNests() {
        let before = UndoCommandTicker.shared.revision

        UndoCommandTicker.shared.silencingReadEchoes {
            UndoCommandTicker.shared.silencingReadEchoes { _ = UndoManager().canRedo }
            // Still inside the outer window after the inner one closes.
            _ = UndoManager().canRedo
        }
        #expect(UndoCommandTicker.shared.revision == before, "nothing inside the window lands")

        _ = UndoManager().canRedo
        #expect(UndoCommandTicker.shared.revision > before, "and the window closes behind the read")
    }
}
