import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban

/// `ClipboardStore`'s own machinery — the manifest, the staging lifecycle, the sweep, the
/// changeCount-based takeover, and the deferred cut's arming and voiding (04-interactions.md ▸
/// Clipboard). The *writes* a paste performs live in `PasteWriteTests.swift`.
///
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
/// run) and a temp staging directory (so nothing goes near the app's real Application Support home).
/// Both seams exist exactly because those two claims are the ones worth pinning.

// MARK: - Test doubles

/// The pasteboard, as a value a test can shove around.
///
/// `changeCount` behaves the way `NSPasteboard`'s does — a machine-wide counter that anyone's write
/// bumps — because that is the whole basis of takeover detection, and a double that only counted
/// *our* writes would make the interesting case untestable.
@MainActor
final class FakePasteboard: ClipboardPasteboard {

    private(set) var changeCount = 0
    private(set) var text: String?
    private var data: Data?

    /// **What some other app put down**, by type — the raw material the image branch classifies
    /// (`PastedImage.flavor(hasBoardItems:types:)`). Ordered so `availableTypes()` can answer in a
    /// stable order, which is what makes "our preference order wins over the pasteboard's" a claim a
    /// test can actually make.
    private var foreign: [(type: String, data: Data)] = []

    /// **A multi-item read, unlike `foreign`** — the file-URL branch's own `fileURLs()`, which reads
    /// across every pasteboard item rather than the first item's types (`availableTypes()`'s
    /// carve-out). `foreign`'s flat `(type, data)` list cannot represent "the same type on several
    /// items", which is exactly what a multi-file Finder copy needs seeded.
    private var seededFileURLs: [URL] = []

    func manifestData() -> Data? { data }

    @discardableResult
    func write(manifest: Data, text: String) -> Int {
        changeCount += 1
        data = manifest
        self.text = text
        // A real write clears the pasteboard first, so anything another app left is gone.
        foreign = []
        return changeCount
    }

    func availableTypes() -> [String] {
        (data != nil ? [UTType.laneworkClipboard.identifier] : []) + foreign.map(\.type)
    }

    func data(forType type: String) -> Data? {
        if type == UTType.laneworkClipboard.identifier { return data }
        return foreign.first { $0.type == type }?.data
    }

    func fileURLs() -> [URL] { seededFileURLs }

    /// Another app copied: ownership moves, our type is gone, the counter advanced.
    func takeOver() {
        changeCount += 1
        data = nil
        text = nil
        foreign = []
        seededFileURLs = []
    }

    /// Another app put *these* flavors down — a screenshot, a browser's Copy Image, a Finder copy.
    /// `takeOver`'s shape with a payload: the counter advances and our own type goes, because that is
    /// what `clearContents()` does to it.
    func seed(_ payloads: [(type: String, data: Data)]) {
        takeOver()
        foreign = payloads
    }

    /// A Finder copy of one or more **files** — every URL as its own pasteboard item, exactly as a
    /// real multi-select copy is, with `public.file-url` reported through `availableTypes()` (the
    /// first-item read the image branch's precedence classifies) so `carriesFileURL` sees it. `also`
    /// seeds types riding beside the file URL on that same first item — an image flavor, for the
    /// precedence tests where a Finder-copied image file carries both.
    func seedFileURLs(_ urls: [URL], also: [(type: String, data: Data)] = []) {
        takeOver()
        seededFileURLs = urls
        foreign = urls.isEmpty ? also : [(UTType.fileURL.identifier, Data())] + also
    }

    /// A foreign type layered over whatever this pasteboard already holds, counter bumped but nothing
    /// cleared — a combination `write()` alone can never produce (a real copy's `clearContents()`
    /// takes everything with it), but exactly what "the app's own type wins outright" needs to
    /// construct to prove the guard actually fires rather than merely never being exercised
    /// (`PastedImageClassificationTests.boardItemsWin`'s same synthetic shape, one layer up).
    func layerForeign(_ payloads: [(type: String, data: Data)]) {
        changeCount += 1
        foreign = payloads
    }
}

// MARK: - Fixtures

let clipboardLane1 = ItemID(rawValue: Ident.lane1)
let clipboardLane2 = ItemID(rawValue: Ident.lane2)
let clipboardCard1 = ItemID(rawValue: Ident.card1)
let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)

/// The trash's **lane row** in the harness below — the opaque unit ⌘X restores (03-board-ui.md §
/// Trash, lanes rejoined 2026-07-29).
let clipboardTrashedLane = ItemID(rawValue: Ident.lane3)

/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    created: 2026-01-01T09:00:00Z
    ---
    \(title) body.

    """
}

/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash.
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable.
@MainActor
func makeClipboardBoard() 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.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
    try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
    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.card4)", Item.rich(order: "1024", title: "Fourth"))
    try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed"))
    return fixture
}

/// A store plus the two seams, torn down together.
@MainActor
struct ClipboardHarness {
    let fixture: WriterFixture
    let staging: URL
    let pasteboard: FakePasteboard
    let clipboard: ClipboardStore
    let store: BoardStore

    init(fixture: WriterFixture) throws {
        self.fixture = fixture
        staging = FileManager.default.temporaryDirectory
            .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
        pasteboard = FakePasteboard()
        clipboard = ClipboardStore(pasteboard: pasteboard, stagingRoot: staging, observesActivation: false)
        store = try BoardStore(rootURL: fixture.root)
    }

    func tearDown() {
        try? FileManager.default.removeItem(at: staging)
        fixture.tearDown()
    }

    /// The staged copy directories, sorted — "at most the current copy" is a claim about this list.
    ///
    /// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them
    /// (`ClipboardStore.prune`'s claim-then-delete). A staged copy is never hidden — its name is a
    /// lowercased UUID.
    func stagedCopyIDs() throws -> [String] {
        try FileManager.default.contentsOfDirectory(atPath: staging.path)
            .filter { !$0.hasPrefix(".") }
            .sorted()
    }
}

@MainActor
func makeClipboardHarness() throws -> ClipboardHarness {
    try ClipboardHarness(fixture: try makeClipboardBoard())
}

/// The same board with a **lane row in its trash**, carrying one card — the clipboard's other trash
/// subject (04-interactions.md ▸ The trash: "⌘X works … a trashed lane pastes after the anchor
/// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the
/// trash's cards keeps counting exactly what it did.
@MainActor
func makeTrashedLaneHarness() throws -> ClipboardHarness {
    let fixture = try makeClipboardBoard()
    try fixture.item(
        ".trash/\(Ident.lane3)",
        "---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
    )
    try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight"))
    return try ClipboardHarness(fixture: fixture)
}

// MARK: - The manifest

@Suite("ClipboardManifest")
struct ClipboardManifestTests {

    private func entry(_ id: String) -> ClipboardManifest.Entry {
        ClipboardManifest.Entry(
            id: id,
            folder: id,
            title: "First",
            index: "---\nschema: 1\n---\nbody\n",
            attachmentCount: 2
        )
    }

    @Test("A manifest round-trips through JSON")
    func roundTrip() throws {
        let manifest = ClipboardManifest(
            copyID: "abc",
            boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
            kind: .card,
            container: .board,
            entries: [entry(Ident.card1)]
        )
        let data = try #require(manifest.encoded())
        #expect(ClipboardManifest(data: data) == manifest)
    }

    @Test("A manifest from a future version is refused rather than half-read")
    func futureVersion() throws {
        var manifest = ClipboardManifest(
            copyID: "abc",
            boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
            kind: .card,
            container: .board,
            entries: [entry(Ident.card1)]
        )
        manifest.version = ClipboardManifest.currentVersion + 1
        let data = try #require(manifest.encoded())
        #expect(ClipboardManifest(data: data) == nil)
    }

    @Test("An entryless manifest is nothing to paste")
    func empty() throws {
        let manifest = ClipboardManifest(
            copyID: "abc",
            boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
            kind: .card,
            container: .board,
            entries: []
        )
        let data = try #require(manifest.encoded())
        #expect(ClipboardManifest(data: data) == nil)
    }

    @Test("A lane entry's attachment count totals its cards'")
    func totalAttachments() {
        let lane = ClipboardManifest.Entry(
            id: Ident.lane1,
            folder: Ident.lane1,
            title: "Todo",
            index: "---\nschema: 1\n---\n",
            attachmentCount: 0,
            cards: [
                .init(id: Ident.card1, title: "First", index: "a", attachmentCount: 2),
                .init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
            ]
        )
        #expect(lane.totalAttachmentCount == 3)
    }

    @Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
    func plainText() {
        var titled = entry(Ident.card1)
        var untitled = entry(Ident.card2)
        untitled.title = nil
        titled.title = "First"
        let manifest = ClipboardManifest(
            copyID: "abc",
            boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
            kind: .card,
            container: .board,
            entries: [titled, untitled]
        )
        #expect(manifest.plainText == "First\nUntitled")
    }

    // MARK: - pasteMenuTitle

    private func manifest(kind: SelectionKind, entryCount: Int) -> ClipboardManifest {
        ClipboardManifest(
            copyID: "abc",
            boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
            kind: kind,
            container: .board,
            entries: (0..<entryCount).map { entry("item-\($0)") }
        )
    }

    @Test("No app payload titles the row plain 'Paste'")
    func pasteMenuTitleNoPayload() {
        #expect(ClipboardManifest.pasteMenuTitle(for: nil) == "Paste")
    }

    @Test("An entryless manifest — unreachable in practice, but defensive — titles the row plain 'Paste'")
    func pasteMenuTitleEmptyEntries() {
        #expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 0)) == "Paste")
    }

    @Test("One copied card titles the row 'Paste Card'")
    func pasteMenuTitleSingleCard() {
        #expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 1)) == "Paste Card")
    }

    @Test("Several copied cards title the row 'Paste N Cards'")
    func pasteMenuTitleMultipleCards() {
        #expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 3)) == "Paste 3 Cards")
    }

    @Test("One copied lane titles the row 'Paste Lane'")
    func pasteMenuTitleSingleLane() {
        #expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .lane, entryCount: 1)) == "Paste Lane")
    }

    @Test("Several copied lanes title the row 'Paste N Lanes'")
    func pasteMenuTitleMultipleLanes() {
        #expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .lane, entryCount: 2)) == "Paste 2 Lanes")
    }
}

// MARK: - Copy and staging

@MainActor
@Suite("ClipboardStore ▸ copy")
struct ClipboardCopyTests {

    @Test("A copy stages the whole card folder, attachments and all")
    func stagesAttachments() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        await harness.clipboard.stagingSettled()

        let manifest = try #require(harness.clipboard.payload)
        let staged = harness.staging
            .appendingPathComponent(manifest.copyID, isDirectory: true)
            .appendingPathComponent(Ident.card1, isDirectory: true)
        #expect(FileManager.default.fileExists(atPath: staged.appendingPathComponent("index.md").path))
        #expect(try Data(contentsOf: staged.appendingPathComponent("attachments/photo.png"))
            == Data("png bytes".utf8))
        #expect(try Data(contentsOf: staged.appendingPathComponent("attachments/notes.txt"))
            == Data("notes".utf8))
    }

    @Test("The manifest records identity, side, kind and the source board root")
    func manifestShape() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1, clipboardCard2], in: .board)
        harness.clipboard.copy(from: harness.store)

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.kind == .card)
        #expect(manifest.container == .board)
        #expect(manifest.rootURL.path == harness.fixture.root.path)
        // Flatten order — lane `order`, then card `order`.
        #expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
        #expect(manifest.entries.map(\.title) == ["First", "Second"])
        #expect(manifest.entries[0].attachmentCount == 2)
        #expect(manifest.entries[1].attachmentCount == 0)
        #expect(harness.pasteboard.text == "First\nSecond")
    }

    @Test("The embedded index text is the file's bytes, verbatim")
    func embeddedIndexIsSourceBytes() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)

        let manifest = try #require(harness.clipboard.payload)
        let onDisk = try harness.fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
        #expect(manifest.entries[0].index == onDisk)
    }

    @Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested")
    func laneEntryEmbedsItsCards() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardLane1], in: .board)
        harness.clipboard.copy(from: harness.store)

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.kind == .lane)
        #expect(manifest.entries.map(\.id) == [Ident.lane1])
        #expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
        #expect(manifest.entries[0].totalAttachmentCount == 2)
    }

    @Test("A trash selection copies out, container recorded")
    func trashContainerRecorded() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.transient.isTrashVisible = true
        harness.store.select([clipboardCard3], in: .trash)
        harness.clipboard.copy(from: harness.store)

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.container == .trash)
        #expect(manifest.entries.map(\.id) == [Ident.card3])
    }

    @Test("An empty selection copies nothing and leaves the pasteboard alone")
    func emptySelection() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.clipboard.copy(from: harness.store)
        #expect(harness.pasteboard.changeCount == 0)
        #expect(harness.clipboard.payload == nil)
    }

    @Test("The store holds at most the current copy — a second copy sweeps the first")
    func atMostTheCurrentCopy() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        await harness.clipboard.stagingSettled()
        let first = try #require(harness.clipboard.payload?.copyID)

        harness.store.select([clipboardCard2], in: .board)
        harness.clipboard.copy(from: harness.store)
        await harness.clipboard.stagingSettled()
        let second = try #require(harness.clipboard.payload?.copyID)

        #expect(first != second)
        #expect(try harness.stagedCopyIDs() == [second])
    }
}

// MARK: - The sweep

@MainActor
@Suite("ClipboardStore ▸ sweep")
struct ClipboardSweepTests {

    @Test("A launch sweep collects every tree the pasteboard no longer names")
    func launchSweepPurgesOrphans() async throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let staging = FileManager.default.temporaryDirectory
            .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: staging) }
        // Two trees from a previous launch, and a pasteboard that names neither.
        for orphan in ["one", "two"] {
            try FileManager.default.createDirectory(
                at: staging.appendingPathComponent(orphan, isDirectory: true),
                withIntermediateDirectories: true
            )
        }

        let clipboard = ClipboardStore(
            pasteboard: FakePasteboard(),
            stagingRoot: staging,
            observesActivation: false
        )
        await clipboard.stagingSettled()

        #expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
    }

    @Test("A sweep keeps the tree the pasteboard still names")
    func sweepKeepsTheCurrentCopy() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }
        try FileManager.default.createDirectory(
            at: harness.staging.appendingPathComponent("stale", isDirectory: true),
            withIntermediateDirectories: true
        )

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        await harness.clipboard.stagingSettled()

        let current = try #require(harness.clipboard.payload?.copyID)
        #expect(try harness.stagedCopyIDs() == [current])
    }

    @Test("A takeover makes our own tree an orphan, and the next sweep collects it")
    func takeoverOrphansOurSnapshot() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        await harness.clipboard.stagingSettled()
        #expect(try harness.stagedCopyIDs().count == 1)

        harness.pasteboard.takeOver()
        harness.clipboard.sweep()
        await harness.clipboard.stagingSettled()

        #expect(try harness.stagedCopyIDs().isEmpty)
    }

    // MARK: Two sweeps over one store
    //
    // One app, so the ordinary case is one sweeper — but the sweep is written claim-then-delete
    // anyway (`ClipboardStore.prune`), which is what makes a second sweeper a non-event: a second
    // copy of the app launched with `open -n` shares this container, and so does the next sweep after
    // a crash mid-delete. These two tests are that property's two halves: atomic removals, and
    // missing-entry = already swept.

    @Test("Two stores sweeping the same staging root at once agree, and neither errors")
    func concurrentSweepsAgree() async throws {
        let staging = FileManager.default.temporaryDirectory
            .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: staging) }
        try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)

        // Six trees, each with a file in it so a removal is a real recursive delete rather than an
        // empty-directory unlink — the case where two sweepers walking one tree could see it half gone.
        for name in ["a", "b", "c", "d", "e", "keep"] {
            let tree = staging.appendingPathComponent(name, isDirectory: true)
            try FileManager.default.createDirectory(
                at: tree.appendingPathComponent("nested", isDirectory: true),
                withIntermediateDirectories: true
            )
            try Data("bytes".utf8).write(to: tree.appendingPathComponent("nested/file.txt", isDirectory: false))
        }

        // Both sweepers read the *same* machine-wide pasteboard, which is why both keep sets are
        // `keep`. Modelled as two stores over one staging root with pasteboards holding the same
        // manifest, since two processes are not something a unit test can have.
        let manifest = ClipboardManifest(
            copyID: "keep",
            boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
            kind: .card,
            container: .board,
            // One entry, because a manifest with none is refused outright (`init?(data:)`) — and a
            // refused manifest is a keep set of nothing, which is a different test.
            entries: [
                ClipboardManifest.Entry(
                    id: Ident.card1,
                    folder: Ident.card1,
                    title: "First",
                    index: "---\nschema: 1\ntitle: First\norder: 1024\n---\n",
                    attachmentCount: 0
                )
            ]
        )
        let data = try #require(manifest.encoded())

        let onePasteboard = FakePasteboard()
        onePasteboard.write(manifest: data, text: manifest.plainText)
        let otherPasteboard = FakePasteboard()
        otherPasteboard.write(manifest: data, text: manifest.plainText)

        // Each `init` sweeps — the launch sweep — so the two are already racing before either explicit
        // call below.
        let one = ClipboardStore(pasteboard: onePasteboard, stagingRoot: staging, observesActivation: false)
        let other = ClipboardStore(pasteboard: otherPasteboard, stagingRoot: staging, observesActivation: false)
        one.sweep()
        other.sweep()
        one.sweep()
        await one.stagingSettled()
        await other.stagingSettled()

        // The answer both computed, arrived at exactly once: the named tree intact, everything else
        // gone, and no bookkeeping left behind.
        #expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() == ["keep"])
        #expect(FileManager.default.fileExists(atPath: staging.appendingPathComponent("keep/nested/file.txt").path))
    }

    @Test("A tree that vanished before the sweep reached it is already swept, not an error")
    func aVanishedEntryIsANoOp() async throws {
        let staging = FileManager.default.temporaryDirectory
            .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: staging) }
        try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
        let doomed = staging.appendingPathComponent("gone", isDirectory: true)
        try FileManager.default.createDirectory(at: doomed, withIntermediateDirectories: true)

        let clipboard = ClipboardStore(
            pasteboard: FakePasteboard(),
            stagingRoot: staging,
            observesActivation: false
        )
        // Something got there first — which from this store's side is indistinguishable from the
        // directory listing simply being stale by the time it is walked.
        try FileManager.default.removeItem(at: doomed)
        clipboard.sweep()
        await clipboard.stagingSettled()

        #expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)

        // And a staging root that has gone altogether is nothing to do either, rather than a throw on
        // the way to a no-op.
        try FileManager.default.removeItem(at: staging)
        clipboard.sweep()
        await clipboard.stagingSettled()
        #expect(!FileManager.default.fileExists(atPath: staging.path), "a vanished store is not recreated by a sweep")
    }
}

// MARK: - Takeover

@MainActor
@Suite("ClipboardStore ▸ takeover")
struct ClipboardTakeoverTests {

    @Test("A changeCount that moved without us is a takeover: the payload goes")
    func payloadClears() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        #expect(harness.clipboard.payload != nil)

        harness.pasteboard.takeOver()
        harness.clipboard.refresh()
        #expect(harness.clipboard.payload == nil)
        #expect(harness.clipboard.canPaste(into: harness.store) == false)
    }

    @Test("An unchanged changeCount is one read and no decode")
    func refreshIsGuarded() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        let before = harness.pasteboard.changeCount
        harness.clipboard.refresh()
        harness.clipboard.refresh()
        // Nothing was written, so nothing moved — and the payload survived the two refreshes intact.
        #expect(harness.pasteboard.changeCount == before)
        #expect(harness.clipboard.payload != nil)
    }

    @Test("A takeover voids an armed cut and undims its items")
    func takeoverVoidsTheCut() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.cut(from: harness.store)
        #expect(harness.store.transient.pendingCut.ids == [clipboardCard1])

        harness.pasteboard.takeOver()
        harness.clipboard.refresh()
        #expect(harness.store.transient.pendingCut.isEmpty)
    }
}

// MARK: - Cut arming

@MainActor
@Suite("ClipboardStore ▸ cut")
struct ClipboardCutTests {

    @Test("A cut arms the source board's pending set, in flatten order membership")
    func armsPendingCut() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1, clipboardCard4], in: .board)
        harness.clipboard.cut(from: harness.store)

        #expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
        #expect(harness.store.transient.pendingCut.container == .board)
    }

    @Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
    func copyVoidsTheCut() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.cut(from: harness.store)
        #expect(!harness.store.transient.pendingCut.isEmpty)

        harness.store.select([clipboardCard2], in: .board)
        harness.clipboard.copy(from: harness.store)
        #expect(harness.store.transient.pendingCut.isEmpty)
    }

    @Test("Deletion voids per item: a deleted cut member leaves the pending set on reload")
    func deletionVoidsPerItem() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1, clipboardCard2], in: .board)
        harness.clipboard.cut(from: harness.store)

        harness.store.delete([clipboardCard1])
        harness.store.handleWatcherEvent(.treeChanged(.appMediated))
        await harness.store.awaitQuiescence()

        #expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
    }
}

// MARK: - Targeted copy/cut (the card context menu's own reading)

/// `copy(from:targeting:)`/`cut(from:targeting:)` — the card context menu's Copy/Cut
/// (`CardFaceView`, 2026-08-09 ▸ "redesign context menu for cards"), Delete's widening rule
/// (`targetIDs`) extended to the clipboard for the first time. The interesting claim these pin: the
/// explicit *target* wins over whatever the store's live selection happens to be — the
/// `TrashWriteTests.contextMenuDeleteIgnoresTheSelection` precedent, one type over.
@MainActor
@Suite("ClipboardStore ▸ targeted copy/cut")
struct ClipboardTargetedCopyCutTests {

    @Test("A targeted copy stages and writes the target, ignoring an unrelated live selection")
    func targetedCopyIgnoresTheSelection() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        // The live selection names a card the target never mentions.
        harness.store.select([clipboardCard4], in: .board)
        let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)

        harness.clipboard.copy(from: harness.store, targeting: target)
        await harness.clipboard.stagingSettled()

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.entries.map(\.id) == [Ident.card1])
    }

    @Test("A targeted cut arms the target, not the live selection")
    func targetedCutArmsTheTarget() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard4], in: .board)
        let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)

        harness.clipboard.cut(from: harness.store, targeting: target)

        #expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
        #expect(harness.store.transient.pendingCut.container == .board)
    }

    @Test("A targeted copy still supports multiple members, in flatten order")
    func targetedCopySupportsMultipleMembers() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }
        harness.store.select([clipboardCard4], in: .board)
        let target = ItemReferenceSet(ids: [clipboardCard1, clipboardCard2], container: .board)

        harness.clipboard.copy(from: harness.store, targeting: target)

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
    }

    @Test("canCopy/canCut targeting answer for the target, not the live selection")
    func canCopyCanCutReadTheTarget() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        // Nothing is live-selected at all, yet a target still enables both.
        let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)
        #expect(harness.clipboard.canCopy(from: harness.store, targeting: target))
        #expect(harness.clipboard.canCut(from: harness.store, targeting: target))
    }

    @Test("An open inline editor closes the targeted forms too — the focused-editor rule")
    func focusedEditorClosesTargetedForms() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }
        let target = ItemReferenceSet(ids: [clipboardCard1], container: .board)

        harness.store.transient.beginPlaceholder(inLane: clipboardLane1)
        #expect(!harness.clipboard.canCopy(from: harness.store, targeting: target))
        #expect(!harness.clipboard.canCut(from: harness.store, targeting: target))
    }

    /// **The lane context menu's own Copy/Cut** (`LaneView`, 2026-08-09 ▸ "redesign context menu for
    /// lanes") — the identical targeted overloads the card menu's Copy/Cut already exercise above,
    /// aimed at a lane id instead of a card id for the first time. Nothing in `ClipboardStore` branches
    /// on kind at the API surface, but `SelectionGrammar.kind(of:in:)` does internally
    /// (`canCopy(from:targeting:)`'s own guard), so this is worth its own test rather than assumed by
    /// analogy: a lane target must write a **lane** manifest, and it must ignore an unrelated live
    /// card selection exactly as the card menu's targeted copy ignores an unrelated live selection.
    @Test("A targeted copy/cut of a lane writes a lane manifest, ignoring an unrelated live selection")
    func targetedCopyAndCutWorkOnALaneID() async throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        // The live selection names a card the target never mentions — `LaneView.clipboardTarget`'s
        // own "right-clicking something outside the selection acts on what was clicked" widening.
        harness.store.select([clipboardCard4], in: .board)
        let target = ItemReferenceSet(ids: [clipboardLane1], container: .board)

        #expect(harness.clipboard.canCopy(from: harness.store, targeting: target))
        #expect(harness.clipboard.canCut(from: harness.store, targeting: target))

        harness.clipboard.copy(from: harness.store, targeting: target)
        await harness.clipboard.stagingSettled()

        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.kind == .lane)
        #expect(manifest.entries.map(\.id) == [Ident.lane1])

        harness.clipboard.cut(from: harness.store, targeting: target)
        #expect(harness.store.transient.pendingCut.ids == [clipboardLane1])
        #expect(harness.store.transient.pendingCut.container == .board)
    }
}

// MARK: - Availability

@MainActor
@Suite("ClipboardStore ▸ availability")
struct ClipboardAvailabilityTests {

    @Test("Copy needs a selection that names something the board renders")
    func copyNeedsASelection() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        #expect(harness.clipboard.canCopy(from: harness.store) == false)
        harness.store.select([clipboardCard1], in: .board)
        #expect(harness.clipboard.canCopy(from: harness.store))
    }

    /// 04-interactions.md ▸ The trash, resettled 2026-07-28: "⌘X **works** (it was disabled under
    /// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an
    /// ordinary folder move."
    @Test("Both copy and cut work on a trash selection — cut is the keyboard restore")
    func trashTakesCopyAndCut() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.transient.isTrashVisible = true
        harness.store.select([clipboardCard3], in: .trash)
        #expect(harness.clipboard.canCopy(from: harness.store))
        #expect(harness.clipboard.canCut(from: harness.store))
    }

    /// The row is a lane on the clipboard's own axis: "the payload kinds never mix because the
    /// selection never does" (04-interactions.md ▸ The trash), so a cut row writes a **lane** payload
    /// recorded in the **trash** container.
    @Test("A trashed lane row copies and cuts, and its payload is a lane in the trash")
    func trashedLaneRowTakesCopyAndCut() throws {
        let harness = try makeTrashedLaneHarness()
        defer { harness.tearDown() }

        harness.store.transient.isTrashVisible = true
        harness.store.select([clipboardTrashedLane], in: .trash)
        #expect(harness.clipboard.canCopy(from: harness.store))
        #expect(harness.clipboard.canCut(from: harness.store))

        harness.clipboard.cut(from: harness.store)
        let manifest = try #require(harness.clipboard.payload)
        #expect(manifest.kind == .lane)
        #expect(manifest.container == .trash)
        #expect(manifest.entries.map(\.title) == ["Done"])
        // The opaque unit describes itself and not its subtree: the *content* travels through the
        // staged folder, which is copied whole.
        #expect(manifest.entries.first?.cards.isEmpty == true)
        #expect(harness.store.transient.pendingCut
            == ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
    }

    /// **The guard the trash's kind-blind selection moved to the exits** (04-interactions.md ▸ The
    /// trash, ruled 2026-07-31): "the pasteboard's payload types are per-kind, so Cut and Copy grey
    /// out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no
    /// beep".
    ///
    /// It is also what keeps `ClipboardManifest.kind` honest: the manifest names one payload type,
    /// and a set spanning both never reaches the capture.
    @Test("A mixed trash selection greys out both Copy and Cut")
    func mixedTrashSelectionClosesCopyAndCut() throws {
        let harness = try makeTrashedLaneHarness()
        defer { harness.tearDown() }
        harness.store.transient.isTrashVisible = true

        // Each kind alone is fine — the selection is legal either way, and so is the gesture.
        harness.store.select([clipboardCard3], in: .trash)
        #expect(harness.clipboard.canCopy(from: harness.store))
        harness.store.select([clipboardTrashedLane], in: .trash)
        #expect(harness.clipboard.canCopy(from: harness.store))

        // Together — a selection the grammar now allows — the exits close.
        harness.store.select([clipboardCard3, clipboardTrashedLane], in: .trash)
        #expect(harness.store.selection.ids.count == 2, "the selection itself is legal")
        #expect(harness.clipboard.canCopy(from: harness.store) == false)
        #expect(harness.clipboard.canCut(from: harness.store) == false)
        // Delete is deliberately *not* gated: it works on a mixed selection, the alert counting both
        // kinds (04 ▸ The trash).
        #expect(TrashModel.canDelete(selection: harness.store.selection, in: harness.store.snapshot))
    }

    /// The live board's own mixed set cannot be built by any gesture — but the predicate answers for
    /// it anyway rather than assuming, so a future caller cannot smuggle one past the exits.
    @Test("The mixed-kind predicate answers for the live board too")
    func mixedKindPredicateCoversTheBoard() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }
        let snapshot = harness.store.snapshot
        let laneID = try #require(snapshot.lanes.first?.id)

        #expect(!SelectionGrammar.mixesKinds(
            ItemReferenceSet(ids: [clipboardCard1], container: .board), in: snapshot))
        #expect(SelectionGrammar.mixesKinds(
            ItemReferenceSet(ids: [clipboardCard1, laneID], container: .board), in: snapshot))
        // Rows the container no longer holds are ignored — a ghost must not grey out a menu item.
        #expect(!SelectionGrammar.mixesKinds(
            ItemReferenceSet(ids: [clipboardCard1, ItemID(rawValue: Ident.indexless)], container: .board),
            in: snapshot))
        #expect(!SelectionGrammar.mixesKinds(.empty, in: snapshot))
    }

    @Test("The read-only lock blocks cut but never copy")
    func lockBlocksCutOnly() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.store.enterVanishedRootLock()
        #expect(harness.clipboard.canCopy(from: harness.store))
        #expect(harness.clipboard.canCut(from: harness.store) == false)
        #expect(harness.clipboard.canPaste(into: harness.store) == false)
    }

    @Test("An open inline editor closes all three — the focused-editor rule")
    func focusedEditorClosesEverything() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")

        #expect(harness.clipboard.canCopy(from: harness.store) == false)
        #expect(harness.clipboard.canCut(from: harness.store) == false)
        #expect(harness.clipboard.canPaste(into: harness.store) == false)
    }

    @Test("Paste needs a payload")
    func pasteNeedsAPayload() throws {
        let harness = try makeClipboardHarness()
        defer { harness.tearDown() }

        #expect(harness.clipboard.canPaste(into: harness.store) == false)
        harness.store.select([clipboardCard1], in: .board)
        harness.clipboard.copy(from: harness.store)
        #expect(harness.clipboard.canPaste(into: harness.store))
    }

    @Test("On a zero-lane board a card payload disables paste and a lane payload does not")
    func zeroLaneBoard() async throws {
        let source = try makeClipboardBoard()
        defer { source.tearDown() }
        let empty = try WriterFixture()
        defer { empty.tearDown() }
        try empty.item("", Item.board)

        let staging = FileManager.default.temporaryDirectory
            .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: staging) }
        let clipboard = ClipboardStore(
            pasteboard: FakePasteboard(),
            stagingRoot: staging,
            observesActivation: false
        )
        let sourceStore = try BoardStore(rootURL: source.root)
        let emptyStore = try BoardStore(rootURL: empty.root)

        sourceStore.select([clipboardCard1], in: .board)
        clipboard.copy(from: sourceStore)
        #expect(clipboard.canPaste(into: emptyStore) == false)

        sourceStore.select([clipboardLane1], in: .board)
        clipboard.copy(from: sourceStore)
        #expect(clipboard.canPaste(into: emptyStore))
    }
}

// MARK: - The paste anchors

@MainActor
@Suite("PasteTarget")
struct PasteTargetTests {

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

    @Test("A card payload lands after the anchor card")
    func afterTheAnchorCard() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        let target = PasteTarget.cards(
            selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
            lastActiveLaneID: nil,
            snapshot: model
        )
        #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 1))
    }

    @Test("A selected lane appends to its bottom")
    func appendsToASelectedLane() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        let target = PasteTarget.cards(
            selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
            lastActiveLaneID: nil,
            snapshot: model
        )
        // Two rendered cards.
        #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
    }

    @Test("A multi-selection anchors at its last member in flatten order")
    func flattenOrderAnchor() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        let target = PasteTarget.cards(
            selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board),
            lastActiveLaneID: nil,
            snapshot: model
        )
        // `card4` is in the second lane, so it is last in flatten order however the set is spelled.
        #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
    }

    @Test("A trash selection never anchors: it behaves as nothing selected")
    func trashSelectionNeverAnchors() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        let target = PasteTarget.cards(
            selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
            lastActiveLaneID: clipboardLane2,
            snapshot: model
        )
        // The last-active lane, appended — the trash is never the destination.
        #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
    }

    @Test("Nothing selected and no last-active lane falls back to the first lane")
    func firstLaneFallback() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        let target = PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model)
        #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
    }

    @Test("A zero-lane board has no card target at all")
    func zeroLaneBoardHasNoCardTarget() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        let model = try snapshot(fixture)

        #expect(PasteTarget.cards(selection: .empty, lastActiveLaneID: nil, snapshot: model) == nil)
    }

    @Test("A lane payload lands after the anchor lane")
    func afterTheAnchorLane() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        #expect(PasteTarget.lanes(
            selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
            snapshot: model
        ) == 1)
    }

    @Test("A selected card names its lane for a lane paste")
    func aSelectedCardNamesItsLane() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        #expect(PasteTarget.lanes(
            selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
            snapshot: model
        ) == 1)
    }

    @Test("Nothing (or a trash selection) lands a lane at the board's right end")
    func rightEnd() throws {
        let fixture = try makeClipboardBoard()
        defer { fixture.tearDown() }
        let model = try snapshot(fixture)

        #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
        #expect(PasteTarget.lanes(
            selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
            snapshot: model
        ) == 2)
    }

    @Test("A zero-lane board still has a lane slot — position zero")
    func zeroLaneBoardStillTakesALane() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        let model = try BoardLoader.load(boardRoot: fixture.root).model

        #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 0)
    }
}

// MARK: - The refused paste's phrasing

/// **04-interactions.md ▸ Clipboard, re-ruled 2026-07-29** — refuse, never degrade:
///
/// > A paste whose staged snapshot is missing or unreadable refuses loudly — never degrades …
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
/// > metadata ("Couldn't paste 'Fix login' — the copied content is gone").
///
/// The retired suite these replace pinned `degradedPasteMessage(for:)` and its loss row — "Pasted
/// 'Fix login' without its 3 attachments". Both are gone with the degraded materialization: nothing
/// arrives, so there is no partial arrival to account for.
@Suite("BannerCenter ▸ refused paste")
struct RefusedPasteBannerTests {

    /// 04's own example sentence, composed the way every failure headline is: the action clause the
    /// banner owns, an em dash, the cause.
    @Test("04's own example sentence")
    @MainActor
    func theExampleSentence() {
        let center = BannerCenter()
        center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")

        #expect(center.oneShots.count == 1)
        let headline = try? #require(center.oneShots.first).error
        #expect(headline.map(BannerCenter.headline(for:)) == "Couldn't paste 'Fix login' — the copied content is gone")
    }

    /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is
    /// "the item" — `actionPhrase`'s standing convention for a failure with no title to quote.
    @Test("An untitled entry is 'the item', never the Untitled rendering")
    @MainActor
    func untitled() {
        let center = BannerCenter()
        center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc")
        let error = try? #require(center.oneShots.first).error
        #expect(error.map(BannerCenter.headline(for:)) == "Couldn't paste the item — the copied content is gone")
    }

    /// **The pivot, stated as a class change**: the degraded paste was a loss row because the items
    /// landed and only their attachments did not. A refusal is *a write that did not happen*, which is
    /// 02-architecture.md's own definition of a one-shot — so it ranks with the true failures, carries
    /// the error tone, and posts no loss row at all.
    @Test("A refused paste is an error-tone one-shot, not a loss row")
    @MainActor
    func refusalIsAOneShotNotALossRow() {
        let center = BannerCenter()
        center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")

        #expect(center.losses.isEmpty, "the degraded paste's loss row is retired")
        #expect(center.signposts.isEmpty)

        let rows = BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: center.oneShots, losses: [], suspension: nil, operations: []
        )
        #expect(rows.count == 1)
        #expect(rows[0].tone == .error)
        #expect(rows[0].dismissID == center.oneShots.first?.id)
    }

    /// The staging path is what the error names, so a bug report about a refusal has something to go
    /// on — the file that was not there.
    @Test("The refusal names the staged path it could not find")
    @MainActor
    func namesTheStagedPath() {
        let center = BannerCenter()
        center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
        #expect(center.oneShots.first?.error.path == "/tmp/staging/abc")
        #expect(center.oneShots.first?.error.reason == .clipboardContentGone)
        #expect(center.oneShots.first?.error.operation == .paste(title: "Fix login"))
    }

    /// **The loss class survives the retirement** — 02's warning-tone class still has live producers
    /// (a Finder drop that skipped folders, the app's own relocation and repair notices); only the
    /// degraded-paste row left it.
    @Test("The loss class still has its other producers")
    @MainActor
    func theLossClassSurvives() {
        let center = BannerCenter()
        center.postSkippedFolders(count: 2)
        #expect(center.losses.count == 1)
        #expect(center.losses.first?.message == "Folders can't be attached — 2 skipped")
    }
}
