import Foundation
import Testing
@testable import Kanban

/// **The two-level undo model** — a card window's own stack, and the one coarse step its close
/// registers on the board's (13-native-undo.md ▸ Rules, re-ruled 2026-07-31; 05-card-window.md ▸ The
/// comments column; 01-storage-format.md § Enhanced schema ▸ the deferred purge).
///
/// The claims here are all about *which stack* and *when*, which is exactly the class of thing that
/// looks right in a running window and is wrong: a step on the board's stack while a window is still
/// open, a purge that ran while an undo still needed the folder it removed, a session that registered
/// a step for a card that had left the board. So every test drives the production wiring
/// (`CardWindowHost.configureUndo` / `.configureComments`, both `static` for this reason) over a real
/// store and asserts against the two stacks and the bytes on disk.
///
/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider
/// grammar is `HistoryProviderTests`'.

// MARK: - A substrate that keeps no steps

/// **A provider that retires every step on arrival** — the minimal fake `HistoryProviding.backedContent`'s
/// own doc names ("a substrate that keeps no steps... and a test fake's"). `register(_:)` runs the
/// step's retirement immediately and keeps nothing, which is what makes "purge rides the close flush"
/// true over such a substrate with no substrate check anywhere in the call path.
@MainActor
private final class NoBackingHistoryProvider: HistoryProviding {
    var canUndo = false
    var canRedo = false
    var undoActionName: String?
    var redoActionName: String?

    func register(_ step: HistoryStep) { step.retirement?.run() }
    func undo() {}
    func redo() {}
    func clear() {}
}

// MARK: - The window under test

@MainActor
private struct Window {
    let store: BoardStore
    /// The **board's** stack — what must stay empty while the window is open.
    let board: NativeHistoryProvider
    let session: CardWindowSession

    var window: CardWindowUndo { session.undo }
    var comments: CardComments { session.comments }
    var body: CardBodyEditSession { session.body }
}

private let cardID = ItemID(rawValue: Ident.card1)
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"

/// **The coarse close step's row, spelled once** — "Changes to '⟨card⟩'" (13-native-undo.md ▸ Rules,
/// ruled 2026-07-31), over the fixture card's own title. Pinned as a value here and asserted verbatim
/// in `theCoarseStepNamesItsCard` below, so a suite that reads the phrase eleven times still only
/// *decides* it once.
private let coarseStep = "Changes to 'Fix login'"

/// A card window over a board with one card, wired exactly as `CardWindowHost` wires one.
@MainActor
private func makeWindow(_ fixture: WriterFixture) throws -> Window {
    let store = try BoardStore(rootURL: fixture.root)
    let board = NativeHistoryProvider()
    store.history = board
    return try openWindow(fixture, store: store, board: board)
}

/// **The same card opened again, over the board that is already holding the last session's step** —
/// the reopen 13's sweep gate is about (ruled 2026-07-31): a second window is a second session with
/// its own empty stack, and the *board's* stack is the one that survived the close.
@MainActor
private func reopen(_ window: Window, _ fixture: WriterFixture) throws -> Window {
    try openWindow(fixture, store: window.store, board: window.board)
}

@MainActor
private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: NativeHistoryProvider) throws -> Window {
    let session = CardWindowSession()
    CardWindowHost.configureUndo(session, store: store, cardID: cardID)
    CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
    session.comments.isEditable = true
    session.comments.cardFolder = fixture.url(cardPath)
    // The one seam the host wires beside the undo ones and this suite is not testing — the buffer's
    // write target (`configureSession`), spelled the same way.
    session.body.save = { [weak store] text in
        store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
    }
    // The title field's own write target — `configureSession`'s seam, spelled the same way as the
    // body's just above it.
    session.title.save = { [weak store] title in
        store?.commitCardTitle(inCard: cardID, title: title) ?? .vanished
    }
    session.comments.open()
    session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
    // `makeCommentBoard`'s fixture card is always titled "Fix login" — `coarseStep`'s own constant.
    session.title.adopt(diskTitle: "Fix login")
    return Window(store: store, board: board, session: session)
}

@MainActor
private func editBody(_ window: Window, to text: String) {
    window.body.edited(text)
    window.body.endEditSession()
}

@MainActor
private func editTitle(_ window: Window, to text: String) {
    window.session.title.edited(text)
    window.session.title.endEditSession()
}

@MainActor
private func postComment(_ window: Window, body: String) -> ItemID? {
    window.comments.composer.edited(body)
    _ = window.comments.composer.flush()
    return window.comments.composer.postNow()
}

private func body(_ fixture: WriterFixture, _ path: String) throws -> String {
    try FrontmatterDocument.parse(fixture.indexText(path)).body
}

/// A `StyleRecents` of this test's own — `StyleRecentsTests`' own pattern (`StyleModelTests.swift`),
/// restated here rather than shared across files: the list is a real user preference, and a suite
/// that wrote into `UserDefaults.standard` would be editing the developer's own quick-style row.
@MainActor
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
    let name = "CardSessionUndoTests-\(UUID().uuidString)"
    guard let defaults = UserDefaults(suiteName: name) else {
        Issue.record("could not create a defaults suite")
        return (StyleRecents(defaults: .standard), {})
    }
    return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
}

// MARK: - Routing

@MainActor
@Suite("Card session undo ▸ routing")
struct CardSessionRoutingTests {

    @Test("A body edit and a comment post register on the window's stack, never on the board's")
    func windowGesturesStayOffTheBoardStack() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        editBody(window, to: "Edited in the window.\n")
        #expect(postComment(window, body: "A remark.\n") != nil)

        #expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
        #expect(window.window.stack.canUndo)
        #expect(window.window.stack.undoActionName == "Comment")
    }

    @Test("⌘Z in the window walks its own gestures, newest first")
    func theWindowStackWalksItsOwnGestures() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let original = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        let posted = try #require(postComment(window, body: "A remark.\n"))

        // Newest first: the post, then the body edit.
        window.window.stack.undo()
        #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
        #expect(fixture.exists("\(card)/comments/.draft"))
        #expect(try body(fixture, cardPath) == "Edited in the window.\n")

        window.window.stack.undo()
        #expect(try body(fixture, cardPath) == original)
        #expect(!window.window.stack.canUndo)
        #expect(!window.board.canUndo, "and the board's stack was never involved")
    }

    @Test("A comment's inline edit session is one window step, at its commit point")
    func inlineEditRegistersOneWindowStep() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText(body: "original\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()

        window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
        window.comments.editing?.edited("first pass\n")
        window.comments.editing?.flush()
        window.comments.editing?.edited("second pass\n")
        window.comments.editing?.flush()
        #expect(!window.window.stack.canUndo, "a save tick is not a step")

        window.comments.commitEdit()
        #expect(window.window.stack.undoActionName == "Edit Comment")
        #expect(!window.board.canUndo)

        window.window.stack.undo()
        #expect(try body(fixture, path) == "original\n", "back to the bytes the session opened on")
    }

    @Test("An inline session's Cancel registers nothing — its net effect is nothing")
    func cancelRegistersNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()

        window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
        window.comments.editing?.edited("rewritten\n")
        window.comments.editing?.flush()
        window.comments.cancelEdit()

        #expect(!window.window.stack.canUndo)
        #expect(!window.board.canUndo)
    }

    @Test("The window's manager answers for the window's stack, and never falls through")
    func theWindowManagerNeverFallsThrough() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        // A board step exists — a rename made from the board window, say.
        window.store.renameBoard("Renamed")
        #expect(window.board.canUndo)

        // The window's own stack is empty, so its manager says no: "exhausting the window stack
        // beeps; it never reaches board history" (06-history-undo.md ▸ Undo routing).
        #expect(!window.window.manager.canUndo)
        #expect(!window.window.manager.canRedo)
        #expect(window.window.manager.undoMenuItemTitle == "Undo")

        editBody(window, to: "Edited.\n")
        #expect(window.window.manager.canUndo)
        #expect(window.window.manager.undoMenuItemTitle == "Undo Edit Card")
    }

    @Test("The read-only lock disables the window's pair, and the stack survives it")
    func theLockDisablesTheWindowsPair() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        editBody(window, to: "Edited.\n")
        #expect(window.window.manager.canUndo)

        window.store.enterVanishedRootLock()
        #expect(!window.window.manager.canUndo, "13 ▸ Rules ▸ locks, one level down")
        #expect(window.window.stack.canUndo, "the stack itself is untouched")
    }
}

// MARK: - The coarse close step

@MainActor
@Suite("Card session undo ▸ the coarse close step")
struct CardSessionCloseTests {

    @Test("A session of three gestures closes as exactly one board step, and undo restores all three")
    func oneStepForTheWholeSession() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let deleted = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(deleted, commentText(body: "kept somewhere\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()
        let original = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        let posted = try #require(postComment(window, body: "A remark.\n"))
        window.comments.delete(ItemID(rawValue: CommentIdent.one))

        await window.session.endSession()

        // One step, named for the session rather than for any gesture inside it.
        #expect(window.board.canUndo)
        #expect(window.board.undoActionName == coarseStep)

        window.board.undo()
        #expect(!window.board.canUndo, "exactly one")
        #expect(try body(fixture, cardPath) == original)
        #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"), "the post is unposted")
        #expect(fixture.exists("\(card)/comments/.draft"))
        #expect(fixture.exists(deleted), "the deleted comment is back — restored from comments/.trash/")
        #expect(try body(fixture, deleted) == "kept somewhere\n")

        window.board.redo()
        #expect(try body(fixture, cardPath) == "Edited in the window.\n")
        #expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
        #expect(!fixture.exists(deleted))
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
    }

    @Test("The coarse step is named for its card — 'Changes to ⟨title⟩', never the fine 'Edit Card'")
    func theCoarseStepNamesItsCard() async throws {
        // 13-native-undo.md ▸ Rules, ruled 2026-07-31: "one coarse step named 'Changes to ⟨card⟩' —
        // the board row reads 'Undo Changes to ⟨Fix login⟩': plural and scope-flavoured, distinct from
        // every fine verb … the fine body-edit wording never leaks onto the board menu."
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        editBody(window, to: "Edited in the window.\n")
        #expect(window.window.stack.undoActionName == "Edit Card",
                "the fine body edit keeps its verb — this is the collision the ruling resolves")

        await window.session.endSession()
        #expect(window.board.undoActionName == "Changes to 'Fix login'")
        #expect(window.board.undoActionName == coarseStep)
        // The "Undo " prefix is the platform's, composed over the bare phrase (`BoardUndoManager`).
        #expect(BoardUndoManager(history: window.board).undoMenuItemTitle == "Undo Changes to 'Fix login'")

        // An untitled card renders the placeholder its own window title bar renders — "Untitled" is a
        // rendering, never a value (03-board-ui.md § Card face).
        #expect(HistoryPhrase.cardSession(nil) == "Changes to 'Untitled'")
    }

    @Test("A session with no net change registers nothing")
    func noNetChangeRegistersNothing() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let original = try body(fixture, cardPath)

        // Two Edit sessions whose net effect on the file is nothing at all.
        editBody(window, to: "A detour.\n")
        editBody(window, to: original)
        #expect(window.window.stack.canUndo, "two real gestures, on the window's stack")

        await window.session.endSession()
        #expect(!window.board.canUndo, "a session with no net change registers nothing")
        #expect(try body(fixture, cardPath) == original)
    }

    @Test("A gesture undone inside the window is not part of the session's net effect")
    func anUndoneGestureLeavesTheFold() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let window = try makeWindow(fixture)
        window.comments.reload()

        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        window.window.stack.undo()
        #expect(fixture.exists(path), "the window's own ⌘Z put it back")

        await window.session.endSession()
        #expect(!window.board.canUndo, "nothing is left of the session to coarsen")
    }

    @Test("A comment edited and then deleted in one session folds to where it actually is")
    func editThenDeleteFoldsToTheTrash() async throws {
        // The fold's move case: the edit's expectations name the comment's live path, and the delete
        // then empties that path. A fold that kept both would be stale at the moment it registered.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText(body: "original\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()

        window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
        window.comments.editing?.edited("rewritten\n")
        window.comments.commitEdit()
        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        await window.session.endSession()
        #expect(window.board.undoActionName == coarseStep)

        window.board.undo()
        #expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival")
        #expect(fixture.exists(path))
        #expect(try body(fixture, path) == "original\n", "restored to the bytes the session opened on")
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
    }

    @Test("A comment posted and then deleted in one session unwinds to the draft it came from")
    func postThenDeleteUnwindsToTheDraft() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        let posted = try #require(postComment(window, body: "A remark.\n"))
        window.comments.reload()
        window.comments.delete(posted)
        await window.session.endSession()

        window.board.undo()
        #expect(window.store.banners.signposts.isEmpty)
        #expect(fixture.exists("\(card)/comments/.draft"))
        #expect(try body(fixture, "\(card)/comments/.draft") == "A remark.\n")
        #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }

    @Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing")
    func aTrashedCardStillRegistersItsSession() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let original = try body(fixture, cardPath)
        editBody(window, to: "Edited.\n")

        // The card window's own delete (its toolbar item and File ▸ Delete Card since 2026-08-09,
        // the sidebar's Actions ▸ Delete button before it) — a board gesture, on the board's stack,
        // which dismisses this window (05-card-window.md ▸ Deletion & lifecycle).
        window.store.deleteCard(cardID)
        window.store.handleWatcherEvent(.treeChanged(.appMediated))
        await window.store.awaitQuiescence()
        #expect(window.board.undoActionName == "Delete Card")

        await window.session.endSession()
        // "A tracked relocation — a lane move mid-session or after close, **a trash move** — never
        // stales the step" (13 ▸ Rules, ruled 2026-07-31): the session resolves through the walk that
        // spans both containers, so the step registers over the delete rather than being dropped.
        #expect(window.board.undoActionName == coarseStep)

        window.board.undo()
        #expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed")
        #expect(try body(fixture, ".trash/\(Ident.card1)") == original,
                "walked back where the card is now, its subtree intact")
        #expect(window.board.undoActionName == "Delete Card", "and the delete is the next step down")
    }

    @Test("A card that resolves nowhere registers nothing")
    func aCardThatResolvesNowhereRegistersNoSessionStep() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        editBody(window, to: "Edited.\n")

        // Purged, or moved out of the board — neither container holds the card, so there is no folder
        // for the step's components to be about and "the honest skip" is not to register one at all.
        try FileManager.default.removeItem(at: fixture.url(cardPath))
        window.store.handleWatcherEvent(.treeChanged(.foreign))
        await window.store.awaitQuiescence()

        await window.session.endSession()
        #expect(!window.board.canUndo, "no session step for a card that is nowhere")
    }

    @Test("A style change made in the window's sidebar joins the session, not the board")
    func sidebarStylingIsASessionGesture() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        // The sidebar anchor's write, exactly as `CardStyleSection` makes it.
        window.store.applyStyle(
            to: CardStyleSection.target(forCard: cardID),
            background: .set("blue"),
            on: window.window
        )
        #expect(window.window.stack.undoActionName == "Restyle Card")
        #expect(!window.board.canUndo)

        await window.session.endSession()
        #expect(window.board.undoActionName == coarseStep)

        window.board.undo()
        let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
        #expect(document.background.isMissing, "the session's net effect, walked back")
    }

    /// The sidebar's compact `SymbolPicker` (2026-08-08, replacing the embedded curated grid) writes
    /// through the same funnel the test above exercises for the background combo —
    /// `StyleCommand.apply`, exactly as `CardStyleSection.applySymbol`/`applyIconColor` call it — so
    /// this is that same claim, `icon`/`iconColor` instead of `background`. A control swap that left
    /// this funnel unrouted would still look correct on screen (the well updates) right up until the
    /// window closes and the change never reaches board history.
    @Test("A symbol and tint change made in the window's sidebar joins the session, not the board")
    func sidebarSymbolPickingIsASessionGesture() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let (recents, teardown) = makeRecents()
        defer { teardown() }

        StyleCommand.apply(
            icon: .set("flame"),
            iconColor: .set("carnation"),
            to: CardStyleSection.target(forCard: cardID),
            in: window.store,
            recents: recents,
            on: window.window
        )
        #expect(window.window.stack.undoActionName == "Restyle Card")
        #expect(!window.board.canUndo)
        // A symbol pick is not a background pick — "only `.set` reaches `StyleRecents.record`" is
        // `StyleCommand.apply`'s rule for `background` alone (`StyleEditor.swift`) — so the picker's
        // glyph and tint wells never touch the quick-style row.
        #expect(recents.backgrounds.isEmpty)

        await window.session.endSession()
        #expect(window.board.undoActionName == coarseStep)

        window.board.undo()
        let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
        #expect(document.icon.isMissing, "the session's net effect, walked back")
        #expect(document.iconColor.isMissing, "and its tint with it")
    }
}

// MARK: - Transactional staleness

@MainActor
@Suite("Card session undo ▸ transactional staleness")
struct CardSessionStalenessTests {

    @Test("One stale component skips the whole step — nothing is partially reverted")
    func anyStaleComponentSkipsTheWholeStep() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let deleted = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(deleted, commentText())
        let window = try makeWindow(fixture)
        window.comments.reload()

        editBody(window, to: "Edited in the window.\n")
        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        await window.session.endSession()
        #expect(window.board.canUndo)

        // Somebody else rewrites the card's body after the window closed — one component of the
        // session's step, and only one.
        _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")

        window.board.undo()

        #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
        #expect(!fixture.exists(deleted), "and the comment half did not half-happen either")
        #expect(!window.board.canUndo, "the stale step was popped")
        #expect(window.store.banners.signposts.map(\.message)
                == ["Undo skipped — 'Fix login' changed outside Lanework"])
    }

    @Test("A foreign change to a field the session never wrote leaves the step alone")
    func anUnrelatedForeignChangeDoesNotSkip() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let original = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        await window.session.endSession()

        // A foreign styling of the same card: the session wrote the body and nothing else, so the
        // step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
        try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
            $0.setStyleValue("blue", for: FrontmatterKeys.background)
        }

        window.board.undo()
        #expect(try body(fixture, cardPath) == original)
        #expect(window.store.banners.signposts.isEmpty)
    }
}

// MARK: - Identity anchoring

/// **Session steps anchor by card identity, never by path** (13-native-undo.md ▸ Rules, ruled
/// 2026-07-31) — the defect these tests exist for, stated as the gesture that produced it: a card
/// window's steps carried lane-bearing folder paths, so one board-side lane move staled *every*
/// component of the session step at once and the whole session skipped, though nothing about the
/// card's content had changed.
///
/// The claim is not "moves are ignored". It is that a **relocation** and a **content change** are
/// different questions and only the second is what validation exists to catch — so the collision half
/// is pinned here beside the round trip, at the card's *new* home.
@MainActor
@Suite("Card session undo ▸ identity anchoring")
struct CardSessionAnchorTests {

    /// The one-lane comment board with somewhere to move the card to.
    @MainActor
    private func twoLaneBoard(_ fixture: WriterFixture) throws -> String {
        let card = try makeCommentBoard(fixture)
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        return card
    }

    /// The card's folder after a move into the second lane.
    private let movedPath = "\(Ident.lane2)/\(Ident.card1)"

    @Test("A board-side lane move never stales the session — all three gestures still apply")
    func aLaneMoveDoesNotStaleTheSessionStep() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try twoLaneBoard(fixture)
        let deleted = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(deleted, commentText(body: "kept somewhere\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()
        let original = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        let posted = try #require(postComment(window, body: "A remark.\n"))
        window.comments.delete(ItemID(rawValue: CommentIdent.one))

        // The board moves the card to another lane while its window is open — a drag on the board,
        // which is a board gesture on the board's own stack. Every folder the session wrote to has
        // just changed path.
        window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
        window.store.handleWatcherEvent(.treeChanged(.appMediated))
        await window.store.awaitQuiescence()
        #expect(fixture.exists(movedPath))
        #expect(window.board.undoActionName == "Move Card")

        await window.session.endSession()
        #expect(window.board.undoActionName == coarseStep, "the session registered over the move")

        window.board.undo()
        #expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision")
        #expect(try body(fixture, movedPath) == original, "the body is back — at the card's new home")
        #expect(!fixture.exists("\(movedPath)/comments/\(posted.rawValue)"), "the post is unposted")
        #expect(fixture.exists("\(movedPath)/comments/.draft"))
        #expect(fixture.exists("\(movedPath)/comments/\(CommentIdent.one)"),
                "and the deleted comment is back, out of the trash that travelled with the card")
        #expect(window.board.undoActionName == "Move Card", "the move is the next step down")
    }

    @Test("A genuine field collision at the card's new home still skips the whole step")
    func aFieldCollisionAfterAMoveStillSkipsWhole() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try twoLaneBoard(fixture)
        let deleted = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(deleted, commentText(body: "kept somewhere\n"))
        let window = try makeWindow(fixture)
        window.comments.reload()

        editBody(window, to: "Edited in the window.\n")
        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
        window.store.handleWatcherEvent(.treeChanged(.appMediated))
        await window.store.awaitQuiescence()
        await window.session.endSession()

        // Somebody else rewrites the body — at the folder the card now sits in, which is exactly where
        // the anchor resolves. One component, and the whole step goes (13: "any stale component skips
        // the whole step — never a partial session revert").
        _ = try BoardWriter.writeBody(inItemFolder: fixture.url(movedPath), body: "Somebody else.\n")

        window.board.undo()
        #expect(window.store.banners.signposts.map(\.message)
                == ["Undo skipped — 'Fix login' changed outside Lanework"])
        // The skip pops the step and **⌘Z falls through to the next one down** (13 ▸ Rules), which
        // here is the board's own move — so the card, the foreign body and the still-deleted comment
        // all travel back to the lane the move took them from.
        #expect(fixture.exists(cardPath))
        #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
        #expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"),
                "and the comment half did not half-happen either")
        #expect(fixture.exists("\(cardPath)/comments/.trash/\(CommentIdent.one)"),
                "the skip is not a clean exit — the backing it held survives to the session's end")
        #expect(!window.board.canUndo, "both steps are gone — one skipped, one applied")
    }

    @Test("The window's own ⌘Z survives a lane move too — the fine steps carry the same anchors")
    func theWindowStackSurvivesALaneMove() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try twoLaneBoard(fixture)
        let window = try makeWindow(fixture)
        let original = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
        window.store.handleWatcherEvent(.treeChanged(.appMediated))
        await window.store.awaitQuiescence()

        // "The coarse step — **and the window's fine steps it folds**" (13 ▸ Rules): the fold is only
        // as sound as its components, so the window's own stack is anchored the same way.
        window.window.stack.undo()
        #expect(try body(fixture, movedPath) == original)
        #expect(window.store.banners.signposts.isEmpty)
    }
}

// MARK: - The deferred purge

@MainActor
@Suite("Card session undo ▸ the deferred purge")
struct CardSessionPurgeTests {

    /// A window whose session deleted one comment and closed — the state every test below starts in.
    @MainActor
    private func closedWithADeletedComment(_ fixture: WriterFixture) async throws -> (Window, String) {
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let window = try makeWindow(fixture)
        window.comments.reload()
        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        await window.session.endSession()
        return (window, card)
    }

    /// The same close, collided with — **the coarse step popped as stale, having applied nothing**,
    /// which is the state the skip-purge decoupling is about (13 ▸ Interaction with the trash, ruled
    /// 2026-07-31).
    ///
    /// **The body edit is what makes this a skip at all.** A session of nothing but the delete names
    /// only the comment, and a foreign write to the *card* would leave it perfectly current — the undo
    /// would then apply, restore the comment, and empty the trash by moving its one entry out, which
    /// is the same disk state for entirely the wrong reason. With the body in the step, a foreign body
    /// rewrite stales it **without touching what the purge would remove**, which is the only way to
    /// ask what the skip did to the backing.
    @MainActor
    private func skippedOnAForeignEdit(_ fixture: WriterFixture) async throws -> (Window, String) {
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        let window = try makeWindow(fixture)
        window.comments.reload()
        editBody(window, to: "Edited in the window.\n")
        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        await window.session.endSession()

        _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
        window.board.undo()
        #expect(window.store.banners.signposts.map(\.message)
                == ["Undo skipped — 'Fix login' changed outside Lanework"])
        #expect(!window.board.canUndo, "the stale step was popped")
        return (window, card)
    }

    @Test("comments/.trash survives the close while the coarse step lives")
    func theTrashOutlivesTheClose() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await closedWithADeletedComment(fixture)

        #expect(window.board.canUndo)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the step's undo restores from here — the purge waits for it")
    }

    // MARK: The reopen — 13's sweep gate

    @Test("Reopening the card window never destroys the last session's undo backing")
    func theReopenSweepSparesTheCoarseStepsBacking() async throws {
        // The ship-blocker this gate exists for (13 ▸ Interaction with the trash, ruled 2026-07-31):
        // the open-time residue sweep used to empty `comments/.trash/` unconditionally, so opening the
        // card again threw away the folder the board's own coarse step was about to restore from.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await closedWithADeletedComment(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)

        // The reopen. `CardComments.open()` runs the sweep before it reads the thread, exactly as the
        // host wires it — so this is the production sequence, not a re-typed copy of it.
        _ = try reopen(window, fixture)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the sweep asked the board's stack first: this is backing, not residue")

        window.board.undo()
        #expect(window.store.banners.signposts.isEmpty, "nothing was stale — the backing was still there")
        #expect(fixture.exists(path), "the coarse step's undo restored the comment the reopen spared")
    }

    @Test("Residue no live step owns still sweeps at the open, beside backing that does")
    func unownedResidueStillSweeps() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await closedWithADeletedComment(fixture)
        // A crashed session's leftovers, landing beside the live step's backing: nothing on the stack
        // names this one, so it is residue by the ruling's own definition.
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())

        _ = try reopen(window, fixture)

        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "unowned content sweeps as before")
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "and owned content is left alone")
        #expect(window.store.heals.memo(for: .commentTrashResidue) == nil,
                "the signature is the entries actually purged, and it cleared on success")
    }

    @Test("Once the hold ends, the purge the sweep deferred to runs")
    func theRetirementRunsThePurgeTheSweepDeferredTo() async throws {
        // "One condition, two consumers" (13): the sweep spared this content because a step owned it,
        // and the moment that ownership ends is the moment the deferred purge was always waiting for.
        // The arc — spared, then released, then purged — is what this proves; *which* release ends it
        // is the two tests below and the two below the divider.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await closedWithADeletedComment(fixture)

        _ = try reopen(window, fixture)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "spared by the reopen's sweep")

        window.board.clear()
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty,
                "and the purge the sweep had deferred to ran with it")
    }

    @Test("The board session's end purges what the step was holding")
    func theBoardSessionsEndPurges() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await closedWithADeletedComment(fixture)

        // `AppModel`'s teardown does exactly this.
        window.board.clear()
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }

    // MARK: The skip — the one exit that is not clean

    @Test("A stale skip purges nothing — the backing it was holding survives")
    func aSkippedStepsBackingSurvives() async throws {
        // The decoupling ruled 2026-07-31 (13 ▸ Interaction with the trash), and its reason: "the skip
        // banner says nothing was applied, and an irreversible purge riding that gesture would be
        // surprise loss — the skip is exactly when the user may want to inspect what the collision
        // left". This is the pin that used to say the opposite.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (_, card) = try await skippedOnAForeignEdit(fixture)

        #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)),
                "the step skipped rather than applied — nothing was restored")
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "and nothing was destroyed either: the bytes are still there to look at")
    }

    @Test("Reopening the card after a skip spares the survivor — the same hold, read twice")
    func theSweepSparesASkipSurvivor() async throws {
        // The half that makes "survives to board-session end" true rather than merely intended: the
        // step is off both stacks, so a sweep that read only those would call this residue and purge
        // it at the very next open — taking the ruling back one window later.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await skippedOnAForeignEdit(fixture)

        _ = try reopen(window, fixture)

        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the sweep asked the same hold the purge is still waiting on")
    }

    @Test("Unowned residue beside a skip's survivor still sweeps")
    func residueSweepsBesideASkipSurvivor() async throws {
        // The gate did not become "spare everything in there": a stranded step's claim is exactly as
        // narrow as a live one's, one anchor at a time.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await skippedOnAForeignEdit(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())

        _ = try reopen(window, fixture)

        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the survivor is owned")
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "the crash leftover is not")
    }

    @Test("The board session's end purges a skip's survivor too")
    func theBoardSessionsEndPurgesASkipSurvivor() async throws {
        // "A stale-skipped step's backing instead survives **to board-session end**" — the deferral
        // has a floor, and it is the same one every other hold has (`AppModel`'s teardown).
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let (window, card) = try await skippedOnAForeignEdit(fixture)

        window.board.clear()
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }

    @Test("A session that registers no step purges at the close, as it always did")
    func aSessionWithNoStepPurgesAtOnce() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        // Residue with no session behind it: nothing this close does could need it back.
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let window = try makeWindow(fixture)

        await window.session.endSession()
        #expect(!window.board.canUndo)
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }

    @Test("A substrate that keeps no steps purges at the close — Pro's rule, structurally")
    func aSubstrateThatKeepsNoStepsPurgesAtOnce() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let window = try makeWindow(fixture)
        window.comments.reload()
        // A substrate that keeps no steps drops every registration and retires it on the way past —
        // which is what makes "purge rides the close flush" true structurally, with no substrate
        // check at any call site. `history` is weak, so the fake is held locally for the assertion's
        // duration.
        let noBackingProvider = NoBackingHistoryProvider()
        window.store.history = noBackingProvider

        window.comments.delete(ItemID(rawValue: CommentIdent.one))
        await window.session.endSession()

        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }

    @Test("Undone-and-superseded releases the work the step was holding")
    func supersedingAnUndoneStepRetiresIt() {
        // The one release condition disk cannot show, because by the time it fires the coarse undo
        // has already emptied the trash it was holding: a step undone and then superseded by a new
        // gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash).
        let provider = NativeHistoryProvider()
        let retirement = HistoryStep.Retirement {}
        provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
        #expect(retirement.isOwed)

        provider.undo()
        #expect(retirement.isOwed, "an undone step is still crossable — redo would need its backing")

        provider.register(HistoryStep(name: "Move Card", undo: { _ in .applied }, redo: { _ in .applied }))
        #expect(!retirement.isOwed, "the redo stack cleared, so the step is gone for good")
    }

    @Test("A retirement runs once, whatever a provider does to the step")
    func aRetirementRunsOnce() {
        var runs = 0
        let retirement = HistoryStep.Retirement { runs += 1 }
        let provider = NativeHistoryProvider()
        provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))

        provider.clear()
        provider.clear()
        #expect(runs == 1)
    }

    @Test("A board with no substrate at all owes the purge immediately")
    func noProviderRunsTheRetirementAtOnce() throws {
        // A store with no session behind it — no board the app composes binds no provider any more
        // (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: repo-nested boards bind the native
        // stack too). Nothing could ever report the step's death, so the work is owed at registration
        // or never.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let store = try BoardStore(rootURL: fixture.root)
        let session = CardWindowSession()
        CardWindowHost.configureUndo(session, store: store, cardID: cardID)
        CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
        session.comments.isEditable = true
        session.comments.cardFolder = fixture.url(cardPath)

        var purged = false
        _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
        #expect(purged == false, "no net effect — nothing was registered, and the caller still owes it")

        session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
        session.body.adopt(diskBody: try body(fixture, cardPath))
        session.body.edited("Edited.\n")
        session.body.endEditSession()
        _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
        #expect(purged, "a step nothing keeps is a step nothing can retire later")
    }
}

// MARK: - The fold

@MainActor
@Suite("Card session undo ▸ the fold")
struct CardSessionFoldTests {

    private let folder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)

    @Test("Merging is per target and per field, later entries winning")
    func laterEntriesWin() {
        let other = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
        let fold = CardWindowUndo.fold([
            [.present(folder, .background("blue"), .body("first\n"))],
            [.present(folder, .background("green")), .present(other)],
        ])

        #expect(fold.expectations == [
            .present(folder, .background("green"), .body("first\n")),
            .present(other),
        ])
    }

    @Test("Two folds of the same writes, read from both ends, are equal only when nothing changed")
    func equalityIsTheNoNetChangeTest() {
        let there = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("b\n"))]])
        let back = CardWindowUndo.fold([[.present(folder, .body("b\n"))], [.present(folder, .body("a\n"))]])
        #expect(there != back)

        let round = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("a\n"))]])
        #expect(round == CardWindowUndo.fold([[.present(folder, .body("a\n"))]]))
    }

    @Test("Presence takes the later answer, and an absence clears what was said about the path")
    func presenceTakesTheLaterAnswer() {
        let live = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
        // An edit, then the delete that emptied the path: a fold still expecting the edited bytes
        // there would be stale on arrival.
        let fold = CardWindowUndo.fold([[.present(live, .body("rewritten\n"))], [.absent(live)]])
        #expect(fold.expectations == [.absent(live)])

        // And back again — a restore names the path present, which is the ordinary later-wins.
        let restored = CardWindowUndo.fold([[.absent(live)], [.present(live, .body("original\n"))]])
        #expect(restored.expectations == [.present(live, .body("original\n"))])
    }
}

// MARK: - The title field

/// `registerTitleEdit`'s own routing and fold, over the production wiring `CardWindowHost.configureUndo`
/// installs — `CardSessionRoutingTests`' and `CardSessionCloseTests`' own claims, restated for the
/// header's title field rather than the body (`CardTitleWriteTests.swift` pins the write itself).
@MainActor
@Suite("Card session undo ▸ the title field")
struct CardSessionTitleTests {

    @Test("A title edit registers on the window's own stack, never on the board's")
    func windowGesturesStayOffTheBoardStack() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        editTitle(window, to: "Fix login, take two")

        #expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
        #expect(window.window.stack.canUndo)
        #expect(window.window.stack.undoActionName == "Rename Card")
    }

    @Test("A title edit and a body edit in one session fold into one coarse step")
    func foldsIntoTheCoarseStepAlongsideTheBody() async throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)
        let originalBody = try body(fixture, cardPath)

        editBody(window, to: "Edited in the window.\n")
        editTitle(window, to: "Fix login, take two")

        await window.session.endSession()
        #expect(window.board.undoActionName == coarseStep, "named for the card, not for either fine gesture")

        window.board.undo()
        let model = try BoardLoader.load(boardRoot: fixture.root).model
        let restored = model.lanes.flatMap(\.cards).first { $0.id == cardID }
        #expect(restored?.title == .valid("Fix login"), "the title is back too")
        #expect(try body(fixture, cardPath) == originalBody)

        window.board.redo()
        let after = try BoardLoader.load(boardRoot: fixture.root).model
        #expect(after.lanes.flatMap(\.cards).first { $0.id == cardID }?.title == .valid("Fix login, take two"))
        #expect(try body(fixture, cardPath) == "Edited in the window.\n")
    }

    @Test("A title typed back to its starting value registers nothing")
    func aRoundTripRegistersNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        _ = try makeCommentBoard(fixture)
        let window = try makeWindow(fixture)

        window.session.title.edited("Detour")
        _ = window.session.title.flush()
        editTitle(window, to: "Fix login")

        #expect(!window.window.stack.canUndo, "the session's net effect on the title is nothing")
    }
}
