import Foundation
import SwiftGitX
import Testing
@testable import Kanban

/// **Branch switching, create-and-switch, and the popover's git surface** (06-history-undo.md ▸ Branch
/// switching; ▸ Rules ▸ Abnormal repo states; ▸ Interaction with external writers; 03-board-ui.md
/// ▸ Board popover).
///
/// Every repository here is a **real** one, built through the app's own add-git over bundled libgit2,
/// and every claim about HEAD, the trail, or the working tree is read back through libgit2 or off
/// disk rather than through the code that made it. Nothing shells out to `git` (`AutoCommitTests`'
/// rule, kept).

// MARK: - Fixtures

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) {
    let fixture = try makeBoard()
    let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger()))
    #expect(await git.addGit())
    await git.refreshBranch()
    return (fixture, git)
}

@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 switcher wired the way `AppModel.wireBranchSwitching` wires one, minus the seams that need
/// windows, a store, or a banner strip — each of those gets its own suite below.
@MainActor
private func makeSwitcher(_ git: HistoryStore) throws -> GitBranchSwitcher {
    let switcher = try #require(git.switcher)
    switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
    switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
    switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
    switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
    switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
    switcher.lockRetryDelay = .milliseconds(1)
    switcher.lockWaitInterval = .milliseconds(5)
    switcher.lockPathNamingDelay = .milliseconds(10)
    switcher.lockWaitLimit = .milliseconds(60)
    return switcher
}

/// A settleable session a test drives by hand — `GitUndoTests`' own, which this suite needs for the
/// other caller of the same gate.
@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
            }
        )
    }
}

// MARK: Reading the repository back

private func headOID(at boardRoot: URL) -> String? {
    GitHistoryWalk.headOID(at: boardRoot)
}

private func subjects(at boardRoot: URL, limit: Int = 32) throws -> [String] {
    let repository = try Repository.open(at: boardRoot)
    guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
    var found: [String] = []
    var current: Commit? = tip
    while let commit = current, found.count < limit {
        found.append(commit.summary)
        current = (try? commit.parents)?.first
    }
    return found
}

/// The tip of a named branch, without checking it out — how "the flush landed on the branch it was
/// made on" is asserted from the other branch.
private func branchTipSubject(_ branch: String, at boardRoot: URL) throws -> String? {
    let repository = try Repository.open(at: boardRoot)
    guard let reference = try? repository.branch.get(named: branch) else { return nil }
    return (reference.target as? Commit)?.summary
}

// MARK: - The repository half

@Suite("Branch operations ▸ the repository half")
struct GitBranchOperationTests {

    @Test("A fresh repository lists exactly the branch its root commit landed on")
    @MainActor
    func listsLocalBranches() async throws {
        let (fixture, _) = try await makeGitBoard()
        defer { fixture.tearDown() }

        #expect(GitBranchOperation.localBranches(at: fixture.root) == ["main"])
    }

    @Test("Create-and-switch adds a branch at HEAD and moves HEAD onto it")
    @MainActor
    func createsAndSwitches() async throws {
        let (fixture, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let before = headOID(at: fixture.root)

        let outcome = GitBranchOperation.createAndSwitch("redesign", at: fixture.root)

        #expect(outcome == .switched("redesign"))
        #expect(GitRepository.branchName(at: fixture.root) == "redesign")
        #expect(GitBranchOperation.localBranches(at: fixture.root) == ["main", "redesign"])
        // Created *at* HEAD: the two branches are the same commit, which is why the tree cannot have
        // changed and why the checkout that follows it is a no-op.
        #expect(headOID(at: fixture.root) == before)
    }

    @Test("Switching materializes the branch's tree — a card added on one branch is gone on the other")
    @MainActor
    func checkoutMaterializesTheTree() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))

        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        #expect(GitRepository.branchName(at: fixture.root) == "main")
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the card belongs to the other branch")
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))

        // And back again: nothing was lost, the branch still has it.
        #expect(GitBranchOperation.checkout("redesign", at: fixture.root) == .switched("redesign"))
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
    }

    /// The safe strategy's whole point, and the reason nothing in `GitBranchOperation` passes
    /// `GIT_CHECKOUT_FORCE`: a working tree carrying changes the checkout would overwrite refuses the
    /// checkout wholesale rather than losing them.
    @Test("A conflicting uncommitted change refuses the checkout and leaves every byte where it was")
    @MainActor
    func safeCheckoutRefusesRatherThanOverwrite() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // Two branches that disagree about one file.
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed on redesign"))
        await committer.flushNow()
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        // An uncommitted edit to exactly that file — the state the settle-and-flush steps exist to
        // make impossible, reproduced here to prove what happens if it ever is not.
        let dirty = plain(order: "1024", title: "Typed but never saved to history")
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", dirty)

        let outcome = GitBranchOperation.checkout("redesign", at: fixture.root)

        guard case .failed = outcome else {
            Issue.record("a conflicting checkout must refuse, not overwrite: \(outcome)")
            return
        }
        #expect(GitRepository.branchName(at: fixture.root) == "main", "the tree is left as it was")
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == dirty)
    }

    @Test("A held index.lock reads as contention, never as a failure — and names the lock's path")
    @MainActor
    func aHeldIndexIsContention() async throws {
        let (fixture, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))

        try fixture.file(".git/index.lock", Data())
        let outcome = GitBranchOperation.checkout("main", at: fixture.root)

        guard case let .locked(path) = outcome else {
            Issue.record("a held index must read as .locked: \(outcome)")
            return
        }
        #expect(path.hasSuffix("index.lock"))
        #expect(GitRepository.branchName(at: fixture.root) == "redesign", "nothing moved")
    }

    @Test("A paused repository holds the switch — the app never writes in a state it didn't create")
    @MainActor
    func aPausedRepositoryHolds() async throws {
        let (fixture, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))

        let head = try #require(headOID(at: fixture.root))
        try fixture.file(".git/MERGE_HEAD", Data("\(head)\n".utf8))

        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .held(.merge))
        #expect(GitRepository.branchName(at: fixture.root) == "redesign")
    }

    @Test("An invalid name, a duplicate name, and a branch that isn't there each fail cleanly")
    @MainActor
    func namesAreValidated() async throws {
        let (fixture, _) = try await makeGitBoard()
        defer { fixture.tearDown() }

        #expect(!GitBranchOperation.isValidBranchName("has spaces"))
        #expect(!GitBranchOperation.isValidBranchName(""))
        #expect(GitBranchOperation.isValidBranchName("feature/redesign"))

        for name in ["has spaces", "main"] {
            guard case .failed = GitBranchOperation.createAndSwitch(name, at: fixture.root) else {
                Issue.record("'\(name)' must be refused")
                return
            }
        }
        guard case .failed = GitBranchOperation.checkout("nonexistent", at: fixture.root) else {
            Issue.record("a branch that does not exist must fail cleanly")
            return
        }
        #expect(GitRepository.branchName(at: fixture.root) == "main")
    }

    @Test("Create-and-switch on an unborn HEAD moves the symbolic ref and creates nothing")
    func createOnAnUnbornHead() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // `git init` with no commit: exactly what add-git leaves on an empty folder, and what a
        // cloned-but-empty repository is.
        _ = try Repository.create(at: fixture.root)
        try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))

        #expect(GitBranchOperation.createAndSwitch("trunk", at: fixture.root) == .switched("trunk"))
        #expect(GitRepository.branchName(at: fixture.root) == "trunk")
        #expect(GitBranchOperation.localBranches(at: fixture.root).isEmpty, "an unborn HEAD has no branch yet")
    }
}

// MARK: - The sequence

@Suite("Branch switching ▸ the sequence")
struct BranchSwitchSequenceTests {

    /// The card's first done-when criterion: "Switching with an open unsaved Edit session presents the
    /// save-or-discard step and never proceeds silently."
    @Test("An open unsaved session is always asked about, and Cancel keeps the branch and the session")
    @MainActor
    func cancelKeepsEverything() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        let session = FakeSession()
        var asked = 0
        switcher.settleSessions = {
            let gate = SessionSettleGate(
                sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
                ask: { asked += 1; return .cancel }
            )
            return await gate.settleAll()
        }

        #expect(await switcher.switchTo("redesign") == false)
        #expect(asked == 1, "the step is presented — never a silent commit, never a silent abandon")
        #expect(GitRepository.branchName(at: fixture.root) == "main")
        #expect(session.saves == 0)
        #expect(session.discards == 0)
    }

    @Test("Save All ends the sessions and the switch proceeds")
    @MainActor
    func saveAllProceeds() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        let session = FakeSession()
        switcher.settleSessions = {
            let gate = SessionSettleGate(
                sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
                ask: { .saveAll }
            )
            return await gate.settleAll()
        }

        #expect(await switcher.switchTo("redesign"))
        #expect(session.saves == 1)
        #expect(GitRepository.branchName(at: fixture.root) == "redesign")
    }

    /// "Since Apply validates, a buffer that fails validation cancels the whole switch with focus on
    /// the offending window, nothing half-switched."
    @Test("A raw buffer that will not validate cancels the whole switch, focused on the offender")
    @MainActor
    func aFailingRawBufferCancelsTheSwitch() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        let bad = FakeSession()
        bad.saveSucceeds = false
        var focused: [String] = []
        switcher.settleSessions = {
            let gate = SessionSettleGate(
                sessions: { [bad.descriptor(id: "card-b", folder: "card-b")] },
                ask: { .saveAll },
                focus: { focused.append($0) }
            )
            return await gate.settleAll()
        }

        #expect(await switcher.switchTo("redesign") == false)
        #expect(focused == ["card-b"])
        #expect(GitRepository.branchName(at: fixture.root) == "main", "nothing half-switched")
    }

    /// The gate is over *every* open session, not the ones a diff reaches — the branch-switch
    /// narrowing that the undo restore deliberately does not share.
    @Test("Every open session is asked about, even one the checkout's diff never touches")
    @MainActor
    func theGateIsNotNarrowedByADiff() async {
        let untouched = FakeSession()
        var asked = 0
        let gate = SessionSettleGate(
            sessions: { [untouched.descriptor(id: "card-z", folder: "card-z")] },
            ask: { asked += 1; return .cancel }
        )

        // The restore's gate, handed paths that miss this card, never asks.
        #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
        #expect(asked == 0)

        // The switch's gate asks about it anyway.
        #expect(await gate.settleAll() == .cancelled)
        #expect(asked == 1)
    }

    @Test("A board with nothing settleable is never asked at all")
    @MainActor
    func nothingToSettleAsksNothing() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        let settled = FakeSession()
        settled.isSettled = true
        var asked = 0
        switcher.settleSessions = {
            let gate = SessionSettleGate(
                sessions: { [settled.descriptor(id: "card-a", folder: "card-a")] },
                ask: { asked += 1; return .cancel }
            )
            return await gate.settleAll()
        }

        #expect(await switcher.switchTo("redesign"))
        #expect(asked == 0)
    }

    /// "With sessions settled, the pending auto-commit flushes (flush-before-overwrite) and checkout
    /// runs on a truly settled tree" — and the flush lands on the branch the work was done on.
    @Test("The pending auto-commit flushes onto the branch being left, before the checkout")
    @MainActor
    func theFlushLandsOnTheOldBranch() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        // Work on main that no commit has yet.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Edited on main"))
        #expect(try subjects(at: fixture.root) == [GitRepository.initialCommitSubject])

        #expect(await switcher.switchTo("redesign"))

        #expect(GitRepository.branchName(at: fixture.root) == "redesign")
        let mainTip = try branchTipSubject("main", at: fixture.root)
        #expect(mainTip != GitRepository.initialCommitSubject, "the pending work committed before the switch")
        #expect(mainTip?.contains("Edited on main") == true || mainTip?.isEmpty == false)
        // And the switch really moved the tree: redesign never saw that edit.
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("Edited on main") == false)
        _ = committer
    }

    /// The card's second done-when criterion, first half: "A successful switch reseeds undo/redo from
    /// the new branch's HEAD."
    @Test("The undo stack is reseeded from the new branch's ancestry, with redo empty")
    @MainActor
    func theStackIsReseeded() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let switcher = try makeSwitcher(git)

        // main: one commit. redesign: two.
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()

        let provider = GitHistoryProvider(boardRoot: fixture.root)
        await provider.reseed()
        switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
        #expect(provider.ancestry.count == 2)

        #expect(await switcher.switchTo("main"))

        #expect(provider.ancestry.count == 1, "the stack is the new HEAD's first-parent ancestry")
        #expect(provider.ancestry.first?.subject == GitRepository.initialCommitSubject)
        #expect(provider.redoCommits.isEmpty, "redo starts empty")
    }

    /// The card's second done-when criterion, second half: "leaves the watcher fully resumed after one
    /// reload."
    @Test("The switch runs inside exactly one bracket, balanced")
    @MainActor
    func theSwitchIsBracketedOnce() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        var begins = 0
        var ends = 0
        var announcements: [String] = []
        switcher.runBracketed = { announcement, work in
            begins += 1
            announcements.append(announcement)
            await work()
            ends += 1
        }

        #expect(await switcher.switchTo("redesign"))

        #expect(begins == 1)
        #expect(ends == 1)
        #expect(announcements == ["Switched to branch 'redesign'"])
    }

    /// "**Discard** reverts buffers and uncommitted saves to HEAD" — the *saves* half, which the
    /// branch switch owns because it materializes nothing of its own.
    @Test("Discard puts the session's uncommitted on-disk saves back to HEAD before anything commits")
    @MainActor
    func discardRevertsUncommittedSaves() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        _ = try quickCommitter(git)
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        let committed = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
        // The ~700 ms crash-safe save of an open Edit session: on disk, deliberately uncommitted.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed"))

        let session = FakeSession()
        switcher.settleSessions = { [weak switcher] in
            let gate = SessionSettleGate(
                sessions: {
                    [SettleableSession(
                        id: Ident.card1,
                        cardFolderName: Ident.card1,
                        needsSettling: { !session.isSettled },
                        saveAll: { session.saves += 1; return true },
                        discard: {
                            // What the card window does: the buffer, and nothing on disk.
                            session.discards += 1
                            session.isSettled = true
                            switcher?.noteDiscarded(cardFolderName: Ident.card1)
                        }
                    )]
                },
                ask: { .discard }
            )
            return await gate.settleAll()
        }

        #expect(await switcher.switchTo("redesign"))

        #expect(session.discards == 1)
        #expect(GitRepository.branchName(at: fixture.root) == "redesign")
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == committed, "the save was reverted")
        // And it was reverted *before* the flush, so nothing recorded the text the user discarded.
        let everySubject = try subjects(at: fixture.root)
            + (try branchTipSubject("main", at: fixture.root).map { [$0] } ?? [])
        #expect(everySubject.allSatisfy { !$0.contains("Half-typed") })
    }

    /// Create-and-switch keeps the whole sequence — the judgment call `createAndSwitch(to:)` records.
    @Test("Create-and-switch runs the same settle step, and lands the new branch at HEAD")
    @MainActor
    func createAndSwitchKeepsTheSequence() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)

        let session = FakeSession()
        var asked = 0
        switcher.settleSessions = {
            let gate = SessionSettleGate(
                sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
                ask: { asked += 1; return .saveAll }
            )
            return await gate.settleAll()
        }

        let before = headOID(at: fixture.root)
        #expect(await switcher.createAndSwitch(to: "feature/inbox"))

        #expect(asked == 1, "the sequence is uniform — a dirty session is settled either way")
        #expect(GitRepository.branchName(at: fixture.root) == "feature/inbox")
        #expect(headOID(at: fixture.root) == before)
        #expect(switcher.branches.contains("feature/inbox"))
    }

    @Test("A switch refuses while the git surface is held, without asking anybody anything")
    @MainActor
    func aHeldSurfaceRefusesBeforeTheModal() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        var asked = 0
        switcher.settleSessions = { asked += 1; return .proceed }
        switcher.isHeld = { true }

        #expect(await switcher.switchTo("redesign") == false)
        #expect(asked == 0)
        #expect(GitRepository.branchName(at: fixture.root) == "main")
    }

    /// The same refusal through the **real** pause rather than an injected one: an unreadable
    /// repository is a `GitRepositoryPause` like any other, so the switch's existing gate closes on
    /// it with nothing added here (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled
    /// 2026-07-31: "the whole git surface paused").
    @Test("A switch refuses against a repository the app cannot open, and changes nothing")
    @MainActor
    func anUnreadableRepositoryRefusesTheSwitch() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        var asked = 0
        switcher.settleSessions = { asked += 1; return .proceed }

        let head = fixture.root.appendingPathComponent(".git/HEAD")
        let savedHead = try Data(contentsOf: head)
        try FileManager.default.removeItem(at: head)
        await git.committer?.refreshPause()
        #expect(git.isRepositoryUnreadable)

        #expect(await switcher.switchTo("redesign") == false)
        #expect(asked == 0, "no modal, no stamp, no checkout — nothing is attempted at all")
        #expect(switcher.lastFailure == nil, "a pause is not a failure; the standing banner is the message")

        // The repository is exactly as it was found, which is the promise the row makes.
        try savedHead.write(to: head)
        #expect(GitRepository.branchName(at: fixture.root) == "main")
    }

    /// "Contention outlasting the brief retry surfaces as a *waiting* state in the operation's
    /// in-progress banner row … a wait that persists implausibly long names the lock path — never an
    /// error dialog, never a hammer."
    @Test("A persistent lock becomes a waiting state, then names the lock path, then fails cleanly")
    @MainActor
    func theLockWaitingState() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        try fixture.file(".git/index.lock", Data())

        var labels: [String] = []
        var ended = 0
        switcher.beginProgress = { label in
            labels.append(label)
            return UUID()
        }
        switcher.updateProgress = { _, label in labels.append(label) }
        switcher.endProgress = { _ in ended += 1 }
        var failures: [GitOperationFailure] = []
        switcher.reportFailure = { failures.append($0) }

        #expect(await switcher.switchTo("redesign") == false)

        #expect(labels.first == "Switching to 'redesign'…")
        #expect(labels.contains(GitBranchSwitcher.waitingLabel), "the waiting state is the same row, relabelled")
        #expect(labels.contains { $0.hasPrefix(GitBranchSwitcher.waitingLabel + " (") },
                "an implausibly long wait names the lock path")
        #expect(ended == 1, "the row is cleared however the operation ends")
        #expect(failures.count == 1)
        #expect(failures.first?.message.contains("index.lock") == true)
        #expect(GitRepository.branchName(at: fixture.root) == "main", "the tree is left as it was")
    }

    @Test("A switch to the branch already checked out does nothing")
    @MainActor
    func switchingToTheCurrentBranch() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)

        var asked = 0
        switcher.settleSessions = { asked += 1; return .proceed }

        // The picker never offers it (`BoardGitControls.otherBranches`), and the operation is a no-op
        // if one ever arrives: same branch, same tree, nothing to announce.
        #expect(await switcher.switchTo("main"))
        #expect(GitRepository.branchName(at: fixture.root) == "main")
        #expect(asked == 1, "the settle step still runs — the sequence has one shape")
    }
}

// MARK: - The own-leftovers stamp

@Suite("Branch switching ▸ the own-leftovers stamp")
struct GitOperationStampTests {

    @Test("The recovery decision needs both a stamp and a pause")
    func theDecisionMatrix() {
        let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: "abc")

        #expect(GitOperationRecovery.decide(stamp: nil, pause: nil) == .nothingToDo)
        // A pause with no stamp is somebody else's operation — the pause-and-defer stance, unchanged.
        #expect(GitOperationRecovery.decide(stamp: nil, pause: .merge) == .nothingToDo)
        // A stamp with no pause is the app's own *finished* work.
        #expect(GitOperationRecovery.decide(stamp: stamp, pause: nil) == .clearStamp)
        #expect(GitOperationRecovery.decide(stamp: stamp, pause: .detachedHead) == .abort(stamp))
    }

    @Test("The stamp is written before the repository is touched and cleared when the switch is over")
    @MainActor
    func theStampBracketsTheOperation() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))

        var stamp: GitOperationStamp?
        var writes: [GitOperationStamp?] = []
        var stampDuringCheckout: GitOperationStamp?
        switcher.readStamp = { stamp }
        switcher.writeStamp = { value in
            stamp = value
            writes.append(value)
        }
        switcher.runBracketed = { _, work in
            stampDuringCheckout = stamp
            await work()
        }

        #expect(await switcher.switchTo("redesign"))

        #expect(stampDuringCheckout?.fromBranch == "main")
        #expect(stampDuringCheckout?.toBranch == "redesign")
        #expect(stampDuringCheckout?.headOID != nil)
        #expect(writes.count == 2, "written once, cleared once")
        #expect(writes.last ?? nil == nil, "a finished operation leaves nothing for a later open to abort")
        #expect(stamp == nil)
    }

    /// The mechanism itself: a pause state *with* a matching stamp is the app's own leftover, aborted
    /// back to the pre-operation state and announced.
    @Test("An interrupted switch is aborted at the next open, the previous state restored, the stamp cleared")
    @MainActor
    func anInterruptedSwitchIsRecovered() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let switcher = try makeSwitcher(git)

        // A second branch whose tree differs, so "the previous state is restored" is a claim about
        // files rather than only about a ref.
        #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
        let mainHead = try #require(headOID(at: fixture.root))

        // The shape a crash mid-checkout leaves: a detached HEAD the app is holding for.
        let redesignHead = try #require(try {
            let repository = try Repository.open(at: fixture.root)
            return (try repository.branch.get(named: "redesign").target as? Commit)?.id.hex
        }())
        try fixture.file(".git/HEAD", Data("\(redesignHead)\n".utf8))
        #expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead)

        var stamp: GitOperationStamp? = GitOperationStamp(
            fromBranch: "main",
            toBranch: "redesign",
            headOID: mainHead
        )
        switcher.readStamp = { stamp }
        switcher.writeStamp = { stamp = $0 }
        var recoveries: [String] = []
        switcher.reportRecovery = { recoveries.append($0) }

        await switcher.recoverInterruptedOperation()

        #expect(GitRepository.branchName(at: fixture.root) == "main", "HEAD is back where it started")
        #expect(headOID(at: fixture.root) == mainHead)
        #expect(stamp == nil, "the stamp is cleared")
        #expect(recoveries == ["A branch switch was interrupted — the previous state is restored."])
        #expect(GitCommitOperation.reading(at: fixture.root).pause == nil, "the surface is live again")
    }

    @Test("A pause with no stamp is left strictly alone — the pause-and-defer stance is unchanged")
    @MainActor
    func aForeignPauseIsNotTouched() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)

        let head = try #require(headOID(at: fixture.root))
        try fixture.file(".git/HEAD", Data("\(head)\n".utf8))
        #expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead)

        var recoveries: [String] = []
        switcher.readStamp = { nil }
        switcher.writeStamp = { _ in Issue.record("nothing may be written without a stamp to match") }
        switcher.reportRecovery = { recoveries.append($0) }

        await switcher.recoverInterruptedOperation()

        #expect(recoveries.isEmpty)
        #expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead, "still detached")
    }

    @Test("A stale stamp over a healthy repository is dropped silently")
    @MainActor
    func aStaleStampIsDropped() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let switcher = try makeSwitcher(git)

        var stamp: GitOperationStamp? = GitOperationStamp(
            fromBranch: "main",
            toBranch: "redesign",
            headOID: headOID(at: fixture.root)
        )
        switcher.readStamp = { stamp }
        switcher.writeStamp = { stamp = $0 }
        var recoveries: [String] = []
        switcher.reportRecovery = { recoveries.append($0) }

        await switcher.recoverInterruptedOperation()

        #expect(stamp == nil)
        #expect(recoveries.isEmpty, "nothing happened that anybody needs telling about")
    }

    @Test("The stamp round-trips through the per-board registry file")
    @MainActor
    func theStampSurvivesTheRegistryFile() throws {
        let home = FileManager.default.temporaryDirectory
            .appendingPathComponent("BranchStamp-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: home) }
        let storage = home.appendingPathComponent("registry.json")

        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let registry = BoardRegistry(storageURL: storage)
        let id = registry.recordOpen(of: fixture.root)
        #expect(registry.gitOperationStamp(id: id) == nil)

        let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: "abc123")
        registry.setGitOperationStamp(id: id, stamp)

        // A new registry over the same file is the next launch — which is the only reader that
        // matters, since the stamp exists for the case where this process did not survive.
        let reloaded = BoardRegistry(storageURL: storage)
        #expect(reloaded.record(id: id)?.gitOperationStamp == stamp)

        reloaded.setGitOperationStamp(id: id, nil)
        #expect(BoardRegistry(storageURL: storage).record(id: id)?.gitOperationStamp == nil)
    }
}

// MARK: - The bracket and the lock

@Suite("Branch switching ▸ the bracket and the read-only lock")
struct BranchSwitchBracketTests {

    /// The card's third done-when criterion: "A failed post-switch reload locks the board read-only
    /// until a successful reload." The lock itself is 02's and already exists; this proves the branch
    /// switch reaches it.
    @Test("A switch onto a branch this app cannot load locks the board, and a repair clears it")
    @MainActor
    func aFailedFinalReloadLocksTheBoard() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let switcher = try makeSwitcher(git)

        // A branch whose tree carries an `index.md` the loader refuses.
        #expect(GitBranchOperation.createAndSwitch("broken", at: fixture.root) == .switched("broken"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: [unclosed\n---\n")
        await committer.flushNow()
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
        committer.stop()

        let store = try BoardStore(rootURL: fixture.root)
        var begins = 0
        var ends = 0
        store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
        switcher.runBracketed = { [weak store] announcement, work in
            guard let store else { return await work() }
            try? await store.performWholesale(announcing: announcement) { await work() }
        }
        let lastGood = store.snapshot

        #expect(await switcher.switchTo("broken"))
        #expect(begins == 1)
        #expect(ends == 1, "the watcher is resumed whichever way the operation went")

        // The bracket's owed reload lands and fails: the snapshot on screen describes the branch that
        // is no longer checked out.
        store.handleWatcherEvent(.treeChanged(.appMediated))
        await store.awaitQuiescence()

        #expect(store.readOnlyLock == .bracketedReloadFailed)
        #expect(store.isReadOnly)
        #expect(store.snapshot == lastGood)

        // A repair — here, switching back — and the next successful reload clears both.
        #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.readOnlyLock == nil)
        #expect(store.reloadFailure == nil)
    }

    @Test("The in-progress row is relabelled in place, keeping its id")
    @MainActor
    func theInProgressRowIsRelabelledNotReplaced() {
        let center = BannerCenter()
        let id = center.beginOperation(label: "Switching to 'main'…")

        center.updateOperation(id, label: GitBranchSwitcher.waitingLabel)

        #expect(center.operations.count == 1, "the operation has not restarted — it is explaining itself")
        #expect(center.operations.first?.id == id)
        #expect(center.operations.first?.label == GitBranchSwitcher.waitingLabel)

        center.updateOperation(UUID(), label: "nobody")
        #expect(center.operations.first?.label == GitBranchSwitcher.waitingLabel, "an unknown id is a no-op")

        center.endOperation(id)
        #expect(center.operations.isEmpty)
    }
}

// MARK: - The popover's git section

@Suite("Board popover ▸ the git-mode surface")
struct BoardGitBranchSurfaceTests {

    @Test("A live repository shows its branch and accepts the controls")
    func aLiveSurface() {
        let surface = BoardGitBranchSurface.resolve(
            branch: "main",
            pause: nil,
            isSwitching: false,
            isWritable: true
        )

        #expect(surface.branchLabel == "main")
        #expect(!surface.isReadingBranch)
        #expect(surface.pauseExplanation == nil)
        #expect(surface.controlsEnabled)
        #expect(surface.accessibilityLabel == "Branch main")
    }

    @Test("A held repository names the state plainly and disables the branch controls")
    func aPausedSurface() {
        for pause in GitRepositoryPause.allCases {
            let surface = BoardGitBranchSurface.resolve(
                branch: "main",
                pause: pause,
                isSwitching: false,
                isWritable: true
            )
            #expect(surface.pauseExplanation == pause.explanation)
            #expect(!surface.controlsEnabled)
        }

        // The two sentences the design asks for: what the repository is doing, and whose job it is.
        #expect(GitRepositoryPause.detachedHead.explanation
            == "HEAD is detached — commits would belong to no branch")
        #expect(GitRepositoryPause.merge.explanation == "a merge is in progress")
        #expect(BoardGitBranchSurface.pauseCaption.contains("leaves the repository untouched"))
    }

    @Test("A detached HEAD's label is the short hash the branch reader already answers with")
    func aDetachedSurfaceShowsTheHash() {
        let surface = BoardGitBranchSurface.resolve(
            branch: "a1b2c3d",
            pause: .detachedHead,
            isSwitching: false,
            isWritable: true
        )

        #expect(surface.branchLabel == "a1b2c3d")
        #expect(!surface.controlsEnabled)
    }

    @Test("The read-only lock and a switch in flight each disable the controls")
    func lockedAndBusySurfaces() {
        #expect(!BoardGitBranchSurface.resolve(
            branch: "main", pause: nil, isSwitching: false, isWritable: false
        ).controlsEnabled)

        #expect(!BoardGitBranchSurface.resolve(
            branch: "main", pause: nil, isSwitching: true, isWritable: true
        ).controlsEnabled)
    }

    /// **The corrupt-`.git` loud failure at this one control** (06-history-undo.md ▸ Rules, ruled
    /// 2026-07-31: "Never a silent placeholder discovered only in the popover"). The bug this pins
    /// against is the shipped one: `GitRepository.branchName` answers `nil` on a repository it cannot
    /// open, and the placeholder meant "still reading" — forever.
    @Test("An unreadable repository reads as broken, never as still loading")
    func anUnreadableSurface() {
        let surface = BoardGitBranchSurface.resolve(
            branch: nil,
            pause: .unreadable,
            isSwitching: false,
            isWritable: true
        )

        #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel)
        #expect(surface.branchLabel != BoardGitBranchSurface.placeholder)
        #expect(!surface.isReadingBranch, "there is nothing in flight to be waiting for")
        #expect(surface.isRepositoryUnreadable)
        #expect(!surface.controlsEnabled, "the whole git surface is paused")
        #expect(surface.accessibilityLabel == "Branch unavailable")

        // The section's own sentence — a sibling of the popover's nested and unverifiable notes, and
        // not the pause note, whose second line would be advice about an operation nobody started.
        #expect(BoardGitBranchSurface.unreadableNote
            == "Lanework can't read this board's git repository, so history is paused; the repository is left untouched.")
    }

    @Test("A branch name read before the repository broke does not survive the pause")
    func anUnreadableSurfaceDropsAStaleBranch() {
        let surface = BoardGitBranchSurface.resolve(
            branch: "main",
            pause: .unreadable,
            isSwitching: false,
            isWritable: true
        )

        #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel,
                "the line must not go on naming a branch nothing can read")
    }

    @Test("Before the first read the line is a placeholder, not a guess at a branch name")
    func theReadingSurface() {
        let surface = BoardGitBranchSurface.resolve(
            branch: nil,
            pause: nil,
            isSwitching: false,
            isWritable: true
        )

        #expect(surface.branchLabel == BoardGitBranchSurface.placeholder)
        #expect(surface.isReadingBranch)
        #expect(surface.accessibilityLabel == "Reading branch")
    }

    /// The section's posture matrix is `BoardGitSectionTests`' (unchanged by this card); what is new
    /// is that git mode now carries controls rather than a read-only line.
    @Test("Git mode still resolves to the branch section — now the one with controls in it")
    func gitModeResolvesToTheBranchSection() {
        #expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
    }
}

// MARK: - Commit identity

@Suite("Board popover ▸ the commit-identity fields")
struct GitIdentityWriteTests {

    @Test("Writing into an empty config creates the section, name before email")
    func writesIntoAnEmptyConfig() {
        let written = GitConfigFile.applying(name: "Ada Lovelace", email: "ada@example.com", to: "")

        #expect(written == "[user]\n\tname = Ada Lovelace\n\temail = ada@example.com\n")
        let read = GitConfigFile.identity(inConfigText: written)
        #expect(read.name == "Ada Lovelace")
        #expect(read.email == "ada@example.com")
    }

    @Test("A new value appends a fresh section that wins on read — existing lines survive verbatim")
    func appendsAndWins() {
        let original = """
            [core]
            \trepositoryformatversion = 0
            \tbare = false
            [user]
            \tname = Old Name
            \tsigningkey = ABC123
            [remote "origin"]
            \turl = git@example.com:board.git

            """

        let written = GitConfigFile.applying(name: "New Name", email: "new@example.com", to: original)

        #expect(written.contains("\tname = New Name"))
        #expect(written.contains("\temail = new@example.com"))
        #expect(written.contains("\tname = Old Name"), "sets never edit existing sections — 06's append-only rule")
        #expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
        #expect(written.contains("[remote \"origin\"]"))
        #expect(written.contains("\turl = git@example.com:board.git"))
        #expect(written.contains("\tbare = false"))

        let read = GitConfigFile.identity(inConfigText: written)
        #expect(read.name == "New Name", "the appended section is last, and reads take the last")
        #expect(read.email == "new@example.com")
    }

    @Test("An empty field clears its key, and clearing both removes the section entirely")
    func emptyClearsTheKey() {
        let both = "[user]\n\tname = Ada\n\temail = ada@example.com\n"

        let nameOnly = GitConfigFile.applying(name: "Ada", email: "", to: both)
        #expect(nameOnly.contains("\tname = Ada"))
        #expect(!nameOnly.contains("email"))
        #expect(GitConfigFile.identity(inConfigText: nameOnly).email == nil)

        let cleared = GitConfigFile.applying(name: "", email: nil, to: both)
        #expect(!cleared.contains("[user]"), "a config indistinguishable from one nobody edited")
        #expect(GitConfigFile.identity(inConfigText: cleared) == (nil, nil))

        // And with company: the neighbours stay.
        let withCore = GitConfigFile.applying(
            name: "",
            email: "",
            to: "[core]\n\tbare = false\n[user]\n\tname = Ada\n"
        )
        #expect(withCore.contains("\tbare = false"))
        #expect(!withCore.contains("[user]"))
    }

    @Test("A subsectioned [user \"work\"] is somebody else's scope and is never edited")
    func subsectionsAreLeftAlone() {
        let original = "[user \"work\"]\n\tname = Work Ada\n\temail = ada@work.example\n"

        let written = GitConfigFile.applying(name: "Home Ada", email: "ada@home.example", to: original)

        #expect(written.contains("[user \"work\"]"))
        #expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched")
        #expect(written.contains("\temail = ada@work.example"))
        #expect(written.contains("[user]\n\tname = Home Ada"), "the plain section is what the fields write")
        // The plain section lands last, which is what keeps the writer and the take-the-last-value
        // reader agreeing about a file this app has written.
        #expect(GitConfigFile.identity(inConfigText: written).name == "Home Ada")
        #expect(GitConfigFile.identity(inConfigText: written).email == "ada@home.example")
    }

    /// The card's fourth done-when criterion: "the identity fields persist to repo-local config" —
    /// asserted where it matters, in the authorship of the next commit.
    @Test("The fields write repo-local config, and the next commit is authored by them")
    @MainActor
    func theFieldsReachTheTrail() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        await git.refreshIdentity()
        #expect(git.identityName.isEmpty, "a repository the app created carries no identity of ours")
        #expect(git.identityEmail.isEmpty)
        #expect(git.derivedIdentity != nil, "the placeholder has something to show")

        await git.writeIdentity(name: "Ada Lovelace", email: "ada@example.com")

        #expect(git.identityFailure == nil)
        #expect(git.identityName == "Ada Lovelace")
        #expect(git.identityEmail == "ada@example.com")
        // The setting *is* the file: read back off disk, not out of memory.
        let config = try String(contentsOf: fixture.root.appendingPathComponent(".git/config"), encoding: .utf8)
        #expect(config.contains("ada@example.com"))
        #expect(GitCommitOperation.userIdentity(at: fixture.root)
            == GitIdentity(name: "Ada Lovelace", email: "ada@example.com"))

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()

        // **The committer field, not the author.** The change above was made behind the app's back
        // — a test writing bytes is a foreign writer — so 06's attribution rules author it
        // `Lanework External` and record *this machine's user* as the committer, which is exactly the
        // field the popover's fields feed ("The committer field is always the user's identity").
        let head = try #require(GitRepository.headCommit(at: fixture.root))
        #expect(head.authorName == CommitAttribution.externalIdentity.name)
        let recorded = try #require(try {
            let repository = try Repository.open(at: fixture.root)
            return (try repository.HEAD.target as? Commit)?.committer
        }())
        #expect(recorded.name == "Ada Lovelace")
        #expect(recorded.email == "ada@example.com")
    }

    @Test("Clearing the fields returns the board to the derived default")
    @MainActor
    func clearingReturnsToTheDerivedDefault() async throws {
        let (fixture, git) = try await makeGitBoard()
        defer { fixture.tearDown() }

        await git.writeIdentity(name: "Ada Lovelace", email: "ada@example.com")
        await git.writeIdentity(name: "", email: "")

        #expect(git.identityName.isEmpty)
        #expect(git.identityEmail.isEmpty)
        let derived = try #require(git.derivedIdentity)
        #expect(GitCommitOperation.userIdentity(at: fixture.root) == derived)
        #expect(GitCommitOperation.repoLocalIdentity(at: fixture.root) == (nil, nil))
    }
}
