import Foundation
import Testing
@testable import Kanban

/// `BoardStore`'s trash operations — Delete (staged by place), Empty Trash, and the legacy tombstone
/// migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md § Deletion).
///
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
/// the app's own read path, like every other write suite here: the interesting claims are about the
/// files — which folder moved, which rank it landed under, which stamps followed it, and what came
/// through byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
///
/// **Restore has no suite here**, and its absence is a finding: "restoring is an ordinary move out …
/// there is no restore-specific machinery and no Put Back" (03 § Trash), so it is tested where the
/// ordinary moves are (`DragWriteTests ▸ restore by move-out`).

// MARK: - Fixtures

/// One more literal identity than `Ident` offers — the trash needs two residents to have an order.
private enum More {
    static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
}

/// A lane already sitting in `<root>/.trash/` — the opaque unit, `kind: lane` being the only thing
/// that tells it from a card in the flat container (01-storage-format.md § Deletion).
///
/// `modified` is the row's **position** since 2026-07-31 (the trash sorts by it, descending), so it
/// is a fixture parameter rather than the afterthought it was while ranks did the ordering.
private func trashedLane(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    modified: \(modified)
    kind: lane
    project: lanework       # agent overlay
    ---
    \(title) body.

    """
}

/// A card already sitting in `<root>/.trash/` — an ordinary card in a special place, with an unknown
/// key so the verbatim-preservation claims have something to preserve.
private func trashResident(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    project: lanework       # agent overlay
    created: 2026-01-01T09:00:00Z
    modified: \(modified)
    ---
    \(title) body.

    """
}

/// A legacy tombstone — the only thing in this file that still writes a `deleted:` key, because the
/// migration is the one code path that still reads one.
private func legacyTombstone(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    project: lanework       # agent overlay
    created: 2026-01-01T09:00:00Z
    deleted: \(deleted)
    ---
    \(title) body.

    """
}

/// Three lanes — two cards in the first, one in the second, one in the third — plus two cards
/// already in the board's trash.
@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("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
    try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
    try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
    // Their *stamps* are what orders them now — "Newer" is the newer one, and its `order` is the
    // lane rank it carried in, deliberately disagreeing with the column position.
    try fixture.item(
        ".trash/\(Ident.indexless)",
        trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z"))
    try fixture.item(
        ".trash/\(More.newer)",
        trashResident(order: "512", title: "Newer", modified: "2026-05-02T09:00:00Z"))
    return fixture
}

private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let trashed = ItemID(rawValue: Ident.indexless)
private let newer = ItemID(rawValue: More.newer)

/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
/// through a delete byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
    text.split(separator: "\n", omittingEmptySubsequences: false).filter {
        // `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
        !$0.hasPrefix("modified") && !$0.hasPrefix("order:") && !$0.hasPrefix("kind:")
    }
}

/// A file's bytes and mtime — "minimal touch" stated the way `WriteFidelityTests` states it.
private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
    let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
    let data = try Data(contentsOf: indexURL)
    let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
    guard let modified = attributes[.modificationDate] as? Date else {
        Issue.record("no modification date for \(relativePath)")
        return (data, .distantPast)
    }
    return (data, modified)
}

private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
    try BoardLoader.load(boardRoot: fixture.root).model
}

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

@MainActor
private func reload(_ store: BoardStore) async {
    store.handleWatcherEvent(.treeChanged(.foreign))
    await store.awaitQuiescence()
}

// MARK: - Delete: a card is a move into .trash/

@MainActor
@Suite("BoardStore ▸ delete a card")
struct DeleteCardTests {

    @Test("Deleting a card moves its folder into .trash/ and writes no key at all")
    func deleteIsAMove() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")

        store.delete([card1])

        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
        #expect(fixture.exists(".trash/\(Ident.card1)"))
        let after = try fixture.indexText(".trash/\(Ident.card1)")
        #expect(!after.contains("deleted:"), "the tombstone model is retired — no key is ever written")
        // Everything but the rank and the stamps rides along byte-for-byte, unknown keys and their
        // comments included: a move never reads below the folder it moves.
        #expect(untouchedLines(after) == untouchedLines(before))
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("The move stamps modified — the deliberate exception to moves-don't-stamp")
    func deleteStamps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.delete([card1])

        // 01-storage-format.md § Deletion: "The move stamps `modified` (deletion is an edit to the
        // card's story — the deliberate exception to moves-don't-stamp), which is what a future
        // age-based auto-purge will read."
        #expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil)
    }

    /// **Entry is at the top, and the stamp is what puts it there** (03 ▸ Trash, re-ruled
    /// 2026-07-31): no rank is minted, so the card's `order` arrives exactly as it left its lane and
    /// the fresh `modified` does the positioning.
    @Test("Entry is at the top: the fresh stamp outranks every resident, and `order` is untouched")
    func entryIsAtTheTop() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        // The trash's current top is `newer`, by its stamp — its `order` (512) is the smaller of the
        // two, which under the retired rule would have been the reason and now is a coincidence.
        #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed])

        store.delete([card1])

        let arrived = try document(fixture, ".trash/\(Ident.card1)")
        #expect(arrived.order == .valid(1024), "the lane rank rides along; nothing is minted")
        let stamp = try #require(arrived.modified.value)
        let residentStamp = try #require(try document(fixture, ".trash/\(More.newer)").modified.value)
        #expect(stamp > residentStamp)
        #expect(try loaded(fixture).trash.map(\.id) == [card1, newer, trashed],
                "newest-first falls out of the stamp — no rank anywhere in the container")
    }

    /// **A batch shares one instant, so the deterministic tail orders it** (01 § Deletion: "Ties
    /// break by title (case-insensitive), then folder name — the deterministic tail"). `modified`
    /// serializes at whole-second granularity, so two cards deleted in one bracket carry the same
    /// stamp by construction; the run still sorts above every resident, and *within* the run the
    /// titles decide — "First" before "Second". That is the tail doing exactly its job, and it is the
    /// honest reading of two deletions that really did happen at the same time.
    @Test("A multi-card delete is one bracket; the run lands on top and the tail orders it")
    func batchLandsOnTopOrderedByTheTail() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.delete([card1, card2])

        #expect(try document(fixture, ".trash/\(Ident.card1)").order == .valid(1024))
        #expect(try document(fixture, ".trash/\(Ident.card2)").order == .valid(2048))
        #expect(try loaded(fixture).trash.map(\.id) == [card1, card2, newer, trashed])
    }

    @Test("The selection moves to the successor sibling, immediately")
    func successorSelection() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([card1], in: .board)

        store.delete([card1])

        // Computed from the pre-write snapshot and applied at once: a second ⌫ pressed before the
        // watcher rounds the first one back must already have somewhere to land.
        #expect(store.selection.ids == [card2])
        #expect(store.selection.container == .board)
    }

    @Test("An emptied lane leaves nothing selected")
    func emptiedContainerClears() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([card3], in: .board)

        store.delete([card3])

        #expect(store.selection.isEmpty)
    }

    @Test("Drop-on-trash and the card window's button write exactly what ⌫ writes")
    func everyGestureWritesTheSameThing() throws {
        let byKey = try makeBoard()
        defer { byKey.tearDown() }
        let byDrop = try makeBoard()
        defer { byDrop.tearDown() }
        let byButton = try makeBoard()
        defer { byButton.tearDown() }

        try BoardStore(rootURL: byKey.root).delete([card1])
        try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1])
        try BoardStore(rootURL: byButton.root).deleteCard(card1)

        let keyed = try byKey.indexText(".trash/\(Ident.card1)")
        #expect(untouchedLines(try byDrop.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed))
        #expect(untouchedLines(try byButton.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed))
        // Same rank, too: all three mint at the head of the same trash.
        let rank = try document(byKey, ".trash/\(Ident.card1)").order.value
        #expect(try document(byDrop, ".trash/\(Ident.card1)").order.value == rank)
        #expect(try document(byButton, ".trash/\(Ident.card1)").order.value == rank)
    }

    @Test("Only ⌫ picks a successor — a drag and a card window's button leave the selection alone")
    func onlyTheKeystrokePicksASuccessor() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([card3], in: .board)

        store.deleteByDrag(cardIDs: [card1])
        #expect(store.selection.ids == [card3], "a drag's run need not be the selection at all")

        store.deleteCard(card2)
        #expect(store.selection.ids == [card3])
    }

    @Test("An id that names nothing writes nothing and opens no bracket")
    func vanishedTargetsAreSkipped() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")

        store.delete([ItemID(rawValue: "44444444-4444-4444-4444-444444444444")])
        store.delete([])
        // A card that is in the trash is not on the board side, so a board delete never finds it.
        store.delete([trashed])

        #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
        #expect(fixture.exists(".trash/\(Ident.indexless)"))
        #expect(store.banners.oneShots.isEmpty)
    }
}

// MARK: - Delete: a lane takes the same move

@MainActor
@Suite("BoardStore ▸ delete a lane")
struct DeleteLaneTests {

    /// 03-board-ui.md § Trash, re-ruled 2026-07-29: "deleting a lane moves its folder — subtree
    /// intact — into `.trash/`, exactly as a card moves". The freight is inside the folder that
    /// moved, so it is not filed separately and not surfaced.
    @Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque")
    func laneDeleteIsAMoveIntoTheTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.delete([lane3])

        #expect(!fixture.exists(Ident.lane3))
        #expect(fixture.exists(".trash/\(Ident.lane3)"))
        #expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it")
        #expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards")

        let model = try loaded(fixture)
        #expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged")
        #expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3])
        #expect(model.trashedLanes.first?.heldCards == 1)
        #expect(store.banners.oneShots.isEmpty)
    }

    /// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash) —
    /// and since 2026-07-31 that is the **merged `modified` order** doing it, over both kinds at
    /// once, rather than a ladder the store minted against.
    @Test("The lane lands at the top of the trash, above every existing entry")
    func laneLandsOnTop() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.delete([lane3])
        #expect(try loaded(fixture).trashEntries.first?.id == lane3, "topmost row of the whole column")
        // The lane's strip rank rode along untouched, which is what its restore reads.
        #expect(try document(fixture, ".trash/\(Ident.lane3)").order == .valid(3072))

        // And a *later* card delete lands above it — the merged order is the whole container, so a
        // row of the other kind is exactly as sortable as one of its own. The reload is what puts
        // the new row in the snapshot; without it the store is still holding the pre-delete picture,
        // as it is for two of any deletes in a row. The one-second sleep is load-bearing: `modified`
        // serializes at whole-second granularity, so without it the two deletes share an instant and
        // the deterministic title tail — not recency — would decide.
        await reload(store)
        try await Task.sleep(for: .seconds(1.1))
        store.delete([card1])
        #expect(try loaded(fixture).trashEntries.map(\.id).prefix(2) == [card1, lane3])
    }

    /// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
    @Test("A lane delete raises no confirmation and needs none")
    func laneDeleteIsNotConfirmed() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()
        store.select([lane3], in: .board)

        confirmations.requestDelete(in: store)

        #expect(confirmations.pending == nil)
        #expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through")
    }

    @Test("The selection moves to the successor lane")
    func laneSuccessor() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([lane2], in: .board)

        store.delete([lane2])

        #expect(store.selection.ids == [lane3])
    }

    /// "Dropping a live card — **or lane** — on the shown trash deletes it" (04-interactions.md ▸ The
    /// trash, lanes extended 2026-07-29): the drop writes exactly what ⌫ writes, and — like the card
    /// gesture — says nothing about the selection, because a drag has no keystroke to keep repeatable
    /// and its run is not necessarily the selection at all.
    @Test("Drop-on-trash writes exactly what ⌫ writes at the lane level, and leaves the selection alone")
    func laneDropOnTrashMatchesTheKeystroke() throws {
        let byKey = try makeBoard()
        defer { byKey.tearDown() }
        let byDrag = try makeBoard()
        defer { byDrag.tearDown() }
        let keyStore = try BoardStore(rootURL: byKey.root)
        let dragStore = try BoardStore(rootURL: byDrag.root)
        dragStore.select([card1], in: .board)

        keyStore.delete([lane3])
        dragStore.deleteLanesByDrag(laneIDs: [lane3])

        // Same folder, same subtree, same rank — `moveLanesToTrash` is the one write both take.
        #expect(byDrag.exists(".trash/\(Ident.lane3)/\(Ident.card4)"))
        #expect(try untouchedLines(byDrag.indexText(".trash/\(Ident.lane3)"))
            == untouchedLines(byKey.indexText(".trash/\(Ident.lane3)")))
        #expect(try loaded(byDrag).trashedLanes.map(\.order) == loaded(byKey).trashedLanes.map(\.order))
        // ⌫ moved the selection to the successor lane; the drag left it exactly where it was.
        #expect(dragStore.selection.ids == [card1])
        #expect(dragStore.banners.oneShots.isEmpty)
    }

    @Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
    func lanesWinAMixedSet() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.delete([lane3, card1])

        #expect(!fixture.exists(Ident.lane3))
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"),
                "the card is left alone rather than earning a second undo step for one keystroke")
    }
}

// MARK: - Delete: staged by place

@MainActor
@Suite("BoardStore ▸ Delete is staged by place")
struct StagedDeleteTests {

    @Test("A board selection moves to the trash; a trash selection deletes permanently")
    func stagingFollowsTheContainer() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1], in: .board)
        store.deleteSelection()
        #expect(fixture.exists(".trash/\(Ident.card1)"), "on the board it moves to the trash")

        store.select([trashed], in: .trash)
        store.deleteSelection()
        #expect(!fixture.exists(".trash/\(Ident.indexless)"), "in the trash it removes the folder")
    }

    @Test("A permanent delete walks the trash's own successor, so repeated ⌫ walks the column")
    func trashSuccessorWalksTheColumn() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        // The column's order is [newer, trashed].
        store.select([newer], in: .trash)

        store.deleteTrashEntries([newer])

        #expect(store.selection.ids == [trashed])
        #expect(store.selection.container == .trash)
    }

    @Test("A permanent delete of the last card clears the selection")
    func emptiedTrashClears() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([newer, trashed], in: .trash)

        store.deleteTrashEntries([newer, trashed])

        #expect(try loaded(fixture).trash.isEmpty)
        #expect(store.selection.isEmpty)
    }

    @Test("A permanent delete never reaches a board card, whatever the ids say")
    func trashDeleteCannotReachTheBoard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.deleteTrashEntries([card1])

        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
        #expect(store.banners.oneShots.isEmpty)
    }
}

// MARK: - Empty Trash

@MainActor
@Suite("BoardStore ▸ purge")
struct PurgeTests {

    @Test("Empty Trash removes every card in the container, and leaves strays verbatim")
    func emptyTrashIsWholeScope() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".trash/notes.txt", Data("hand-written".utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.emptyTrash()

        #expect(try loaded(fixture).trash.isEmpty)
        #expect(!fixture.exists(".trash/\(Ident.indexless)"))
        #expect(!fixture.exists(".trash/\(More.newer)"))
        #expect(FileManager.default.fileExists(atPath: fixture.url(".trash").appendingPathComponent("notes.txt").path),
                "stray tolerance does not stop applying because the folder is the app's")
    }

    @Test("Empty Trash clears a trash-side selection and leaves a board one alone")
    func emptyTrashAndTheSelection() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1], in: .board)
        store.emptyTrash()
        #expect(store.selection.ids == [card1], "the board it names is still right there")
    }

    @Test("Emptying an already-empty trash writes nothing")
    func emptyTrashOnNothing() 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 store = try BoardStore(rootURL: fixture.root)

        store.emptyTrash()

        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("No purge registers an undo step: purgeIsUnrecoverable stays true")
    func purgesAreUnrecoverable() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let history = NativeHistoryProvider()
        store.history = history
        #expect(store.purgeIsUnrecoverable)

        store.select([trashed], in: .trash)
        store.deleteTrashEntries([trashed])
        store.deleteTrashEntries([newer])
        store.emptyTrash()

        // 13-native-undo.md ▸ Rules: "Permanently delete (the trash's Delete, Empty Trash) …
        // the confirm *is* the safety." A stack entry here would be a promise the filesystem
        // cannot keep.
        #expect(!history.canUndo)
    }
}

// MARK: - The trash's other kind of row

/// A board whose trash holds **both kinds, interleaved by rank**: a card on top, then a lane row
/// carrying two cards, then a second card (03-board-ui.md § Trash, lanes rejoined 2026-07-29).
///
/// The lane row sits between the two cards deliberately — every claim below about crossing kinds
/// needs a row of the other kind on both sides of it.
@MainActor
private func makeTrashedLaneBoard() 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"))
    // The column reads newest-first by `modified`: Newer, Doing (the lane row), Trashed — the two
    // kinds interleaved by the one stamp, which is the merged order this suite navigates by.
    try fixture.item(
        ".trash/\(More.newer)",
        trashResident(order: "512", title: "Newer", modified: "2026-05-03T09:00:00Z"))
    try fixture.item(
        ".trash/\(Ident.lane2)",
        trashedLane(order: "768", title: "Doing", modified: "2026-05-02T09:00:00Z"))
    try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
    try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
    try fixture.item(
        ".trash/\(Ident.indexless)",
        trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z"))
    return fixture
}

/// The permanent delete, the confirmation that stands in front of it, and the successor it leaves —
/// on a **trashed lane row** (03-board-ui.md § Trash: "it restores whole or purges whole").
@MainActor
@Suite("BoardStore ▸ purging a trashed lane row")
struct PurgeTrashedLaneTests {

    @Test("The row purges whole — its subtree with it — and registers no undo step")
    func rowPurgesWhole() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let history = NativeHistoryProvider()
        store.history = history

        store.deleteTrashEntries([lane2])

        #expect(!fixture.exists(".trash/\(Ident.lane2)"))
        #expect(!fixture.exists(".trash/\(Ident.lane2)/\(Ident.card3)"), "the freight went with it")
        let model = try loaded(fixture)
        #expect(model.trashedLanes.isEmpty)
        #expect(model.trash.map(\.id) == [newer, trashed], "the column's cards are untouched")
        // 13-native-undo.md ▸ Rules: "lanes and their freight included" — the confirm is the safety.
        #expect(!history.canUndo)
    }

    /// "Confirms name the freight honestly — a trashed lane's alert counts its cards" (03 § Trash).
    @Test("The row's confirmation names the lane and counts its cards")
    func rowConfirmationCountsTheFreight() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()
        store.select([lane2], in: .trash)

        confirmations.requestDelete(in: store)

        let pending = try #require(confirmations.pending)
        #expect(pending.prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 2 cards?")
        #expect(pending.action == .deleteTrashEntries([lane2]))

        confirmations.confirm(in: store)
        #expect(!fixture.exists(".trash/\(Ident.lane2)"))
    }

    /// Empty Trash's own sentence, with lanes in the container: "… 41 cards and 2 lanes containing 9
    /// more cards" (03 § Trash) — and the walk really removes the subtrees.
    @Test("Empty Trash counts both kinds and walks the lane subtrees")
    func emptyTrashCountsBothKinds() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true
        let confirmations = TrashConfirmations()

        #expect(store.canEmptyTrash)
        confirmations.requestEmptyTrash(in: store)
        let pending = try #require(confirmations.pending)
        #expect(pending.prompt.title == "Permanently delete 2 cards and 1 lane containing 2 more cards?")

        confirmations.confirm(in: store)
        let model = try loaded(fixture)
        #expect(model.trash.isEmpty)
        #expect(model.trashedLanes.isEmpty)
        #expect(!fixture.exists(".trash/\(Ident.lane2)"))
    }

    /// **The successor is kind-blind** (04-interactions.md ▸ The map, ruled 2026-07-31, ratifying
    /// what stood here as an interim): "the next row of either kind, in the same all-rows order plain
    /// arrows walk … repeated ⌘⌫ empties a mixed trash without dead-ends".
    @Test("The successor after purging a lane row is the next row down, kind notwithstanding")
    func successorCrossesKinds() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([lane2], in: .trash)

        store.deleteTrashEntries([lane2])

        // The column reads [newer, lane2, trashed]; forward-first lands on the card below.
        #expect(store.selection == ItemReferenceSet(ids: [trashed], container: .trash))
    }

    @Test("A row purged from the bottom falls back to its predecessor, and an emptied column clears")
    func successorFallsBackAndClears() async 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/\(More.newer)", trashResident(order: "512", title: "Newer"))
        try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "1024", title: "Doing"))
        let store = try BoardStore(rootURL: fixture.root)

        store.select([lane2], in: .trash)
        store.deleteTrashEntries([lane2])
        #expect(store.selection.ids == [newer], "nothing below, so the row above")

        // The successor is drawn from the *pre-write* snapshot, so the second purge needs the reload
        // that tells the store the row is gone — the lane-delete suite's rule, on the trash side.
        await reload(store)
        store.deleteTrashEntries([newer])
        #expect(store.selection.isEmpty, "an emptied container selects nothing")
    }
}

/// The lane row's own menu validation — "everything edit-shaped is disabled on trash selections …
/// **and lane width ops on lane rows**" (04-interactions.md ▸ The trash).
@MainActor
@Suite("Edit-shaped commands on a trashed lane row")
struct TrashedLaneRowValidationTests {

    @Test("Rename, Style… and Open Card all refuse a lane row")
    func editShapedRefusals() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([lane2], in: .trash)

        // The container answers all three, which is why none of them needed a kind clause.
        #expect(store.renameTarget == nil)
        #expect(store.boardStyleTarget == nil)
        #expect(store.openCardTarget == nil)
        // The within-lane sort is inert too — its plan reads a *board* card selection.
        #expect(store.sortPlan(.up) == nil)
        #expect(store.sortPlan(.down) == nil)
    }

    /// The width pair batches over "the selected live lanes", which is `snapshot.lanes` narrowed by a
    /// **board** selection — so a trashed row contributes nothing and both items disable.
    @Test("The width stepper's batch is empty for a lane row")
    func widthOpsRefuse() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([lane2], in: .trash)

        let selected = store.snapshot.lanes.filter { store.selection.ids.contains($0.id) }
        #expect(selected.isEmpty)
        #expect(store.selection.container == .trash)
        // And the row is not a lane on the strip at all, so Move Left/Right cannot name it either.
        #expect(!SelectionGrammar.lanes(in: store.snapshot).contains(lane2))
    }

    /// Delete stays enabled and honest — the row is a real entry the container holds, so the one
    /// Delete predicate answers `true` and the write it stages is the permanent one.
    @Test("Delete is enabled on a lane row and stages the permanent write")
    func deleteStaysHonest() throws {
        let fixture = try makeTrashedLaneBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([lane2], in: .trash)

        #expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
        #expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
            == [.trashLane(lane2)])
    }
}

// MARK: - The legacy tombstone migration

/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never
/// destroy" — the store-side scheduling, which is `relocateLooseCardFiles`' twin in every
/// mechanical respect.
@MainActor
@Suite("BoardStore ▸ the legacy tombstone migration")
struct StoreTombstoneMigrationTests {

    /// A board an older version wrote: two tombstoned cards under a live lane, one tombstoned lane
    /// with a live card inside it, and a board-level key that means nothing.
    private func makeLegacyBoard() 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: "Live"))
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card2)",
            legacyTombstone(order: "2048", title: "Older", deleted: "2026-03-01T09:00:00Z")
        )
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card3)",
            legacyTombstone(order: "3072", title: "Newer", deleted: "2026-03-05T09:00:00Z")
        )
        try fixture.item(Ident.lane2, legacyTombstone(order: "2048", title: "Retired"))
        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along"))
        return fixture
    }

    @Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
    func migrationMovesCardsAndLeavesLanesAlone() throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let laneBefore = try fixture.indexText(Ident.lane2)
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        // The cards moved, and their keys went with the move.
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
        #expect(fixture.exists(".trash/\(Ident.card2)"))
        #expect(fixture.exists(".trash/\(Ident.card3)"))
        #expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
        #expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")))

        // "A lane carrying `deleted:` simply loads live with the key ignored — no migration
        // machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
        // untouched, `modified` included, and it renders as an ordinary lane.
        #expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
        #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
        #expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
    }

    /// **The stamps do the ordering, not the batch** (01 § Deletion, re-ruled 2026-07-31): each
    /// migrated card takes its own `deleted:` timestamp as its `modified`, so the board's real
    /// deletion order survives whatever sequence the heal happens to run in.
    @Test("Migrated cards keep their real deletion order — newest deletion on top")
    func migrationKeepsRealDeletionOrder() throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        // 03-05 above 03-01, straight off the retired key — not off the order the batch ran in.
        #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"])
        let newerStamp = try #require(try document(fixture, ".trash/\(Ident.card3)").modified.value)
        #expect(newerStamp == Date(timeIntervalSince1970: 1_772_701_200),
                "2026-03-05T09:00:00Z — the legacy stamp, carried over verbatim")
    }

    /// An **unparseable** legacy stamp is no evidence of when the card was deleted, so the migration
    /// stamps it at migration time (01 § Deletion: "and from migration time otherwise") — which
    /// lands it among the freshest rather than inventing a date for it. Deterministic either way,
    /// which is what this pins.
    @Test("A card whose legacy stamp cannot be read is migrated at migration time")
    func unparseableStampMigratesAtNow() 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)",
            legacyTombstone(order: "1024", title: "Corrupt", deleted: "not-a-date")
        )
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card2)",
            legacyTombstone(order: "2048", title: "Dated", deleted: "2026-03-01T09:00:00Z")
        )
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        // Migration time is today, which is newer than any legacy stamp a real board carries.
        #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Corrupt", "Dated"])
    }

    @Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged")
    func boardLevelKeyIsLeftAlone() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", """
        ---
        schema: 1
        title: Board
        deleted: 2026-03-03T09:00:00Z
        ---
        Board body.

        """)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        #expect(try fixture.indexText("").contains("deleted:"), "preserved verbatim")
        #expect(store.banners.losses.isEmpty, "and nothing to announce")
    }

    @Test("The notice is one warning-tone row, and names only what was moved")
    func theNotice() throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        #expect(store.banners.losses.count == 1, "one migration, one row")
        // No lane clause: nothing happened to the lane, so a row claiming otherwise would announce
        // an act the app did not perform.
        #expect(store.banners.losses.first?.message
            == "Moved 2 cards to the trash — they carried old deleted markers")
    }

    @Test("A board with nothing legacy migrates nothing and says nothing")
    func nothingToMigrate() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()

        #expect(store.banners.losses.isEmpty)
        #expect(try loaded(fixture).trash.count == 2, "the existing trash is not disturbed")
    }

    @Test("The read-only lock defers it, and remembers nothing — the next attempt is a fresh one")
    func theLockDefers() throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()

        store.migrateLegacyTombstones()

        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "01: deferred under any read-only lock")
        #expect(store.banners.losses.isEmpty)
    }

    /// The loop guard `relocateLooseCardFiles` documents, read for this migration: after a success
    /// the walk finds nothing and the memo clears; a second call against the *same* unchanged picture
    /// never re-attempts.
    @Test("It cannot hot-loop: a second call against the same picture writes nothing")
    func theLoopGuard() throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.migrateLegacyTombstones()
        let after = try stat(fixture, ".trash/\(Ident.card2)")
        store.banners.dismissAllDismissableRows()

        // The store's snapshot still reports the same legacy tombstones (the reload has not landed),
        // so an unguarded second call would migrate a card that has already moved — and fail.
        store.migrateLegacyTombstones()

        #expect(try stat(fixture, ".trash/\(Ident.card2)") == after)
        #expect(store.banners.losses.isEmpty)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A successful reload clears the memo and the pending work together")
    func theReloadClosesTheWindow() async throws {
        let fixture = try makeLegacyBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        #expect(!store.legacyTombstones.isEmpty)

        store.migrateLegacyTombstones()
        await reload(store)

        // The window closes per board on the first successful migration write: no key is left to
        // read, so the channel empties and stays empty.
        #expect(store.legacyTombstones.isEmpty)
        #expect(store.snapshot.trash.count == 2)
        #expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane")
    }

    @Test("It is armed by the reload seam, exactly like the loose-file relocation")
    func theReloadArmsIt() async 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 store = try BoardStore(rootURL: fixture.root)

        // The tombstone arrives after the store opened — an older version's board pulled in by a
        // sync, or a hand edit.
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card1)",
            legacyTombstone(order: "1024", title: "Legacy")
        )
        await reload(store)

        #expect(fixture.exists(".trash/\(Ident.card1)"), "the reload that saw it is the reload that fixed it")
        #expect(store.banners.losses.count == 1)
    }
}

// MARK: - The banner's phrasing

@Suite("BannerCenter ▸ the migration notice")
struct MigrationNoticeTests {

    @Test("One card names it; several fold to a count")
    func cardsFold() {
        #expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"])
            == "Moved 'Fix login' to the trash — it carried an old deleted marker")
        #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"])
            == "Moved 3 cards to the trash — they carried old deleted markers")
    }

    @Test("An untitled item reads as a rendering, and nothing migrated is not news")
    func edges() {
        #expect(BannerCenter.migratedTombstonesMessage(cards: [nil])
            == "Moved an untitled item to the trash — it carried an old deleted marker")
        #expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil)
    }
}

// MARK: - The confirmations

@MainActor
@Suite("TrashConfirmations")
struct TrashConfirmationsTests {

    /// 03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent … Both confirm exactly
    /// where the loss is real."
    @Test("The trash's own Delete confirms; the board's goes straight through")
    func deleteIsConfirmedOnlyInTheTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()

        // A board selection is recoverable — the trash itself, and undo — so no alert.
        store.select([card1], in: .board)
        confirmations.requestDelete(in: store)
        #expect(confirmations.pending == nil)
        #expect(fixture.exists(".trash/\(Ident.card1)"), "it went straight through")

        // A trash selection is the permanent one.
        store.select([trashed], in: .trash)
        confirmations.requestDelete(in: store)
        let pending = try #require(confirmations.pending)
        #expect(pending.action == .deleteTrashEntries([trashed]))
        #expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")

        confirmations.confirm(in: store)
        #expect(!fixture.exists(".trash/\(Ident.indexless)"))
    }

    @Test("Cancelling dismisses and writes nothing")
    func cancelWritesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()
        store.select([trashed], in: .trash)

        confirmations.requestTrashDelete(of: [trashed], in: store)
        confirmations.cancel()

        #expect(confirmations.pending == nil)
        #expect(fixture.exists(".trash/\(Ident.indexless)"))
    }

    @Test("Empty Trash always confirms, and its scope is the whole trash")
    func emptyTrashConfirms() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()

        confirmations.requestEmptyTrash(in: store)

        let pending = try #require(confirmations.pending)
        #expect(pending.action == .emptyTrash)
        #expect(pending.prompt.title == "Permanently delete 2 cards?")

        confirmations.confirm(in: store)
        #expect(try loaded(fixture).trash.isEmpty)
    }

    @Test("No command raises an alert with nothing to act on")
    func nothingToConfirm() 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 store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()

        confirmations.requestEmptyTrash(in: store)
        #expect(confirmations.pending == nil)

        // A board lane names nothing in the trash — the trash-side Delete's own refusal.
        confirmations.requestTrashDelete(of: [lane1], in: store)
        #expect(confirmations.pending == nil)
    }
}

// MARK: - The menu-validation seams

/// The trash's two File-menu rows, validated as predicates rather than as menu items — 11-command
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
///
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled(…)` per row over these
/// answers; what is worth pinning is the answers. `TrashModel.canDelete` is pinned as a pure function
/// in `TrashModelTests`; this suite covers the two seams that need a live store — Empty Trash's scope,
/// and the staging a ⌘⌫ actually performs.
@MainActor
@Suite("The trash's menu validation")
struct TrashMenuValidationTests {

    /// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
    /// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
    /// filtered view".
    @Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
    func emptyTrashValidation() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Hidden, the trash "is invisible to every gesture" — the command included.
        #expect(!store.canEmptyTrash)

        store.transient.isTrashVisible = true
        #expect(store.canEmptyTrash)

        // A query that hides every trash card leaves it enabled: the scope is the container, not
        // what is on screen.
        store.searchQuery = "zzzz-nothing-matches"
        #expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
        #expect(store.canEmptyTrash)

        // And the confirmation still names the true count, for the same reason.
        let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
        #expect(prompt.title == "Permanently delete 2 cards?")
    }

    @Test("An empty container disables it however visible the column is")
    func emptyTrashNeedsCards() 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 store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true

        #expect(!store.canEmptyTrash)
    }

    /// **One Delete, staged by place** — 04-interactions.md ▸ The map: "File ▸ Delete is the chord's
    /// only owner — no twin menu items, no shared-equivalent routing". Put Back's ⌘⌫ twin is retired,
    /// so exactly one predicate enables the row and the selection's *container* decides which write
    /// it performs.
    @Test("Delete is one enabled row on both sides, and the container picks the write")
    func deleteIsOneRowStagedByPlace() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()

        store.select([card1], in: .board)
        #expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
        confirmations.requestDelete(in: store)
        // The board staging: a move, no alert, the card now in the trash.
        #expect(confirmations.pending == nil)
        #expect(fixture.exists(".trash/\(Ident.card1)"))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))

        store.select([trashed], in: .trash)
        #expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
        confirmations.requestDelete(in: store)
        // The trash staging: permanent, and behind the alert.
        let pending = try #require(confirmations.pending)
        #expect(pending.action == .deleteTrashEntries([trashed]))
    }

    /// A context menu names its target by where it was invoked, so the trash row's Delete must purge
    /// the clicked card even while a *board* selection stands — the case a selection-reading path
    /// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
    @Test("The trash card's context-menu Delete acts on its own target, not on the selection")
    func contextMenuDeleteIgnoresTheSelection() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let confirmations = TrashConfirmations()
        store.select([card1], in: .board)

        confirmations.requestTrashDelete(of: [trashed], in: store)

        let pending = try #require(confirmations.pending)
        #expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
        #expect(pending.action == .deleteTrashEntries([trashed]))

        confirmations.confirm(in: store)
        #expect(!fixture.exists(".trash/\(Ident.indexless)"))
        // The board selection was never the subject and is untouched on disk.
        #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
    }
}

// MARK: - Everything edit-shaped refuses a trash selection

/// 04-interactions.md ▸ The trash: "Everything edit-shaped is disabled on trash selections — Open
/// Card, Rename, Style…". Each of the three answers with one expression used for both its `disabled`
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
/// `.renameTarget`, `.boardStyleTarget`).
@MainActor
@Suite("Edit-shaped commands on a trash selection")
struct TrashGrammarExclusionTests {

    @Test("Open Card takes a sole board card and nothing else")
    func openRefusesTheTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1], in: .board)
        #expect(store.openCardTarget == card1)

        // "Trash cards don't open — double-click stops at selection; move it out first" (03 § Trash).
        store.select([trashed], in: .trash)
        #expect(store.openCardTarget == nil)
        store.select([trashed, newer], in: .trash)
        #expect(store.openCardTarget == nil)

        // A lane and a multi-selection refuse too — a card window is tied to one card.
        store.select([lane1], in: .board)
        #expect(store.openCardTarget == nil)
        store.select([card1, card2], in: .board)
        #expect(store.openCardTarget == nil)
    }

    @Test("Rename takes a sole board item, card or lane, and never a trash card")
    func renameRefusesTheTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1], in: .board)
        #expect(store.renameTarget?.id == card1)
        #expect(store.renameTarget?.title == "First")
        store.select([lane1], in: .board)
        #expect(store.renameTarget?.id == lane1)

        store.select([trashed], in: .trash)
        #expect(store.renameTarget == nil)
    }

    /// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
    /// selection has to disable rather than land there — "quietly restyling the board because the
    /// user had a trashed card selected would be the silent retarget 03 forbids".
    @Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
    func styleRefusesTheTrashWithoutFallingThrough() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.clearSelection()
        #expect(store.boardStyleTarget == .board)

        store.select([card1], in: .board)
        #expect(store.boardStyleTarget == .items([card1]))

        store.select([trashed], in: .trash)
        #expect(store.boardStyleTarget == nil)
    }

    /// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available — the two
    /// rows 11-command-nexus.md gives a trash card, and no others.
    @Test("Delete and Reveal are what a trash selection keeps")
    func whatTheTrashKeeps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.select([trashed], in: .trash)

        #expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
        #expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
            .map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
    }
}

// MARK: - Put Back is gone

/// 03-board-ui.md § Trash: "**No Put Back** (settled) … Restoring is an ordinary move out". The
/// retirement is mostly a compile-time fact — there is no `putBack` on the store, no restore write on
/// the Writer, and no second ⌫-titled menu row — so what is left to state at runtime is that a
/// restore registers, phrases and writes as the ordinary move it now is.
@MainActor
@Suite("Put Back is retired")
struct PutBackRetirementTests {

    @Test("The undo vocabulary has no restore verb — a restore is a Move")
    func noRestoreVerb() {
        #expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
        #expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
        // What a drag-out or a ⌘X/⌘V restore actually reads as in the Edit menu.
        #expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
    }

    /// One predicate for both stagings, because there is only one item: the mirror-image pair that
    /// existed to make two ⌘⌫ twins enable exactly one of themselves retired with Put Back.
    @Test("One Delete predicate covers both containers")
    func oneDeletePredicate() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try loaded(fixture)

        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
    }
}
