import Foundation
import Testing
@testable import Kanban

/// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses
/// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced
/// schema; 05-card-window.md ▸ The comments column; 13-native-undo.md).
///
/// Assertions are against the **bytes on disk**, like every other write suite here — a comment that
/// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from
/// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from
/// `CommentThreadTests.swift`.

// MARK: - Helpers

private let cardTitle = "Fix login"

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

/// The one comment folder in a thread, as a path — the posted identity is minted, so a test that
/// posts has to find it rather than name it.
private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] {
    try fixture.entryNames("\(card)/comments")
        .filter { IntegrityRules.isIdentityShaped($0) }
        .sorted()
}

@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
    let store = try BoardStore(rootURL: fixture.root)
    let history = NativeHistoryProvider()
    store.history = history
    return (store, history)
}

// MARK: - The draft

@Suite("Comments ▸ draft lifecycle")
struct CommentDraftTests {

    @Test("The first save creates .draft with the comment schema, no title and no order")
    func createStampsTheSchema() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        let outcome = try BoardWriter.saveCommentDraft(
            inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle
        )
        #expect(outcome == .created)

        let draft = "\(card)/comments/.draft"
        let text = try fixture.indexText(draft)
        let parsed = try FrontmatterDocument.parse(text)
        #expect(parsed.schema.value == 1)
        #expect(parsed.kind.value == "comment")
        #expect(parsed.author.value == NSFullUserName())
        #expect(parsed.created.value != nil)
        // One `Date` for both stamps, so a draft never reads as edited before it is posted.
        #expect(parsed.created.value == parsed.modified.value)
        #expect(parsed.title.isMissing, "a comment has no title")
        #expect(parsed.order.isMissing, "a comment has no order")
        #expect(parsed.body == "Half a thought")
        #expect(text.hasPrefix("---\nschema: 1\n"))
    }

    @Test("An update replaces the body span and stamps modified; author and created stay")
    func updateWritesTheBody() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let draft = "\(card)/comments/.draft"
        try fixture.item(draft, commentText())

        let outcome = try BoardWriter.saveCommentDraft(
            inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle
        )
        #expect(outcome == .updated)

        let parsed = try document(fixture, draft)
        #expect(parsed.body == "Second thought\n")
        #expect(parsed.author.value == "Ada Lovelace")
        #expect(parsed.created.value == parsed.created.value)
        #expect(parsed.modified.value != parsed.created.value)
        // The unknown key, its inline comment and its position are untouched.
        #expect(try fixture.indexText(draft).contains("project: lanework       # agent overlay"))
    }

    @Test("Identical bytes write nothing at all — the ~30 s tick must not stamp")
    func unchangedWritesNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let draft = "\(card)/comments/.draft"
        try fixture.item(draft, commentText(body: "Same\n"))
        let before = try fixture.indexData(draft)
        let stamp = try FileManager.default
            .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date

        let outcome = try BoardWriter.saveCommentDraft(
            inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle
        )
        #expect(outcome == .unchanged)
        #expect(try fixture.indexData(draft) == before)
        let after = try FileManager.default
            .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
        #expect(after == stamp)
    }

    @Test("A draft emptied of text with no attachments deletes its folder — never litter")
    func emptiedDraftIsDeleted() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n"))

        let outcome = try BoardWriter.saveCommentDraft(
            inCard: fixture.url(card), body: "   \n\n", cardTitle: cardTitle
        )
        #expect(outcome == .deleted)
        #expect(!fixture.exists("\(card)/comments/.draft"))
        #expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it")
    }

    @Test("A draft emptied of text but holding attachments survives")
    func emptiedDraftWithAttachmentsSurvives() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let draft = "\(card)/comments/.draft"
        try fixture.item(draft, commentText(body: "typed\n"))
        try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))

        let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
        #expect(outcome == .updated)
        #expect(try document(fixture, draft).body == "")
        #expect(fixture.exists("\(draft)/attachments/shot.png"))
    }

    @Test("An empty save against no draft creates nothing")
    func emptySaveCreatesNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
        #expect(outcome == .unchanged)
        #expect(!fixture.exists("\(card)/comments"))
    }

    @Test("An interrupted create — a draft folder with no index.md — is filled in, not refused")
    func indexlessDraftIsFilledIn() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try FileManager.default.createDirectory(
            at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true
        )

        #expect(try BoardWriter.saveCommentDraft(
            inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle
        ) == .created)
        #expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n")
    }

    @Test("A draft is not a card write: only a card folder takes one")
    func refusesNonCards() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try makeCommentBoard(fixture)

        let failure = writeFailure {
            try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil)
        }
        #expect(failure?.operation == .saveCommentDraft(title: nil))
    }
}

// MARK: - Posting

@Suite("Comments ▸ post")
struct CommentPostTests {

    @Test("Posting renames the draft to a fresh lowercase identity and restamps both dates")
    func postRenamesAndRestamps() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let draft = "\(card)/comments/.draft"
        try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z"))
        try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
        let draftBody = try document(fixture, draft).body

        let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)

        #expect(!fixture.exists(draft))
        #expect(posted.id.rawValue == posted.id.rawValue.lowercased())
        #expect(IntegrityRules.isIdentityShaped(posted.id.rawValue))
        #expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue])

        let path = "\(card)/comments/\(posted.id.rawValue)"
        let parsed = try document(fixture, path)
        #expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two")
        #expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true)
        #expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000))
        // Everything else is the draft's own bytes: the body, the author, the unknown key and its
        // inline comment, and the attachment that rode the rename.
        #expect(parsed.body == draftBody)
        #expect(parsed.author.value == "Ada Lovelace")
        #expect(try fixture.indexText(path).contains("project: lanework       # agent overlay"))
        #expect(fixture.exists("\(path)/attachments/shot.png"))
    }

    @Test("A posted comment joins the thread; the draft no longer does")
    func postedCommentIsInTheThread() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())

        let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id) == [posted.id])
        #expect(!thread.hasDraft)
    }

    @Test("Posting nothing refuses, naming the card")
    func postWithoutADraftRefuses() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) }
        #expect(failure?.operation == .postComment(title: cardTitle))
    }

    @Test("A second post mints a second identity beside the first")
    func twoPostsAreTwoComments() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item("\(card)/comments/.draft", commentText(body: "one\n"))
        let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
        try fixture.item("\(card)/comments/.draft", commentText(body: "two\n"))
        let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)

        #expect(first.id != second.id)
        #expect(try postedNames(fixture, inCard: card).count == 2)
    }
}

// MARK: - Editing

@Suite("Comments ▸ edit")
struct CommentEditTests {

    @Test("An edit writes the body and stamps modified — '· edited' falls out of the pair")
    func editStampsModified() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())

        #expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle))

        let parsed = try document(fixture, path)
        #expect(parsed.body == "revised\n")
        #expect(parsed.created.value != parsed.modified.value)
        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.first?.isEdited == true)
    }

    @Test("An unchanged body writes nothing")
    func unchangedEditWritesNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText(body: "same\n"))
        let before = try fixture.indexData(path)

        #expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false)
        #expect(try fixture.indexData(path) == before)
    }

    @Test("Only a posted comment is editable — never the draft, never a trashed one")
    func refusesTheTwoDotFolders() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())

        for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] {
            let failure = writeFailure {
                _ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle)
            }
            #expect(failure?.operation == .editComment(title: cardTitle))
        }
    }
}

// MARK: - Delete, restore, purge

@Suite("Comments ▸ delete and purge")
struct CommentDeleteTests {

    @Test("A delete is a move into comments/.trash that stamps and clears modified-by")
    func deleteMovesAndStamps() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n")

        let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle)
        #expect(id.rawValue == CommentIdent.one)
        #expect(!fixture.exists(path))

        let trashed = "\(card)/comments/.trash/\(CommentIdent.one)"
        let parsed = try document(fixture, trashed)
        #expect(parsed.modified.value != parsed.created.value, "a container change stamps")
        #expect(parsed.modifiedBy.isMissing)
        #expect(parsed.author.value == "Ada", "author is content and survives even this")
        #expect(parsed.body == "body\n")
    }

    @Test("A deleted comment leaves the thread")
    func deletedIsExcluded() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z"))

        try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle)
        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two])
    }

    @Test("Restoring is the ordinary move back out, and stamps again")
    func restoreMovesBack() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())

        try BoardWriter.restoreComment(
            ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle
        )
        #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)))
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
        #expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body
            == "A comment body — with *markdown*.\n")
    }

    @Test("The purge removes the entries, keeps strays, and leaves the container")
    func purgeRemovesEntriesOnly() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8))

        let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card))
        #expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two])
        #expect(fixture.exists("\(card)/comments/.trash"))
        #expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8))
    }

    @Test("A card with no thread trash purges nothing")
    func purgeIsTotal() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        #expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty)
    }
}

// MARK: - The crash-residue memo

@MainActor
@Suite("Comments ▸ crash residue")
struct CommentResidueTests {

    @Test("Residue left by a crashed session sweeps at the next open, and the memo clears")
    func residueSweeps() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
        let (store, _) = try makeStore(fixture)

        store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))

        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
        #expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success")
    }

    @Test("Content a live step still backs is not residue — the sweep asks the stack first")
    func aLiveStepsBackingIsNotResidue() throws {
        // 13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31: "`comments/.trash/`
        // content referenced by a live coarse step on the board stack is a step's backing, not
        // residue — the open-time sweep consults the stack and skips owned content". The gate is over
        // *steps*, not over the coarse one by name, so the shortest way to hold a live step is the
        // window-less delete (`deleteComment`'s `nil` window, which is the board's own stack).
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let (store, history) = try makeStore(fixture)
        let cardID = ItemID(rawValue: Ident.card1)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
        store.sweepCommentTrashResidue(inCard: cardID)

        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the delete step's undo is the move back out — this is its backing")
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
                "and the entry no step names swept as before")

        history.undo()
        #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so ⌘Z still has something to restore")
    }

    @Test("An open with nothing to sweep rests — no bracket, no memo")
    func cleanOpenRests() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try makeCommentBoard(fixture)
        let (store, _) = try makeStore(fixture)

        store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
        #expect(store.heals.memo(for: .commentTrashResidue) == nil)
    }

    @Test("The close purge and the residue sweep converge on the same disk state")
    func purgeAndSweepAgree() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
        let (store, _) = try makeStore(fixture)

        store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
    }
}

// MARK: - The purge's ownership gate

/// **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (13-native-undo.md
/// ▸ Interaction with the trash, ruled 2026-08-06):
///
/// > "a step's retirement and a no-step close remove only entries no live step still backs — the same
/// > `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The
/// > container-whole purge assumed one owning step per card's comment trash, and two sessions over the
/// > same card broke it: the second step's retirement — or a mere reopen-and-close that registered
/// > nothing — emptied the first step's backing out from under it, silently killing an undo the stack
/// > still promised."
///
/// The sweep's own half of that condition is `CommentResidueTests` above; the close-and-retirement
/// arcs through a real card window are `CardSessionUndoTests`'. What is here is the gate itself, at
/// the one method the ruling retired the container-whole behaviour from.
@MainActor
@Suite("Comments ▸ the purge's ownership gate")
struct CommentPurgeGateTests {

    private let cardID = ItemID(rawValue: Ident.card1)

    /// One coarse close step's shape, as the fold registers it: a backing claim over one trashed
    /// comment, and a retirement that runs the deferred purge.
    private func coarseStep(
        holding commentID: String,
        purging store: BoardStore,
        inCard id: ItemID
    ) -> HistoryStep {
        HistoryStep(
            name: "Changes to '\(cardTitle)'",
            backing: [.trashedComment(ItemID(rawValue: commentID), inCard: id)],
            retirement: HistoryStep.Retirement { store.purgeCommentTrash(inCard: id) },
            undo: { _ in .applied },
            redo: { _ in .applied }
        )
    }

    @Test("A purge removes what no step names and spares what one does")
    func thePurgeIsPartitionedByOwnership() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        // Seeded rather than deleted: a previous session's leftover, which nothing on this stack names.
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        // The stack is bound, not discarded: `BoardStore.history` is weak, so a provider nobody holds
        // is a board with no backing at all.
        let (store, history) = try makeStore(fixture)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
        #expect(history.canUndo)
        store.purgeCommentTrash(inCard: cardID)

        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "a live step backs this one")
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "and nothing backs this one")
    }

    @Test("A retirement's purge leaves another live step's backing standing")
    func aRetirementSparesTheOtherStepsBacking() throws {
        // The defect the ruling names, in the shape it takes on disk: two sessions over one card, each
        // leaving a coarse step holding its own entry. The first step retiring must not empty the
        // second's backing out from under it.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let (store, history) = try makeStore(fixture)

        let first = coarseStep(holding: CommentIdent.one, purging: store, inCard: cardID)
        history.register(first)
        // Undone-and-superseded is the clean exit the ruling names, and the only one that retires
        // exactly one step: the undo puts `first` on the redo stack, and registering the second
        // session's step clears it — retiring `first` while `second` is already live.
        history.undo()
        history.register(coarseStep(holding: CommentIdent.two, purging: store, inCard: cardID))

        #expect(!first.retirement!.isOwed, "the superseded step retired, and its purge ran")
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "its own backing went with it")
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
                "and the other session's backing is still there for its ⌘Z")
    }

    @Test("An open card window owns its card's comment trash — the purge defers entirely")
    func anOpenWindowDefersThePurge() throws {
        // The carve-out (ruled 2026-08-06): "an open card window is itself an owner of its card's
        // comment trash ... because entries deleted in the live session are backed by the window's
        // fine steps, which the board-stack inventory cannot see". Total, not per entry — an unowned
        // leftover defers with the rest, because while a window is open the inventory is silent
        // rather than merely incomplete.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let (store, _) = try makeStore(fixture)

        store.cardWindowDidOpen(inCard: cardID)
        store.purgeCommentTrash(inCard: cardID)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred to the window's close")

        store.cardWindowDidClose(inCard: cardID)
        store.purgeCommentTrash(inCard: cardID)
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "which settles it by the same gate")
    }

    @Test("Only the open card's trash defers — another card's purge is untouched")
    func theDeferralIsPerCard() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        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: cardTitle))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Ship it"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)", commentText())
        try fixture.item("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)", commentText())
        let (store, _) = try makeStore(fixture)

        store.cardWindowDidOpen(inCard: cardID)
        store.purgeCommentTrash(inCard: cardID)
        store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card2))

        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)"))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)"))
    }

    /// One card window over the fixture's card, wired exactly as `CardWindowHost` wires one — the two
    /// `static` configure calls, plus the marking its `openCommentThread` does beside the sweep.
    private func openWindow(_ fixture: WriterFixture, on store: BoardStore, card: String) throws -> CardWindowSession {
        let session = CardWindowSession()
        CardWindowHost.configureUndo(session, store: store, cardID: cardID)
        CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
        session.comments.isEditable = true
        session.comments.cardFolder = fixture.url(card)
        session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
        session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(card)).body)
        store.cardWindowDidOpen(inCard: cardID)
        session.comments.open()
        return session
    }

    @Test("The close gives up ownership before it purges — its own purge is never self-deferred")
    func theCloseUnmarksBeforeItPurges() async throws {
        // The ordering the carve-out lives or dies on (`CardWindowSession.endSession`): the no-step
        // close runs `purgeTrashNow()` itself, so a window that unmarked *after* that call would defer
        // its own purge into a no-op and hand the work to nobody.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let (store, history) = try makeStore(fixture)
        let session = try openWindow(fixture, on: store, card: card)

        // A crashed sibling's leftover, landing after this window's own open-time sweep had run: the
        // window owns it while it is open, and owes it at the close.
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        store.purgeCommentTrash(inCard: cardID)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred while the window is open")

        await session.endSession()

        #expect(!history.canUndo, "nothing net happened — no coarse step took the purge on")
        #expect(store.openCardWindows.isEmpty, "the close gave the ownership back, first")
        #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "so the close's own purge ran")
    }

    @Test("A live session's delete survives a purge the board stack cannot see it backing")
    func aLiveSessionsDeleteSurvivesAForeignPurge() async throws {
        // Why the carve-out has to be total: the delete's step is on the *window's* stack, so
        // `backedContent` — the board's — genuinely names nothing, and an ungated purge would remove
        // the folder the window's own ⌘Z restores from. The close then settles it by the same gate:
        // the coarse step becomes the owner, and the entry stays for as long as that step does.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        let (store, history) = try makeStore(fixture)
        let session = try openWindow(fixture, on: store, card: card)

        session.comments.reload()
        session.comments.delete(ItemID(rawValue: CommentIdent.one))
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
        #expect(!history.canUndo, "the fine step is the window's, not the board's")

        // Another card's retirement, firing while this window is open.
        store.purgeCommentTrash(inCard: cardID)
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the open window owns it — the board's inventory could not have known")

        await session.endSession()

        #expect(history.canUndo, "the close folded the session into the coarse step")
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "which is now the owner, by the same gate")
        history.undo()
        #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so the coarse ⌘Z restores it")
    }
}

// MARK: - Undo

@MainActor
@Suite("Comments ▸ undo")
struct CommentUndoTests {

    private func board() throws -> (fixture: WriterFixture, card: String) {
        let fixture = try WriterFixture()
        let card = try makeCommentBoard(fixture)
        return (fixture, card)
    }

    @Test("Posting registers one step, named for the 06 verb family")
    func postRegistersOneStep() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        try fixture.item("\(card)/comments/.draft", commentText())
        let (store, history) = try makeStore(fixture)

        _ = store.postComment(inCard: ItemID(rawValue: Ident.card1))
        #expect(history.canUndo)
        #expect(history.undoActionName == "Comment")
    }

    @Test("Undoing a post renames back to .draft; redo replays the same identity and instant")
    func postRoundTrip() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n"))
        let (store, history) = try makeStore(fixture)

        let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1))
        let id = try #require(posted)
        let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)")

        history.undo()
        #expect(!fixture.exists("\(card)/comments/\(id.rawValue)"))
        #expect(fixture.exists("\(card)/comments/.draft"))
        // A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched.
        #expect(try fixture.indexText("\(card)/comments/.draft") == postedText)

        history.redo()
        #expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint")
        #expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped")
    }

    @Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers")
    func postUndoSkipsWhenADraftIsBack() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        try fixture.item("\(card)/comments/.draft", commentText(body: "first\n"))
        let (store, history) = try makeStore(fixture)

        let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1)))
        // A new draft, the way the composer would leave one.
        try fixture.item("\(card)/comments/.draft", commentText(body: "second\n"))
        let untouched = try fixture.indexText("\(card)/comments/.draft")

        history.undo()

        #expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten")
        #expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted")
        #expect(!history.canUndo, "the stale step was popped")
        #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
    }

    @Test("Deleting registers one step; undo moves it back and redo moves it in")
    func deleteRoundTrip() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let (store, history) = try makeStore(fixture)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
        #expect(history.undoActionName == "Delete Comment")
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))

        history.undo()
        #expect(fixture.exists(path))
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
        #expect(try document(fixture, path).body == "A comment body — with *markdown*.\n")

        history.redo()
        #expect(!fixture.exists(path))
        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
    }

    @Test("A purge spares what a live step backs — under the gate it cannot stale one")
    func purgeSparesALiveStepsBacking() throws {
        // The flip of what this test used to pin (13-native-undo.md ▸ Interaction with the trash,
        // ruled 2026-08-06): "**Every purge of `comments/.trash/` is per-entry behind the ownership
        // gate** ... Under the gate a purge cannot stale a live step by construction." The delete's
        // own step is the live one — its undo is the move back out of `comments/.trash/`, which is
        // exactly the backing claim `backedContent` inventories.
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let (store, history) = try makeStore(fixture)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
        store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))

        #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
                "the purge asked the stack first — this is a step's backing, not residue")
        #expect(history.canUndo)

        history.undo()
        #expect(fixture.exists(path), "so ⌘Z still restores the comment")
        #expect(store.banners.signposts.isEmpty, "and nothing was stale, so nothing was said")
    }

    @Test("A hand-removed trash entry does stale the delete step — lazily, with the banner")
    func aForeignRemovalStalesTheDeleteStep() throws {
        // Lazy invalidation is unchanged (13 ▸ Rules); what changed on 2026-08-06 is that the app's
        // own purge is no longer a source of it. So the staleness has to come from somewhere genuinely
        // foreign — a hand-editor emptying `comments/.trash/` in Finder, which is what this simulates.
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        let (store, history) = try makeStore(fixture)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
        try FileManager.default.removeItem(at: fixture.url("\(card)/comments/.trash/\(CommentIdent.one)"))
        #expect(history.canUndo, "invalidation is lazy — the stack still looks full")

        history.undo()
        #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)), "resurrecting nothing")
        #expect(!history.canUndo)
        #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
    }

    @Test("The draft save, the inline edit and the purge register nothing")
    func theThreeSilentOperations() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
        let (store, history) = try makeStore(fixture)
        let cardID = ItemID(rawValue: Ident.card1)

        store.saveCommentDraft(inCard: cardID, body: "typing\n")
        #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n"))
        store.purgeCommentTrash(inCard: cardID)

        #expect(!history.canUndo, "no byte capture on the undo stack, anywhere — 13's rule")
        // The gate did not make the purge inert: nothing on the stack names this entry, so it goes —
        // per entry, and still without a step to show for it.
        #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"))
    }

    @Test("Comment expectations validate disk, which is why comments need no snapshot")
    func expectationsReadDisk() throws {
        let (fixture, card) = try board()
        defer { fixture.tearDown() }
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())

        // Path anchors resolve to themselves — the resolver a caller with no board hands in
        // (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'.
        #expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
        #expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath))
        try FileManager.default.removeItem(at: fixture.url(path))
        #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
    }
}

// MARK: - Copy boundaries

@Suite("Comments ▸ copy boundaries")
struct CommentCopyTests {

    /// A card with a full thread: one posted comment carrying a tracker key and an attachment, a
    /// draft, and one comment sitting in the thread's trash.
    private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String {
        let path = "\(lane)/\(card)"
        try fixture.item(path, Item.rich(order: "1024", title: "Fix login"))
        try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42"))
        try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8))
        try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n"))
        try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText())
        return path
    }

    @Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash")
    func itemCopyMatrix() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)

        let copy = try BoardWriter.copyItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            toParent: fixture.url(Ident.lane2),
            order: 1024,
            stamps: .fork
        )
        let copied = "\(Ident.lane2)/\(copy.rawValue)"

        // The thread came, reminted.
        let names = try postedNames(fixture, inCard: copied)
        #expect(names.count == 1)
        #expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder")
        #expect(names[0] == names[0].lowercased())

        // The contract applied at comment depth.
        let parsed = try document(fixture, "\(copied)/comments/\(names[0])")
        #expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed")
        #expect(parsed.author.value == "Ada Lovelace")
        #expect(parsed.created.value != nil, ".fork keeps created")
        #expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png"))

        // The draft carries, fork-lossless; the trash does not travel.
        #expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n")
        #expect(!fixture.exists("\(copied)/comments/.trash"))

        // The source is untouched, thread trash included.
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)"))
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)"))
    }

    @Test("A copied lane's cards' threads are reminted too — arbitrary depth")
    func laneCopyReachesCommentDepth() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)

        let copy = try BoardWriter.copyItem(
            at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork
        )
        let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) }
        #expect(cards.count == 1)
        let copiedCard = "\(copy.rawValue)/\(cards[0])"
        let comments = try postedNames(fixture, inCard: copiedCard)
        #expect(comments.count == 1)
        #expect(comments[0] != CommentIdent.one)
        #expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil)
        #expect(!fixture.exists("\(copiedCard)/comments/.trash"))
    }

    /// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and
    /// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to
    /// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card
    /// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not
    /// a load".
    ///
    /// The board still loads with this comment on it — that is the other test in this file — and it
    /// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one
    /// outcome the transaction rule exists to prevent.
    @Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized")
    func brokenCommentRefusesACopy() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
        // Readable, uneditable — the shape that refuses a copy at card level, now at comment depth.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)

        var thrown: BoardWriteError?
        do {
            _ = try BoardWriter.copyItem(
                at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
                toParent: fixture.url(Ident.lane2),
                order: 1024,
                stamps: .fork
            )
        } catch {
            thrown = error
        }

        let error = try #require(thrown)
        if case .uneditableFrontmatter = error.reason {} else {
            Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)")
        }
        #expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"),
                "the path points at the annotation, which is where the fix is")
        // Nothing landed, and the source is untouched.
        #expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty)
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")
            == Item.uneditable)
    }

    /// The other side of "load-scoped": the very same board **loads**, and its card window's thread
    /// read tolerates the annotation. Only the copy gesture refuses.
    @Test("The same broken comment never refuses the board")
    func brokenCommentNeverRefusesTheBoard() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        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: "Fix login"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)

        let model = try BoardLoader.load(boardRoot: fixture.root).model
        #expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1])
    }

    @Test("Template instantiation is born-today at comment depth too")
    func instantiationRestampsComments() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)

        let copy = try BoardWriter.copyItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            toParent: fixture.url(Ident.lane2),
            order: 1024,
            stamps: .born
        )
        let copied = "\(Ident.lane2)/\(copy.rawValue)"
        let names = try postedNames(fixture, inCard: copied)
        let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value)
        #expect(created.timeIntervalSinceNow > -60, ".born restamps created")
    }

    @Test("A whole-board fork carries the thread verbatim — but never its trash")
    func wholeBoardForkCarriesVerbatim() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)

        let destination = fixture.root.deletingLastPathComponent()
            .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: destination) }
        try BoardTreeCopy.createDirectory(at: destination)
        try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })

        let thread = destination
            .appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
            .appendingPathComponent("comments")
        // GUIDs kept, tracker keys kept — a fork is a fork.
        let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md")
        #expect(FileManager.default.fileExists(atPath: forked.path))
        let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self)
        #expect(text.contains("remote: gitea#42"))
        // The draft too.
        #expect(FileManager.default.fileExists(
            atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path
        ))
        // The thread trash, never.
        #expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path))
    }

    @Test("The board's own .trash is untouched by the comment strip")
    func theBoardTrashIsNotTheThreadTrash() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed"))

        let destination = fixture.root.deletingLastPathComponent()
            .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: destination) }
        try BoardTreeCopy.createDirectory(at: destination)
        try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })

        #expect(FileManager.default.fileExists(
            atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path
        ))
    }

    @Test("A trashed card carries its thread — and the trash interplay costs nothing")
    func trashedCardsCarryTheirThread() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)

        try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root)

        let trashedCard = ".trash/\(Ident.card1)"
        #expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
        #expect(fixture.exists("\(trashedCard)/comments/.draft"))
        #expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything")

        let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])

        // Restoring is the ordinary move back, thread intact.
        _ = try BoardWriter.moveItem(
            at: fixture.url(trashedCard),
            toParent: fixture.url(Ident.lane1),
            sourceBoardRoot: fixture.root,
            destinationBoardRoot: fixture.root,
            order: 1024
        )
        #expect(fixture.exists("\(card)/comments/\(CommentIdent.one)"))
    }

    @Test("The purge takes the whole card, thread and all")
    func purgeTakesTheThread() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
        try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")

        try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root)
        #expect(!fixture.exists(".trash/\(Ident.card1)"))
    }
}
