import Foundation
import SwiftGitX
import Testing
@testable import Kanban

/// **Undo and redo as forward commits** (06-history-undo.md; 14-git-operations.md ▸ The forward-restore
/// model) — the stack that *is* HEAD's first-parent ancestry, the restores that only ever move history
/// forward, heal transparency, the save-or-discard gate, the provider binding, and the card window's
/// read-only trail.
///
/// Every repository here is a **real** one, built through the app's own add-git over bundled libgit2,
/// and every claim about the trail is read back through libgit2 rather than through the code that made
/// it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept).

// MARK: - Fixtures

/// A card's `index.md` — `AutoCommitTests`' helper, file-private there and here.
private func plain(order: String, title: String, body: String = "Body.") -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    ---
    \(body)
    """
}

private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, plain(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First"))
    return fixture
}

/// A board with a repository and a root commit — the state a board is in a moment after add-git.
@MainActor
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) {
    let fixture = try makeBoard()
    let ledger = EchoLedger()
    let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger))
    #expect(await git.addGit())
    return (fixture, git, ledger)
}

/// A synthetic native step that records its own crossing — `HistoryProviderTests`' fixture, file-private
/// there and here. What the binding suite needs it for is the *discard*: a step that would announce
/// itself loudly if the swap ever ran it.
@MainActor
private final class StepLog {

    private(set) var crossings: [String] = []

    func step(_ name: String) -> HistoryStep {
        HistoryStep(
            name: name,
            undo: { [weak self] _ in
                self?.crossings.append("undo \(name)")
                return .applied
            },
            redo: { [weak self] _ in
                self?.crossings.append("redo \(name)")
                return .applied
            }
        )
    }
}

@MainActor
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
    let committer = try #require(git.committer)
    committer.debounceInterval = .milliseconds(20)
    committer.lockRetryDelay = .milliseconds(5)
    committer.holdRecheckInterval = .milliseconds(20)
    return committer
}

/// A provider wired to a real committer the way `AppModel.wireGitUndo` wires one, minus the seams
/// that need windows (the settle gate) and a store (the bracket) — those get their own suites.
@MainActor
private func makeProvider(
    _ fixture: WriterFixture,
    _ git: HistoryStore,
    committer: GitAutoCommitter
) async -> GitHistoryProvider {
    let provider = GitHistoryProvider(boardRoot: fixture.root)
    provider.flushPendingCommit = { [weak committer] in await committer?.flushNow() }
    provider.isHeld = { [weak committer] in committer?.pause != nil }
    provider.suspendCommitting = { [weak committer] in committer?.stop() }
    provider.resumeCommitting = { [weak committer] in committer?.start() }
    committer.reportLanded = { [weak provider] window in provider?.noteLanded(window) }
    await provider.reseed()
    return provider
}

/// Commits the pending window and waits for the stack to have heard about it — production validates
/// a menu a turn later; a test asserts the instant the flush returns.
@MainActor
private func commitAndSettle(_ committer: GitAutoCommitter, _ provider: GitHistoryProvider) async {
    await committer.flushNow()
    await provider.settled()
}

// MARK: Reading the trail back

private struct TrailCommit: Equatable {
    let oid: String
    let subject: String
    let parents: Int
}

/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the provider.
private func trail(at boardRoot: URL, limit: Int = 64) throws -> [TrailCommit] {
    let repository = try Repository.open(at: boardRoot)
    guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }

    var records: [TrailCommit] = []
    var current: Commit? = tip
    while let commit = current, records.count < limit {
        records.append(TrailCommit(
            oid: commit.id.hex,
            subject: commit.summary,
            parents: (try? commit.parents)?.count ?? 0
        ))
        current = (try? commit.parents)?.first
    }
    return records
}

private func subjects(at boardRoot: URL) throws -> [String] {
    try trail(at: boardRoot).map(\.subject)
}

/// Every line of `.git/logs/HEAD`, as (old oid, new oid, message) — the trail's own record of how
/// the reference moved, which is where a reset or a force would be visible if one had happened.
private func reflog(at boardRoot: URL) throws -> [(old: String, new: String, message: String)] {
    let url = boardRoot.appendingPathComponent(".git/logs/HEAD")
    guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] }
    return text.split(separator: "\n").compactMap { line in
        let fields = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
        let head = fields[0].split(separator: " ")
        guard head.count >= 2 else { return nil }
        return (String(head[0]), String(head[1]), fields.count > 1 ? String(fields[1]) : "")
    }
}

/// An `AppModel` over its own scratch registry — `HistoryStoreTests`' helper, file-private there.
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
    let folder = FileManager.default.temporaryDirectory
        .appendingPathComponent("GitUndoTests-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    let model = AppModel(
        registryStorageURL: folder.appendingPathComponent("board-registry.json"),
        clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
    )
    return (model, { try? FileManager.default.removeItem(at: folder) })
}

@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
    let ref = BoardWindowRef(url: url)
    let recordID = model.boardRegistry.recordOpen(of: url)
    let store = try model.storeRegistry.acquire(url)
    model.boardRegistry.setOpenNow(id: recordID)
    model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
    return ref
}

private func title(ofCard folder: String, at boardRoot: URL) -> String? {
    let url = boardRoot.appendingPathComponent(folder).appendingPathComponent("index.md")
    guard let text = try? String(contentsOf: url, encoding: .utf8),
          let document = try? FrontmatterDocument.parse(text) else { return nil }
    return document.rawValue(for: "title")?.trimmingCharacters(in: .whitespacesAndNewlines)
}

// MARK: - The stack is HEAD's ancestry

@MainActor
@Suite("Git undo ▸ the stack is HEAD's first-parent ancestry")
struct GitUndoStackTests {

    @Test("A fresh board seeds from HEAD, with an empty redo")
    func seedingReadsTheAncestry() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        // One commit only — the root — which is deliberately not a step.
        #expect(provider.ancestry.count == 1)
        #expect(provider.canUndo == false, "the board's existence is not an undo step")
        #expect(provider.canRedo == false, "redo starts empty")

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        #expect(provider.ancestry.count == 2)
        #expect(provider.canUndo, "the commit that just landed is the step")
        #expect(provider.undoActionName == "Add card 'Second'")
    }

    @Test("The menu label is the crossed commit's own subject, so labels never nest")
    func theLabelIsTheCrossedSubject() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        #expect(provider.undoActionName == "Add card 'Second'")
        await provider.cross(.undo)

        // HEAD is now "Undo: Add card 'Second'" — but the label is the *next* crossable commit's,
        // which is the root, and the root is not a step. Nothing nested (06 ▸ Commit messages).
        #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
        #expect(provider.undoActionName == nil)
        #expect(provider.redoActionName == "Add card 'Second'")
    }

    @Test("A commit landing from anywhere clears the redo stack")
    func anArrivalClearsRedo() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        await provider.cross(.undo)
        #expect(provider.canRedo)

        // Something else commits — an agent's work arriving through the watcher.
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        #expect(provider.canRedo == false, "classic behavior: an arrival clears redo")
        #expect(provider.undoActionName == "Add card 'Third'", "and becomes the new top step")
    }

    @Test("A self-commit made outside the app is learned by the pre-flight sync — ⌘Z steps back one")
    func thePreflightSyncLearnsForeignCommits() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        // An agent commits its own work: the tree moves and HEAD moves, with no signal to the app.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        let identity = GitIdentity(name: "Agent", email: "agent@agents.lanework.invalid")
        let landed = GitCommitOperation.perform(
            at: fixture.root,
            commits: [PlannedCommit(
                paths: GitCommitOperation.changedPaths(at: fixture.root).map(\.path),
                message: "Add card 'Second'",
                author: identity,
                committer: identity
            )]
        )
        guard case .committed = landed else {
            Issue.record("the agent's own commit did not land")
            return
        }
        #expect(provider.canUndo == false, "the cached stack has not heard about it yet")

        await provider.cross(.undo)

        // Exactly one step back: the agent's commit, not the whole session.
        #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
        #expect(try trail(at: fixture.root).count == 3, "root, the agent's commit, the restore")
    }

    @Test("clear() drops the cache and leaves the repository untouched")
    func clearingIsACacheDrop() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        #expect(provider.canUndo)

        provider.clear()
        #expect(provider.canUndo == false)
        #expect(try trail(at: fixture.root).count == 2, "the trail is where it was")

        await provider.reseed()
        #expect(provider.canUndo, "and reseeding finds it again")
    }
}

// MARK: - Forward commits, never a rewrite

@MainActor
@Suite("Git undo ▸ restores are forward commits")
struct GitUndoForwardTests {

    @Test("Undo restores an earlier state as a new commit — refs only move forward")
    func undoIsAForwardCommit() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        let before = try trail(at: fixture.root)
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")

        await provider.cross(.undo)

        let after = try trail(at: fixture.root)
        // **The trail only grew.** Every commit that existed before the undo is still in HEAD's
        // ancestry, in the same order, with the restore on top — which is precisely what a reset or
        // a force could not produce.
        #expect(after.count == before.count + 1)
        #expect(Array(after.dropFirst()) == before)
        #expect(after.first?.subject == "Undo: Rename card 'First' → 'Renamed'")
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First")
    }

    @Test("Redo restores forward again, as another new commit")
    func redoIsAForwardCommitToo() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        await provider.cross(.undo)
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First")

        await provider.cross(.redo)
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")

        let subjects = try subjects(at: fixture.root)
        #expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'")
        #expect(subjects.count == 4, "root, rename, undo, redo — four commits, none rewritten")
        #expect(provider.canRedo == false, "the redo list is spent")
        #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'", "and ⌘Z crosses it again")
    }

    @Test("The reflog only ever appends — no reset, no force")
    func theReflogOnlyAppends() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        await provider.cross(.undo)
        await provider.cross(.redo)

        // **Trail inspection, as any git client would do it.** libgit2 writes one reflog entry per
        // reference movement, naming the tip before and the tip after. Three facts together are the
        // whole of "never reset, never force":
        let entries = try reflog(at: fixture.root)
        #expect(entries.count == 4, "root, rename, undo, redo — one entry per commit, nothing else")

        // 1. Every movement is a *commit*. A reset writes "reset: …", a checkout "checkout: …", a
        //    force-fetch "update by push" — none of which the app can produce, and none of which is
        //    here.
        #expect(entries.allSatisfy { $0.message.hasPrefix("commit") })

        // 2. The chain is unbroken: each entry's `old` is the previous entry's `new`, so the branch
        //    never jumped sideways or backwards.
        for (index, entry) in entries.enumerated() where index > 0 {
            #expect(entry.old == entries[index - 1].new)
        }

        // 3. Every tip the reference ever held is still in HEAD's first-parent ancestry — old commits
        //    stay reachable, which is precisely what a rewrite would destroy.
        let reachable = Set(try trail(at: fixture.root).map(\.oid))
        #expect(entries.dropFirst().allSatisfy { reachable.contains($0.old) })
        #expect(entries.allSatisfy { reachable.contains($0.new) })

        let repository = try Repository.open(at: fixture.root)
        #expect(try repository.HEAD.name == GitRepository.initialBranchName, "and the branch is the same one")
    }

    @Test("A restore materializes only the diff — an untouched card's file is not rewritten")
    func onlyTheDiffIsMaterialized() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        // A third card, uncommitted and untouched by the restore's diff.
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Bystander"))
        let bystander = fixture.root
            .appendingPathComponent("\(Ident.lane1)/\(Ident.card3)/index.md")
        let before = try Data(contentsOf: bystander)

        let root = try #require(provider.ancestry.last?.oid)
        let plan = try #require(GitRestoreOperation.plan(at: fixture.root, target: root))
        #expect(plan.paths.allSatisfy { $0.contains(Ident.card2) },
                "the plan names only what differs between the two commits")
        #expect(try Data(contentsOf: bystander) == before, "and the bystander's bytes are its own")
    }

    @Test("A card the undo removes takes its emptied folder with it")
    func aRemovedCardLeavesNoEmptyFolder() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        await provider.cross(.undo)

        let folder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card2)")
        #expect(FileManager.default.fileExists(atPath: folder.path) == false)
        #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
                "and the tree is clean — the restore committed everything it wrote")
    }

    @Test("A pending auto-commit flushes before the restore, so both states are commits")
    func theRestoreSettlesTheTreeFirst() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        // A change that has *not* been committed when ⌘Z arrives.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)

        await provider.cross(.undo)

        let subjects = try subjects(at: fixture.root)
        // The pending window became its own commit first — so the state ⌘Z stepped back *from*
        // exists in the trail — and the restore sits on top of it.
        #expect(subjects.first == "Undo: Add card 'Second'")
        #expect(subjects.dropFirst().first == "Add card 'Second'")
    }
}

// MARK: - Restore subjects compose the inverse

/// **"Subjects don't nest either — crossing a restore composes the inverse"** (06-history-undo.md ▸
/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and
/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no
/// repository at all — and the one test that does is the case the rule exists for: the relaunch that
/// turns yesterday's restore commit into an ordinary step.
@MainActor
@Suite("Git undo ▸ restore subjects compose the inverse")
struct GitUndoRestoreSubjectTests {

    @Test("An ordinary subject takes one prefix, per direction")
    func anOrdinarySubjectNestsOnce() {
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing")
            == "Undo: Move card 'Fix login' to Doing")
        #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing")
            == "Redo: Move card 'Fix login' to Doing")
    }

    @Test("Undoing across a restore emits the inverse label, not a second prefix")
    func undoingARestoreInverts() {
        // 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
        // 'Undo: S'" — because an undo restores the crossed commit's *parent*, the state that
        // commit took away.
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'")
            == "Redo: Move card 'X'")
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'")
            == "Undo: Move card 'X'")
    }

    @Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it")
    func redoingARestoreRestates() {
        // A redo restores the target commit *itself*, so the label the new commit carries is that
        // commit's own reading: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out.
        // Emitting "Redo: S" there — the label the ⌘Z that crossed it already used, for the opposite
        // tree — would be the euphemism 06 rules out.
        #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'")
            == "Undo: Move card 'X'")
        #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'")
            == "Redo: Move card 'X'")
    }

    @Test("A legacy double prefix reads as two flips, and comes out carrying one")
    func theLegacyDoublePrefixReadsAsTwoFlips() {
        // **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid
        // the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S
        // back out — "Undo: S" — which is what 06's "the truer label, not a euphemism" asks for;
        // "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the
        // ruling caps at one ("it caps prefixes at one across any number of relaunches").
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'")
            == "Undo: Move card 'X'")
        #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'")
            == "Redo: Move card 'X'")
        // The legacy redo's shape reads the same way: "Redo:" restates whatever follows it.
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'")
            == "Redo: Move card 'X'")
        // And any depth caps at one, which is the property the ruling actually claims — the reading
        // is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the
        // depth of the stack.
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'")
            == "Undo: Move card 'X'")
    }

    @Test("A prefix with nothing after it is somebody's subject, not a label")
    func aBarePrefixIsASubject() {
        // The sniff is on the subject string (06), and a subject that is *only* a prefix has no base
        // to talk about — stripping it would compose "Undo: " with nothing, naming no change at all.
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ")
        // Foreign subjects that merely look like prefixes are unaffected — the match is exact.
        #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build")
            == "Undo: undo: fix the build")
    }

    @Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests")
    func theRelaunchCaseLandsTheInverse() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        await provider.cross(.undo)
        #expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'")

        // **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty
        // redo, so the restore commit above is now an ordinary step the pointer sits on.
        await provider.reseed()
        #expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'",
                "the menu label is still the crossed commit's own subject — labels never nested")

        await provider.cross(.undo)

        let subjects = try subjects(at: fixture.root)
        #expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'",
                "the trail says what the restore did: the rename is back")
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
    }
}

// MARK: - Heal transparency

@MainActor
@Suite("Git undo ▸ heal commits are transparent")
struct GitUndoHealTransparencyTests {

    @Test("The pointer passes over a heal commit and crosses the step beneath it")
    func thePointerPassesOverAHeal() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        // An ordinary step.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        // A heal: the app's own repair, vouched for by a heal-marked receipt.
        let healed = fixture.root.appendingPathComponent(AgentGuide.filename)
        try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: healed, atomically: true, encoding: .utf8)
        ledger.recordWrite(at: healed, data: try Data(contentsOf: healed))
        ledger.markHeal(at: healed)
        committer.noteWriteBracketClosed()
        await commitAndSettle(committer, provider)

        #expect(try trail(at: fixture.root).count == 3, "root, the add, the heal")
        // The heal is on top of the trail, and the label names the *add* beneath it.
        #expect(provider.undoActionName == "Add card 'Second'", "the heal is not a step")

        await provider.cross(.undo)
        #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
    }

    @Test("A restore excludes the paths a heal repaired — the repair survives the undo")
    func aRestoreExcludesHealedPaths() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
        try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
        ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
        ledger.markHeal(at: guide)
        committer.noteWriteBracketClosed()
        await commitAndSettle(committer, provider)

        // ⌘Z crosses the add — whose parent is the root commit, which predates the guide entirely.
        // Without the exclusion the restore would delete the healer's file and re-arm it.
        await provider.cross(.undo)

        #expect(FileManager.default.fileExists(atPath: guide.path),
                "the repair is never reverted (06 ▸ Rules ▸ Heal commits are transparent)")
        let text = try String(contentsOf: guide, encoding: .utf8)
        #expect(text.contains("repaired"))
    }

    @Test("A heal landing mid-run leaves the pointer and the redo list alone")
    func aHealDoesNotTrapAnUndoRun() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        await provider.cross(.undo)
        #expect(provider.canRedo, "a step is on the redo list")

        // The healer runs. An ordinary arrival would clear redo and trap the run on a renewing top.
        let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
        try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
        ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
        ledger.markHeal(at: guide)
        committer.noteWriteBracketClosed()
        await commitAndSettle(committer, provider)

        #expect(provider.canRedo, "transparency dissolves the trap rather than suppressing the healer")
        #expect(provider.redoActionName == "Add card 'Second'")
    }

    @Test("A window's landed commits are reported with their classes")
    func theCommitterReportsClasses() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        var seen: [GitLandedWindow] = []
        committer.reportLanded = { seen.append($0) }

        // One window carrying a heal and a foreign change: two commits, two classes.
        let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
        try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
        ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
        ledger.markHeal(at: guide)
        committer.noteWriteBracketClosed()
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let window = try #require(seen.first)
        #expect(window.commits.count == 2)
        #expect(window.healOIDs.count == 1)
        #expect(window.healPaths.contains(AgentGuide.filename))
        #expect(window.isEntirelyHeal == false)
    }
}

// MARK: - The abnormal-state pause

@MainActor
@Suite("Git undo ▸ the held repository disables the pair")
struct GitUndoHoldTests {

    @Test("A held repository disables Undo and Redo")
    func aHeldRepositoryDisablesThePair() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        #expect(provider.canUndo)

        // A merge left by outside-the-app git — 06 ▸ Rules ▸ Abnormal repo states: the *whole* git
        // surface pauses, "Undo/Redo and the branch controls disable".
        let marker = fixture.root.appendingPathComponent(".git/MERGE_HEAD")
        try "0000000000000000000000000000000000000000\n".write(to: marker, atomically: true, encoding: .utf8)
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        #expect(committer.pause == .merge)

        #expect(provider.canUndo == false)
        #expect(provider.canRedo == false)

        let before = try trail(at: fixture.root)
        await provider.cross(.undo)
        #expect(try trail(at: fixture.root) == before, "and a crossing that started anyway writes nothing")
    }
}

// MARK: - The save-or-discard gate

@MainActor
@Suite("Git undo ▸ the save-or-discard step")
struct SessionSettleGateTests {

    /// A settleable session a test drives by hand.
    @MainActor
    private final class FakeSession {
        var isSettled = false
        var saveSucceeds = true
        var saves = 0
        var discards = 0

        func descriptor(id: String, folder: String) -> SettleableSession {
            SettleableSession(
                id: id,
                cardFolderName: folder,
                needsSettling: { [self] in !isSettled },
                saveAll: { [self] in
                    saves += 1
                    guard saveSucceeds else { return false }
                    isSettled = true
                    return true
                },
                discard: { [self] in
                    discards += 1
                    isSettled = true
                }
            )
        }
    }

    @Test("A diff that touches no session card never asks")
    func anUntouchedSessionIsNeverAsked() async throws {
        let session = FakeSession()
        var asked = 0
        let gate = SessionSettleGate(
            sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
            ask: { asked += 1; return .cancel }
        )

        let outcome = await gate.settle(touching: ["lane-1/card-b/index.md"])

        #expect(outcome == .proceed)
        #expect(asked == 0, "most undos never meet an editor at all")
        #expect(session.saves == 0)
    }

    @Test("A clean session in the diff's path is not asked about either")
    func aSettledSessionIsNotAsked() async throws {
        let session = FakeSession()
        session.isSettled = true
        var asked = 0
        let gate = SessionSettleGate(
            sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
            ask: { asked += 1; return .cancel }
        )

        #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
        #expect(asked == 0)
    }

    @Test("Save All ends every touched session; Cancel keeps everything")
    func theThreeAnswers() async throws {
        let first = FakeSession()
        let second = FakeSession()
        let descriptors = {
            [
                first.descriptor(id: "card-a", folder: "card-a"),
                second.descriptor(id: "card-b", folder: "card-b")
            ]
        }

        var choice = SessionSettleChoice.cancel
        let gate = SessionSettleGate(sessions: descriptors, ask: { choice })

        #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .cancelled)
        #expect(first.saves == 0)
        #expect(first.discards == 0, "Cancel keeps everything")

        choice = .saveAll
        #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
        #expect(first.saves == 1)
        #expect(second.saves == 0, "only the sessions the diff reaches")

        choice = .discard
        #expect(await gate.settle(touching: ["lane-2/card-b/index.md"]) == .proceed)
        #expect(second.discards == 1)
    }

    @Test("A raw buffer that will not validate cancels the whole operation, focused on the offender")
    func aFailingBufferCancelsEverything() async throws {
        let good = FakeSession()
        let bad = FakeSession()
        bad.saveSucceeds = false

        var focused: [String] = []
        let gate = SessionSettleGate(
            sessions: {
                [
                    good.descriptor(id: "card-a", folder: "card-a"),
                    bad.descriptor(id: "card-b", folder: "card-b")
                ]
            },
            ask: { .saveAll },
            focus: { focused.append($0) }
        )

        let outcome = await gate.settle(touching: [
            "lane-1/card-a/index.md",
            "lane-1/card-b/index.md"
        ])

        #expect(outcome == .failed("card-b"))
        #expect(focused == ["card-b"], "focus on the offending window")
        #expect(bad.isSettled == false)
    }

    @Test("Folder matching is component-exact — a prefix does not borrow another card's session")
    func matchingIsComponentExact() throws {
        let session = FakeSession()
        let descriptors = [session.descriptor(id: "card-1", folder: "card-1")]

        #expect(SessionSettleGate.reached(by: ["lane/card-1/index.md"], among: descriptors).count == 1)
        #expect(SessionSettleGate.reached(by: ["lane/card-10/index.md"], among: descriptors).isEmpty)
        #expect(SessionSettleGate.reached(by: ["card-1"], among: descriptors).isEmpty,
                "the folder itself is not a file inside it")
    }
}

// MARK: - The restore meeting a session

@MainActor
@Suite("Git undo ▸ restores meet open sessions")
struct GitUndoSessionTests {

    @Test("A restore whose diff misses every session runs untouched")
    func anUnrelatedSessionIsUndisturbed() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        var asked = 0
        provider.settleSessions = { paths in
            let gate = SessionSettleGate(
                sessions: {
                    [SettleableSession(
                        id: Ident.card1,
                        cardFolderName: Ident.card1,
                        needsSettling: { true },
                        saveAll: { true },
                        discard: {}
                    )]
                },
                ask: { asked += 1; return .cancel }
            )
            return await gate.settle(touching: paths)
        }

        await provider.cross(.undo)

        #expect(asked == 0, "the diff names card 2 only")
        #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
    }

    @Test("Cancel at the step leaves the tree and the stack exactly as they were")
    func cancelStopsTheRestore() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)
        let before = try trail(at: fixture.root)

        provider.settleSessions = { _ in .cancelled }
        await provider.cross(.undo)

        #expect(try trail(at: fixture.root) == before, "nothing was written")
        #expect(provider.canRedo == false, "and the stack did not move")
        #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'")
    }

    @Test("Discard reconciles the session card against the working tree, not against HEAD")
    func discardRevertsUncommittedSaves() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        // **An open Edit session**, whose folder the committer stages around: its ~700 ms save lands
        // on disk and is deliberately never committed. That is the exact state 06 says a restore
        // would otherwise bury — and the reason the step exists at all.
        let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
        let token = UUID()
        committer.beginCardSession(token) { cardFolder }
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed"))
        committer.noteReloadLanded(sawForeignChange: false)
        await commitAndSettle(committer, provider)
        #expect(try subjects(at: fixture.root).first == "Rename card 'First' → 'Renamed'",
                "the session's saves committed nothing while it stood")

        provider.settleSessions = { _ in
            // What the gate's Discard branch does — **exactly as production does it**: the window
            // reverts its buffer and ends its session, and the card's *folder name* (its id, which is
            // what `AppModel`'s gate hands over) goes to the plan to reconcile against the working
            // tree. Passing the `<lane>/<card>` path here instead is what once made this test pass
            // over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`.
            committer.endCardSession(token)
            provider.noteDiscarded(cardFolderName: Ident.card1)
            return .proceed
        }
        await provider.cross(.undo)

        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
                "the discarded save is gone and the restore landed, in one pass")
        #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree")
    }

    /// **The regression.** `AppModel`'s settle gate reports a discarded session by
    /// `CardWindowRef.cardID` — a folder *name* — and the plan matched it as a board-root-relative
    /// path prefix. Every live card is `<lane>/<card>`, so the match never fired on any board: Discard
    /// reverted the in-memory buffer and left the uncommitted on-disk saves exactly where they were,
    /// which then rode into the next commit — the one outcome 06 ▸ Rules ▸ Undo restore vs open Edit
    /// sessions singles out ("a surviving dirty buffer's next debounced save would write pre-undo text
    /// over the restored card — a ⌘Z that visibly doesn't happen").
    ///
    /// The fix is one shared resolver (`GitRestoreOperation.folderPaths(named:at:)`), so this asserts
    /// the property the resolver exists for: a card nested under a lane, named only by its id.
    ///
    /// **What it takes to see the bug.** The restore's own diff already rewrites the session card's
    /// `index.md` — that is why the gate appeared at all — so a test that only checks the body is
    /// green either way. What reconciliation alone can reach is the session's uncommitted state the
    /// diff *cannot* name: a file the session created, present in neither HEAD nor the target, which
    /// the plan can only learn about by walking the folder on disk. That is what this leaves behind
    /// and then looks for.
    @Test("Discard reconciles a card identified by folder name alone, however deep it is nested")
    func discardReconcilesACardIdentifiedByName() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let provider = await makeProvider(fixture, git, committer: committer)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        // The open session's uncommitted work, staged around and committed by nothing: a crash-safe
        // body save, and a file the session added beside it (05-card-window.md's attachments land in
        // the card's own folder) — which no commit anywhere has ever seen.
        let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
        let token = UUID()
        committer.beginCardSession(token) { cardFolder }
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed", body: "Half-typed."))
        let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt"
        try fixture.file(stray, Data("dropped mid-session".utf8))

        provider.settleSessions = { _ in
            committer.endCardSession(token)
            // The bare id — never the path. This is the whole regression.
            provider.noteDiscarded(cardFolderName: Ident.card1)
            return .proceed
        }
        await provider.cross(.undo)

        #expect(!fixture.exists(stray),
                "the discarded session's uncommitted file is gone from disk, not merely from a buffer")
        #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
                "and the restore landed over the body")
        #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
                "on a settled tree — nothing is left for the next flush to sweep into a commit")
    }
}

// MARK: - The provider binding

/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md
/// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the
/// matrix it is: **boards without app-managed git — repo-nested included — bind the native stack in
/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider.
/// There is no third answer any more.
///
/// ### The repo-nested row stopped being an exception
///
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
/// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
/// 1.x board, **a repo-nested board**) works normally — files read and write as on any board,
/// **native undo runs**". Pro used to answer differently on the same board, which made subscribing
/// *remove* ⌘Z from it; the re-ruling of 2026-07-31 retired that case outright — "leave strictly
/// alone concerns *git*, and this stack never touches git — memory-only, journal-free,
/// session-scoped" (13's header) — so the two tiers now agree on every board there is, and the tests
/// below pin both halves of that agreement.
@MainActor
@Suite("Git undo ▸ which board gets a provider")
struct GitUndoBindingTests {

    @Test("Free tier binds the native stack everywhere, git or not")
    func freeTierIsNativeEverywhere() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .free }

        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        #expect(session.git == nil, "the free tier composes no git state at all")
        #expect(session.history is NativeHistoryProvider)
        #expect(session.undoManager.canUndo == false, "empty, not absent")
    }

    @Test("Pro on a git board binds the git provider")
    func proOnAGitBoardBindsGit() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }

        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        #expect(session.history is GitHistoryProvider)
    }

    @Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo")
    func proOnAPlainBoardBindsTheNativeStack() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }

        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        // The board is Pro's — it composed a git state and detected a mode — and the mode is what
        // chose the substrate: "gitless boards bind the native undo stack in every tier" (12).
        #expect(session.git != nil, "Pro composes a git state even where there is no repository")
        #expect(session.gitMode == .none)
        #expect(session.history is NativeHistoryProvider)

        // And it is a *working* stack, not a placeholder: the same command surface a free-tier
        // session gets, which is what "a user subscribing relearns nothing" means here.
        let log = StepLog()
        #expect(session.undoManager.canUndo == false, "empty, not absent")
        session.history?.register(log.step("Move Card"))
        #expect(session.undoManager.canUndo)
        #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
        session.undoManager.undo()
        #expect(log.crossings == ["undo Move Card"])
    }

    @Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired")
    func proOnARepoNestedBoardBindsTheNativeStack() throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        // A repository at the *parent*, with the board inside it — the nested posture.
        let repoRoot = outer.root
        _ = GitRepository.create(at: repoRoot)
        let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }

        let ref = try openBoard(model, at: boardRoot)
        let session = try #require(model.session(for: ref))

        // The mode is detected — Pro walks the ancestors — and it no longer decides ⌘Z: "Pro binds it
        // on mode-none **and repo-nested** boards alike … what repo-nested denies is app-managed
        // history, never ⌘Z" (13's header, re-ruled 2026-07-31).
        #expect(session.gitMode == .repoNested)
        #expect(session.history is NativeHistoryProvider)
        #expect(session.undoManager.canUndo == false, "empty, not absent")

        // A working stack, exactly as the free tier's on this same board — which is the whole point
        // of the re-ruling: the two tiers answer alike here now.
        let log = StepLog()
        session.history?.register(log.step("Move Card"))
        #expect(session.undoManager.canUndo)
        #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
        session.undoManager.undo()
        #expect(log.crossings == ["undo Move Card"])

        // And the enclosing repository is still left strictly alone: no repository was created at the
        // board, and nothing here reaches for the ancestor's `.git` (13: memory-only, journal-free).
        #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
        #expect(session.git?.committer == nil, "no committer, and so nothing that could write there")
    }

    @Test("The default provider closure binds the native stack on an unverifiable board too")
    func proOnAnUnverifiableBoardBindsTheNativeStack() throws {
        // "`unverifiable` joins the same branch structurally" (`AppModel.makeHistoryProvider`) — a
        // denied ancestor check is no more a repository the app manages than a repo-nested one is.
        //
        // This exercises the seam directly rather than through `openBoard`: reaching `.unverifiable`
        // on a real board needs a denied *ancestor*, and POSIX permission bits (unlike the sandbox's
        // independent per-path grants) cascade — chmoding a real ancestor to deny its `.git` check
        // also denies reading the board's own files underneath it, so the board could never actually
        // open (`BoardGitModeDenialTests` in `BoardGitModeTests.swift` covers the detection axis
        // itself against real denied directories; this covers what the composition root does with
        // whatever mode a `HistoryStore` reports, real detection or not).
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger())
        let provider = model.makeHistoryProvider(store, .pro, git)

        #expect(provider is NativeHistoryProvider)
    }

    @Test("The free tier's repo-nested board still binds the native stack — it never detects one")
    func freeTierOnARepoNestedBoardIsNativeToo() throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        let repoRoot = outer.root
        _ = GitRepository.create(at: repoRoot)
        let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .free }

        let ref = try openBoard(model, at: boardRoot)
        let session = try #require(model.session(for: ref))

        // 12 ▸ The free tier and `.git` names this board by hand: "opening a board that has one …
        // a repo-nested board … works normally … native undo runs". The tier composes no git state
        // at all, so there is nothing here that *could* tell this board from a plain one — the inert
        // posture made structural rather than remembered.
        #expect(session.git == nil)
        #expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
        #expect(session.history is NativeHistoryProvider)
        // Pro on this same board now answers identically (above) — the two tiers agree, which is what
        // the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board.
    }

    @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")
    func addGitSwapsTheSubstrate() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }

        let ref = try openBoard(model, at: fixture.root)
        let native = try #require(model.session(for: ref)?.history as? NativeHistoryProvider)
        // The AppKit face AppKit already holds: the swap must not replace *this* object, or every
        // window's `windowWillReturnUndoManager` answer would go stale.
        let manager = try #require(model.session(for: ref)?.undoManager)

        // A real in-session step, mid-flight when the flip arrives.
        let log = StepLog()
        native.register(log.step("Move Card"))
        #expect(manager.canUndo)
        #expect(manager.undoMenuItemTitle == "Undo Move Card")

        let git = try #require(model.session(for: ref)?.git)
        #expect(await git.addGit())

        let session = try #require(model.session(for: ref))
        #expect(session.gitMode == .git)
        #expect(session.history is GitHistoryProvider, "the flip carries undo through with it")
        #expect(session.store.history is GitHistoryProvider, "and the Writer boundary registers there")
        #expect(session.undoManager === manager, "the same manager, over a different stack")

        // **The stack dies with the substrate** (13's header — the branch-switch discard-and-reseed
        // precedent): no migration, and the discarded stack is cleared rather than merely dropped,
        // so nothing holding a reference to it can cross a step against a board that now has a trail.
        #expect(native.canUndo == false)
        #expect(native.canRedo == false)
        #expect(log.crossings.isEmpty, "the in-flight step never ran — it was discarded, not applied")

        // **Seeded from the root commit**, which is the stack's floor and not a step (06 ▸ Rules), so
        // ⌘Z is correctly empty the instant the flip lands — and the menu row says nothing.
        #expect(try trail(at: fixture.root).count == 1)
        #expect(manager.canUndo == false, "on a trail whose only commit is the root")
        #expect(manager.canRedo == false)
        #expect(manager.undoMenuItemTitle == "Undo")
    }

    @Test("The swapped-in git provider is live — the next landed commit is a step on it")
    func theSwappedProviderHearsTheCommitter() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }

        let ref = try openBoard(model, at: fixture.root)
        let git = try #require(model.session(for: ref)?.git)
        #expect(await git.addGit())

        let session = try #require(model.session(for: ref))
        let provider = try #require(session.history as? GitHistoryProvider)
        // `activateAutoCommit` remembered the session's wiring for exactly this — the committer
        // add-git built reports landed commits to whatever provider the session now holds, which is
        // the one the swap just installed.
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await commitAndSettle(committer, provider)

        #expect(try trail(at: fixture.root).count == 2)
        #expect(session.undoManager.canUndo, "the trail behind ⌘Z is the repository's, live")
    }
}

// MARK: - Failures reach the strip as failures

/// **The one-shot failure class's second shape, wired** (02-architecture.md ▸ The banner surface,
/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action
/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone
/// loss row (the shipped loss-row compromise is retired)".
///
/// These are wiring tests: what the session hands each seam, and which class of row comes out the
/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is
/// `BannerCenter.rows(...)`'.
@MainActor
@Suite("Git undo ▸ a failed git operation is a failure row")
struct GitOperationFailureBannerTests {

    @Test("A failed restore posts the git failure shape, named by the key that was pressed")
    func aFailedRestorePostsAFailureRow() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))
        let provider = try #require(session.history as? GitHistoryProvider)

        // What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the
        // library's own message. The `operation` string on the failure is developer-facing and is
        // deliberately not what the user reads.
        provider.reportFailure?(.undo, GitOperationFailure(
            operation: GitRestoreOperation.operationName,
            message: "could not write to 'index.md': Permission denied"
        ))

        let banners = session.store.banners
        #expect(banners.gitFailures.count == 1)
        #expect(banners.gitFailures.first?.operation == .undo)
        #expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied")
        #expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure")
        #expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary")

        // ⇧⌘Z's mirror, from the same seam and the same closure.
        provider.reportFailure?(.redo, GitOperationFailure(
            operation: GitRestoreOperation.operationName,
            message: "the repository is locked"
        ))
        #expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot")
    }

    @Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row")
    func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))
        let switcher = try #require(session.git?.switcher)

        switcher.reportFailure?(GitOperationFailure(
            operation: GitBranchOperation.operationName,
            message: "your local changes would be overwritten"
        ))
        // **The recovery is a success report** — "a branch switch was interrupted — the previous
        // state is restored" — so it keeps the warning tone the ruling leaves it (02).
        switcher.reportRecovery?(GitOperationStamp.interruptionMessage)

        let banners = session.store.banners
        #expect(banners.gitFailures.map(\.operation) == [.branchSwitch])
        #expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage])
    }
}

// MARK: - Routing

@MainActor
@Suite("Git undo ▸ focus routing, with the git provider behind it")
struct GitUndoRoutingTests {

    @Test("A focused text surface takes ⌘Z, whatever the board's substrate is")
    func textEditingWins() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))
        #expect(session.history is GitHistoryProvider)

        let text = UndoManager()
        // A field editor holds the keyboard: the *text* manager answers, so a reflexive ⌘Z over a
        // typo can never become a tree checkout (06 ▸ Undo routing).
        #expect(BoardUndoRouting.undoManager(
            isTextEditing: true,
            board: session.undoManager,
            textFallback: text
        ) === text)
        // Focus outside every text surface: the board's own substrate answers.
        #expect(BoardUndoRouting.undoManager(
            isTextEditing: false,
            board: session.undoManager,
            textFallback: text
        ) === session.undoManager)
    }

    @Test("A board with no provider still routes to its own manager — it just cannot cross")
    func noProviderIsStillTheBoardsManager() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }
        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        let text = UndoManager()
        let answered = BoardUndoRouting.undoManager(
            isTextEditing: false,
            board: session.undoManager,
            textFallback: text
        )
        // **No fall-through in either direction**: the board's manager answers even with nothing
        // behind it, so an exhausted editor's ⌘Z never reaches a *different* stack — it reaches this
        // one, which is disabled, and the system beeps.
        #expect(answered === session.undoManager)
        #expect(answered.canUndo == false)
    }
}

// MARK: - The card window's History section

@MainActor
@Suite("Git undo ▸ the card's History trail")
struct CardHistorySectionTests {

    @Test("The trail lists the commits that touched this card, newest first")
    func theTrailIsNewestFirst() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed again"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root)
        #expect(commits.map(\.subject) == [
            "Rename card 'Renamed' → 'Renamed again'",
            "Rename card 'First' → 'Renamed'",
            GitRepository.initialCommitSubject
        ])
    }

    @Test("The trail follows the card across a lane move")
    func theTrailFollowsALaneMove() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        // The card moves lane — its folder name (its identity) is unchanged, its path is not.
        try FileManager.default.moveItem(
            at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)"),
            to: fixture.root.appendingPathComponent("\(Ident.lane2)/\(Ident.card1)")
        )
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root)
        #expect(commits.count == 2, "the move, and the root commit it was born in")
        #expect(commits.first?.subject == "Move card 'First' to Doing")
    }

    @Test("A card no commit has touched has an empty trail")
    func anUntouchedCardHasNoTrail() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        _ = try quickCommitter(git)

        let commits = GitHistoryWalk.commitsTouching(folderNamed: "not-a-card", at: fixture.root)
        #expect(commits.isEmpty)
    }

    @Test("A board with no git answers nothing at all")
    func aPlainBoardHasNoTrail() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        #expect(GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root).isEmpty)
        #expect(GitHistoryWalk.ancestry(at: fixture.root).isEmpty)
        #expect(GitHistoryWalk.headOID(at: fixture.root) == nil)
    }

    @Test("A row reads subject over relative date and author")
    func rowsRenderSubjectDateAuthor() {
        let now = Date()
        let commit = GitCommitRecord(
            oid: "abc123",
            subject: "Move card 'Fix login' to Doing",
            authorName: "Claude",
            date: now.addingTimeInterval(-60 * 60 * 48),
            parentOID: "def456"
        )

        let rows = CardHistoryRows.rows(for: [commit], now: now, locale: Locale(identifier: "en_US"))
        #expect(rows.count == 1)
        #expect(rows[0].subject == "Move card 'Fix login' to Doing")
        #expect(rows[0].attribution == "2 days ago · Claude")
        #expect(rows[0].id == "abc123")
    }

    @Test("A commit from moments ago reads 'just now' rather than a rounded future")
    func aFreshCommitReadsJustNow() {
        let now = Date()
        #expect(CardHistoryRows.relativeDate(now.addingTimeInterval(-2), now: now) == "just now")
        #expect(CardHistoryRows.relativeDate(now, now: now) == "just now")
    }

    @Test("An unauthored commit drops the author segment rather than trailing a separator")
    func anUnauthoredCommitDropsTheSegment() {
        let now = Date()
        let commit = GitCommitRecord(
            oid: "abc",
            subject: "Update board",
            authorName: "   ",
            date: now,
            parentOID: nil
        )
        #expect(CardHistoryRows.attribution(of: commit, now: now) == "just now")
    }

    @Test("Path matching is component-exact")
    func pathMatchingIsExact() {
        #expect(GitHistoryWalk.path("lane/card-1/index.md", isInsideFolderNamed: "card-1"))
        #expect(GitHistoryWalk.path("lane/card-1/attachments/a.png", isInsideFolderNamed: "card-1"))
        #expect(GitHistoryWalk.path(".trash/card-1/index.md", isInsideFolderNamed: "card-1"))
        #expect(GitHistoryWalk.path("lane/card-10/index.md", isInsideFolderNamed: "card-1") == false)
        #expect(GitHistoryWalk.path("card-1", isInsideFolderNamed: "card-1") == false)
    }
}
