import Foundation
import Testing
@testable import Kanban

/// `BoardStore`'s correctness is almost entirely about *what survives what*: a failed reload must
/// not eat a good snapshot, a selection must not survive a liveness flip, a wholesale operation's
/// expectation must not be consumed by a walk that predates it. So these tests drive the store
/// through its one inbound door — `handleWatcherEvent(_:)` — against real boards in real temp
/// directories, and never through a `FolderWatcher` (which has its own suite, and whose FSEvents
/// timing would turn every assertion here into a race).
///
/// **No sleeps stand in for ordering.** `awaitQuiescence()` is how a test waits for the pipeline,
/// and the one test that needs a load pinned *open* mid-flight uses `BoardStore.loadBarrier` to pin
/// it rather than guessing at a duration. The single polling helper (`waitUntil`) waits for an
/// actor to report that a load has arrived at that barrier — a fact, not an elapsed time.

// MARK: - Fixtures

/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`; a board store needs
/// exactly what the writer suites needed — hand-written `index.md` files in a temp tree — so this
/// file borrows them rather than growing a third copy.

/// Frontmatter that opens and closes correctly but does not parse: an unclosed flow sequence. The
/// shape a non-atomic external write leaves behind when a reload catches it mid-flight, which is the
/// case 02-architecture.md § Live-reload resilience is written about.
private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n"

/// A card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a file the
/// user currently has selected.
private func tombstoned(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    deleted: 2026-03-03T09:00:00Z
    ---
    \(title) body.

    """
}

/// The board every test starts from: two lanes, two cards in the first, and one stray folder so
/// `loadWarnings` has something real to carry.
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
    try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
    try fixture.item("notes", "not a board item at all\n")
    return fixture
}

private func lane(_ id: String, in snapshot: BoardModel) -> Lane? {
    snapshot.lanes.first { $0.id.rawValue == id }
}

private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
    (lane(id, in: snapshot)?.cards ?? []).compactMap(\.title.value)
}

/// Relative paths as `BoardLoadError` reports them — root-relative, `/`-joined.
private func indexPath(_ components: String...) -> String {
    (components + ["index.md"]).joined(separator: "/")
}

// MARK: - Assertion helpers

/// The refusal-side twin of `WriterTestSupport.writeFailure`: runs `operation` expecting the store
/// to turn it away, and hands back the refusal for inspection.
@MainActor
@discardableResult
private func refusal(_ operation: () throws -> Void) -> BoardStoreWriteRefusal? {
    do {
        try operation()
        Issue.record("expected the store to refuse the write, but it ran")
        return nil
    } catch let error as BoardStoreWriteRefusal {
        return error
    } catch {
        Issue.record("expected a BoardStoreWriteRefusal, got \(error)")
        return nil
    }
}

/// Counts the bracket calls a store makes, standing in for the watcher the registry will wire up.
@MainActor
private final class BracketLog {
    private(set) var begins = 0
    private(set) var ends = 0

    /// Wired into a store the way the registry will wire its watcher.
    func attach(to store: BoardStore) {
        store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 })
    }
}

/// Holds a finished tree walk open until a test says otherwise, and reports when one has arrived.
///
/// This is what makes the "an in-flight walk never consumes the wholesale expectation" test a proof
/// rather than a coin flip: without it, whether the walk in question had already applied would
/// depend on how fast the disk was.
private actor LoadGate {
    private(set) var arrivals = 0
    private var isOpen = false
    private var waiters: [CheckedContinuation<Void, Never>] = []

    func arrive() async {
        arrivals += 1
        guard !isOpen else { return }
        await withCheckedContinuation { waiters.append($0) }
    }

    func open() {
        isOpen = true
        let pending = waiters
        waiters.removeAll()
        for waiter in pending {
            waiter.resume()
        }
    }
}

/// Polls until `condition` holds or the deadline passes. Used only to notice that a load has reached
/// the gate — the ordering itself is enforced by the gate, never by the interval.
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: @Sendable () async -> Bool) async {
    let start = ContinuousClock.now
    while ContinuousClock.now - start < deadline {
        if await condition() { return }
        try? await Task.sleep(for: .milliseconds(5))
    }
}

// MARK: - Tests

@MainActor
@Suite("BoardStore")
struct BoardStoreTests {

    // MARK: Opening

    @Test("A valid board loads its snapshot and its warnings at init")
    func initialLoadSucceeds() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let store = try BoardStore(rootURL: fixture.root)

        #expect(store.rootURL == fixture.root)
        #expect(store.snapshot.lanes.count == 2)
        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
        #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")))
        #expect(store.reloadFailure == nil)
        #expect(store.readOnlyLock == nil)
        #expect(!store.isReadOnly)
        #expect(store.selection == .empty)
    }

    @Test("A broken board throws at init rather than constructing a store — fail-fast")
    func initialLoadFailsFast() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, brokenIndex)

        // There is nothing to fall back on before the first load, so every later rule — the banner,
        // the lock, "a failed reload never replaces a good snapshot" — has no meaning here.
        do throws(BoardLoadFailure) {
            _ = try BoardStore(rootURL: fixture.root)
            Issue.record("expected the initial load to fail")
        } catch {
            #expect(error.primary.path == indexPath(Ident.lane1))
            if case .unparseableYAML = error.primary.reason {} else {
                Issue.record("expected unparseable YAML, got \(error.primary.reason)")
            }
        }
    }

    @Test("A store built from a pre-walked result is the store the walking init would have built")
    func prewalkedInitMatchesTheWalkingInit() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // The two inits the board window's loading state put side by side: the walk on this actor,
        // and the same walk run somewhere else and handed over (`BoardStoreRegistry.acquireOffMain`
        // runs it on a detached task). If these ever disagreed, an open would show a different board
        // depending on which actor walked it.
        let walkedHere = try BoardStore(rootURL: fixture.root)
        let result = try BoardLoader.load(boardRoot: fixture.root)
        let walkedElsewhere = BoardStore(rootURL: fixture.root, loaded: result)

        #expect(walkedElsewhere.rootURL == walkedHere.rootURL)
        #expect(walkedElsewhere.snapshot == walkedHere.snapshot)
        #expect(walkedElsewhere.loadWarnings == walkedHere.loadWarnings)
        #expect(walkedElsewhere.defects.count == walkedHere.defects.count)
        // The rest of the opening posture, which is what a second init could quietly get wrong.
        #expect(walkedElsewhere.reloadFailure == nil)
        #expect(walkedElsewhere.readOnlyLock == nil)
        #expect(!walkedElsewhere.isReadOnly)
        #expect(walkedElsewhere.selection == .empty)
        #expect(walkedElsewhere.reloadGeneration == 0)
    }

    // MARK: Reloading

    @Test("A foreign tree change reloads and the snapshot shows the external edit")
    func foreignChangeReloads() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Exactly what an agent or an editor does: a card folder appears, with no Writer and no
        // bracket anywhere near it.
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
        #expect(store.reloadFailure == nil)
    }

    @Test("A failed reload keeps the last good snapshot, and the next success heals it")
    func failedReloadKeepsTheSnapshot() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let lastGood = store.snapshot

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot")
        #expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
        #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it")

        // Transient breakage self-heals: the watcher kept watching and the fix arrives as an
        // ordinary reload.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fixed"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.reloadFailure == nil)
        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Fixed", "Second"])
    }

    @Test("An ordinary failed reload does not lock the board — editing continues around the breakage")
    func ordinaryFailureDoesNotLock() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()
        #expect(store.reloadFailure != nil)
        #expect(store.readOnlyLock == nil)
        #expect(!store.isReadOnly)

        // Per-file breakage: the snapshot still describes the tree, so a real write through the
        // Writer is safe and must go through.
        // The explicit closure signature is `performWrite`'s one ergonomic wart: with a non-`Void`
        // result, `T` and the closure's thrown type cannot both be inferred, and the thrown type
        // widens to `any Error`. See `performWrite`'s doc comment.
        let created = try store.performWrite { () throws(BoardWriteError) -> ItemID in
            try BoardWriter.createCard(inLane: fixture.url(Ident.lane2), title: "Filed anyway")
        }
        #expect(fixture.exists("\(Ident.lane2)/\(created.rawValue)/index.md"))
    }

    // MARK: Display-state write-through

    @Test("A successful reload calls the display-state delegate, whether or not anything actually changed")
    func successfulReloadCallsDisplayStateDelegate() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        var calls = 0
        store.displayStateDelegate = { calls += 1 }

        // An ordinary foreign change with no board-level field touched at all — the "did title,
        // icon, or iconColor actually change" comparison is the registry's own no-op guard
        // (`BoardRegistry.syncDisplayState`), not something this store decides by diffing itself.
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(calls == 1)
    }

    @Test("A failed reload does not call the display-state delegate — there is no new snapshot to report")
    func failedReloadDoesNotCallDisplayStateDelegate() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        var calls = 0
        store.displayStateDelegate = { calls += 1 }

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(calls == 0)
    }

    @Test("A reload with no display-state delegate wired is harmless")
    func reloadWithoutADisplayStateDelegateIsANoOp() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
    }

    // MARK: Wholesale operations

    @Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation")
    func wholesaleSuccessConsumesTheExpectation() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)

        try store.performWholesale {
            try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
        }
        #expect(brackets.begins == 1)
        #expect(brackets.ends == 1)

        store.handleWatcherEvent(.treeChanged(.appMediated))
        await store.awaitQuiescence()

        #expect(store.readOnlyLock == nil)
        #expect(store.reloadFailure == nil)
        #expect(store.snapshot.lanes.count == 3)

        // The expectation was consumed by that reload rather than left armed: an ordinary failure
        // afterwards is per-file breakage again, and must not lock.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.reloadFailure != nil)
        #expect(store.readOnlyLock == nil, "the wholesale expectation must not outlive the reload that consumed it")
    }

    @Test("A failed reload after a wholesale operation locks the board, refuses writes, and clears on the next success")
    func wholesaleFailureLocksAndHeals() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)
        let lastGood = store.snapshot

        // The shape of a branch switch that lands a tree this app cannot read: the snapshot on
        // screen now describes something that is not there any more.
        try store.performWholesale {
            try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
        }
        store.handleWatcherEvent(.treeChanged(.appMediated))
        await store.awaitQuiescence()

        #expect(store.readOnlyLock == .bracketedReloadFailed)
        #expect(store.isReadOnly)
        #expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
        #expect(store.snapshot == lastGood)

        // Writes are refused, and refused *before* the bracket opens — a refusal must not leave the
        // watcher suspended.
        var ran = false
        let refused = refusal { try store.performWrite { ran = true } }
        #expect(refused == .readOnlyLocked(.bracketedReloadFailed))
        #expect(!ran)
        #expect(brackets.begins == 1, "the refusal opened no bracket")
        #expect(brackets.ends == 1)

        // A locked board refuses to *start* wholesale work too.
        refusal { try store.performWholesale { ran = true } }
        #expect(!ran)

        // Reading stays live throughout — keeping the last-good snapshot is the point of the lock.
        store.select([ItemID(rawValue: Ident.card2)], in: .board)
        #expect(store.selection.ids.count == 1)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.readOnlyLock == nil, "the next successful reload clears both the banner and the lock")
        #expect(store.reloadFailure == nil)
        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Repaired", "Second"])

        try store.performWrite { ran = true }
        #expect(ran)
    }

    @Test("The wholesale expectation binds to the walk started after it, never to one already in flight")
    func wholesaleExpectationSurvivesAnInFlightLoad() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let gate = LoadGate()
        store.loadBarrier = { await gate.arrive() }

        // Walk 1 reads the tree while it is still valid, then parks before applying.
        store.handleWatcherEvent(.treeChanged(.foreign))
        await waitUntil { await gate.arrivals >= 1 }

        // Broken only now — walk 1 is past its read, walk 2 is not.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)

        // Armed while walk 1 is demonstrably still in flight. A boolean flag would be consumed by
        // walk 1's *successful* application and would leave walk 2's failure unlocked.
        try store.performWholesale {}
        store.handleWatcherEvent(.treeChanged(.appMediated))

        await gate.open()
        await store.awaitQuiescence()

        #expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed")
        #expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
        #expect(store.readOnlyLock == .bracketedReloadFailed)
    }

    // MARK: Selection across reloads

    /// The rules themselves belong to `ItemReferenceSet`/`TransientBoardState` and are proved in
    /// `TransientBoardStateTests` across all three referencing sets. These stay because what they
    /// pin here is the *store's* half: that `land(_:generation:origin:)` re-resolves on success and
    /// only on success, read through the `selection`/`select`/`clearSelection` conveniences the
    /// command sites use.

    @Test("A selected item that vanished from the tree leaves the selection; the survivor stays")
    func selectionDropsVanishedMembers() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
        #expect(store.selection.container == .board)
    }

    @Test("A container crossing is a vanish: a selected card trashed externally leaves the selection")
    func selectionEjectsAContainerCrossing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)

        try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        // Still in the snapshot — the trash renders it — but no longer in the selection's container,
        // so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule,
        // resettled 2026-07-28: "re-resolution matches UUID *and* container side").
        #expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1])
        #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
    }

    @Test("Deleting a lane externally takes its cards out of the selection with it")
    func selectionEjectsCardsUnderARemovedLane() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)

        // A lane delete is physical now (03-board-ui.md § Trash), so the cards genuinely go with it
        // — no ancestor walk needed, and none left to do: presence is the whole test.
        try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil)
        #expect(store.selection.ids.isEmpty)
    }

    @Test("A selection can resolve to nothing, and nothing is invented to replace it")
    func selectionCanResolveToNothing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(store.selection.ids.isEmpty)
        #expect(store.selection.container == .board, "the container survives even when the membership does not")

        store.select([ItemID(rawValue: Ident.lane1)], in: .board)
        store.clearSelection()
        #expect(store.selection == .empty)
    }

    // MARK: Coalescing

    @Test("A burst of signals during one walk coalesces into exactly one follow-up")
    func signalBurstCoalescesIntoOneFollowUp() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
        // No `await` between them, so the main actor never yields and no result can land in the
        // middle: the first signal starts a walk, the other four fold into one pending reload.
        for _ in 0..<5 {
            store.handleWatcherEvent(.treeChanged(.foreign))
        }
        await store.awaitQuiescence()

        #expect(store.reloadGeneration == 2, "five signals, one walk plus one follow-up")
        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
        #expect(store.reloadFailure == nil)
    }

    // MARK: Brackets

    @Test("performWrite brackets exactly once, whether the operation returns or throws")
    func performWriteBracketsExactlyOnce() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)

        var ran = false
        try store.performWrite { ran = true }
        #expect(ran)
        #expect(brackets.begins == 1)
        #expect(brackets.ends == 1)

        // A Writer operation that fails partway has still touched disk, so the bracket has to close
        // on the throwing path too — an unbalanced one would suspend the watcher for the session.
        let boom = BoardWriteError(operation: .style(title: nil), path: "/nowhere", reason: .io(message: "disk full"))
        do {
            try store.performWrite { () throws(BoardWriteError) -> Void in throw boom }
            Issue.record("expected the operation's own error to propagate")
        } catch let error as BoardWriteError {
            #expect(error == boom, "the store rethrows the Writer's error untouched")
        } catch {
            Issue.record("expected a BoardWriteError, got \(error)")
        }
        #expect(brackets.begins == 2)
        #expect(brackets.ends == 2)
    }

    // MARK: Root changes

    @Test("A root change with no delegate wired leaves the last good snapshot alone")
    func rootChangeWithoutADelegateIsANoOp() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let lastGood = store.snapshot

        // Re-resolve-or-lock needs a bookmark this store does not own, so the response is
        // delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no
        // delegate, the honest answer is the same one every failure path gives: keep the last good
        // snapshot. Guessing here would lock a board that had merely moved.
        store.handleWatcherEvent(.rootChanged)
        await store.awaitQuiescence()

        #expect(store.snapshot == lastGood)
        #expect(store.reloadFailure == nil)
        #expect(store.readOnlyLock == nil)
        #expect(store.reloadGeneration == 0, "a root change schedules no reload of its own")
    }

    @Test("A root change with a delegate hands over and starts no reload of its own")
    func rootChangeIsDelegated() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        var calls = 0
        store.rootChangeDelegate = { calls += 1 }
        store.handleWatcherEvent(.rootChanged)
        await store.awaitQuiescence()

        #expect(calls == 1)
        // The delegate's two outcomes both end in a reload — at the re-resolved root, or on the
        // root's return. One fired from here would walk a path that just stopped being the board.
        #expect(store.reloadGeneration == 0)
    }
}

// MARK: - The parse memo and the value-equal skip

/// **"The walk memoizes its parse, never its result"** and **"the store skips the assignment entirely
/// when the fresh snapshot equals the current one"** (02-architecture.md § Live-reload resilience,
/// both blessed 2026-07-31).
///
/// The loader's half is pinned in `BoardLoaderParseMemoTests` and over the golden corpus in
/// `FixtureMemoEquivalenceTests`; what only this suite can state is the *wiring* — that the store
/// hands each walk the one before it (`BoardStore.parseCounter` is the seam that makes "re-parsed
/// nothing" observable at all), and that the skip is a skip of the observable assignment and of
/// nothing else the landing owes.
@MainActor
@Suite("BoardStore ▸ the parse memo and the value-equal skip")
struct BoardStoreReloadMemoTests {

    /// Every `index.md` `makeBoard()` puts in the walk's way: the root, two lanes, two cards. The
    /// stray `notes/` folder holds one too and is deliberately not counted — a non-UUID-shaped folder
    /// is never descended into, so the loader never opens it, memo or no memo.
    static let indexCount = 5

    /// One reload through the store's one inbound door, with a counter attached.
    private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async -> BoardLoader.ParseCounter.Counts {
        let counter = BoardLoader.ParseCounter()
        store.parseCounter = counter
        store.handleWatcherEvent(.treeChanged(origin))
        await store.awaitQuiescence()
        store.parseCounter = nil
        return counter.counts
    }

    @Test("A reload of an untouched tree opens no index.md and assigns no snapshot")
    func anUnchangedTreeCostsNothing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = store.snapshot

        let counts = await reload(store)

        #expect(counts == .init(parsed: 0, reused: Self.indexCount))
        // The skip proper: no assignment, so no `@Observable` churn and no render pass.
        #expect(store.snapshotGeneration == 0)
        #expect(store.snapshot == before)
        // And the landing still happened — the bookkeeping the skip must never cover.
        #expect(store.landedReloads == 1)
        #expect(store.reloadFailure == nil)
    }

    @Test("A single-file echo re-parses exactly that file, and does assign")
    func oneEditedFileIsTheOnlyOneReParsed() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Renamed"))
        let counts = await reload(store)

        #expect(counts == .init(parsed: 1, reused: Self.indexCount - 1))
        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Renamed", "Second"])
        #expect(store.snapshotGeneration == 1)
        #expect(store.landedReloads == 1)
    }

    @Test("The memo carries from reload to reload rather than going cold every other walk")
    func theMemoChains() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Three landings: one value-equal (which skips the assignment), one that changes a file, and
        // one value-equal again. If the skipped landing dropped its memo, the walk after it would go
        // cold — which is exactly the bug this pins.
        #expect(await reload(store) == .init(parsed: 0, reused: Self.indexCount))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Edited"))
        #expect(await reload(store) == .init(parsed: 1, reused: Self.indexCount - 1))
        #expect(await reload(store) == .init(parsed: 0, reused: Self.indexCount))

        #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Edited"])
        #expect(store.snapshotGeneration == 1, "only the middle reload had anything to assign")
        #expect(store.landedReloads == 3)
        #expect(store.parseMemo.count == Self.indexCount)
    }

    @Test("A failed reload keeps the snapshot and bumps neither counter")
    func aFailedReloadBumpsNothing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = store.snapshot

        try fixture.item(Ident.lane1, brokenIndex)
        _ = await reload(store)

        #expect(store.snapshot == before, "a failed reload never replaces a good snapshot")
        #expect(store.snapshotGeneration == 0)
        #expect(store.landedReloads == 0, "there is no snapshot in hand, so nothing was covered")
        #expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1))
    }

    @Test("A failed reload's breakage clears on the next success, which is value-equal")
    func breakageClearsOnAValueEqualSuccess() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        try fixture.item(Ident.lane1, brokenIndex)
        _ = await reload(store)
        #expect(store.reloadFailure != nil)

        // Repaired to exactly what it was: the model comes back value-equal, the assignment is
        // skipped — and the standing breakage condition must still heal, because it is not board
        // structure and is not what the skip covers.
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        _ = await reload(store)

        #expect(store.reloadFailure == nil)
        #expect(store.snapshotGeneration == 0, "the repaired tree is the tree that was already on screen")
        #expect(store.landedReloads == 1)
    }

    // MARK: What the skip must never cover

    @Test("Warnings follow the tree even when the model does not move")
    func warningsFollowTheTree() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // A second non-UUID-shaped folder: a stray is not in the model, so this changes `loadWarnings`
        // and nothing else. It is also never descended into, so the walk still opens no file.
        try fixture.item("scratch", "not a board item at all\n")
        let counts = await reload(store)

        #expect(counts == .init(parsed: 0, reused: Self.indexCount))
        #expect(store.snapshotGeneration == 0)
        #expect(store.landedReloads == 1)
        #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "scratch")))
        #expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")))
    }

    @Test("Defects follow the tree even when the model does not move")
    func defectsFollowTheTree() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        #expect(store.looseCardFiles.isEmpty)

        // A loose file beside an untouched `index.md`: pending work the walk found, with the snapshot
        // value-equal on either side of it. Defects are what `runScheduledHeals` reads, so a skip
        // that swallowed them would silently retire the heal engine on quiet boards.
        try "loose".write(
            to: fixture.url("\(Ident.lane1)/\(Ident.card1)").appendingPathComponent("notes.txt"),
            atomically: true,
            encoding: .utf8
        )
        _ = await reload(store)

        #expect(store.snapshotGeneration == 0)
        #expect(store.landedReloads == 1)
        #expect(store.looseCardFiles.map(\.fileNames) == [["notes.txt"]])
    }

    @Test("An attachment arriving moves the snapshot, because the snapshot carries the listing")
    func attachmentsAreNotMemoized() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let attachments = fixture.url("\(Ident.lane1)/\(Ident.card1)")
            .appendingPathComponent("attachments", isDirectory: true)
        try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
        try "png".write(to: attachments.appendingPathComponent("shot.png"), atomically: true, encoding: .utf8)

        let counts = await reload(store)

        // Not one file opened, and the card's paperclip is still current: directory enumeration is
        // outside the memo's scope by design, because an attachment never touches `index.md`.
        #expect(counts == .init(parsed: 0, reused: Self.indexCount))
        #expect(lane(Ident.lane1, in: store.snapshot)?.cards.first?.attachments == ["shot.png"])
        #expect(store.snapshotGeneration == 1)
        #expect(store.landedReloads == 1)
    }

    @Test("A no-change reload announces nothing and leaves no state behind")
    func aNoChangeReloadIsSilent() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        var spoken: [String?] = []
        store.announce = { spoken.append($0) }
        _ = await reload(store)
        _ = await reload(store, .reconciling)

        #expect(spoken == [nil, nil])
        #expect(store.readOnlyLock == nil)
        #expect(store.snapshotGeneration == 0)
        #expect(store.landedReloads == 2)
    }

    @Test("A value-equal reload still lands for the covering gate")
    func aValueEqualReloadStillLands() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        _ = await reload(store)

        // The covering gate counts landings, not assignments: a walk covers what it walked whether or
        // not the tree turned out to differ.
        #expect(store.landedReloads == 1)
        #expect(store.snapshotGeneration == 0)
    }
}
