import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban

/// `BoardStore`'s Finder file drops — the writes an external file drag performs
/// (04-interactions.md ▸ Drag and drop, "Files from Finder"): onto a card the files join its
/// `attachments/`, onto lane empty space they become one card each, and **folders are refused**.
///
/// Like every other write suite here these drive a **real store over a real temp board** and read
/// back through the loader or the raw bytes, never through a snapshot the store handed out: the
/// interesting claims are about the files — which name a colliding attachment landed under, which
/// rank a created card took, which card was removed again when a batch failed, and which folders were
/// never attempted at all. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
///
/// The gesture's half — which card is under the cursor, which slot the shadows show — is
/// `BoardDropContext`'s and is not unit-testable without a live window; here the target and the index
/// are simply given, which is the same split `DragWriteTests` makes.

// MARK: - Fixtures

private func tombstoned(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    created: 2026-01-01T09:00:00Z
    deleted: 2026-03-03T09:00:00Z
    ---
    \(title) body.

    """
}

/// Three cards in the first lane, one in the second — room for a run to land at the head, between
/// siblings, or at the end without either extreme being the only answer.
@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.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
    try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
    try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
    return fixture
}

/// The files being dropped, in a directory of their own **outside the board** — a Finder drag comes
/// from somewhere else by definition, and a source sitting inside the board would make "the original
/// is never a casualty" untestable.
private struct DropSources {
    let root: URL

    init() throws {
        root = FileManager.default.temporaryDirectory
            .appendingPathComponent("FileDropTests-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
    }

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

    /// A file at `relativePath`, creating its folder.
    @discardableResult
    func file(_ relativePath: String, _ bytes: Data = Data([0x01])) throws -> URL {
        let url = root.appendingPathComponent(relativePath)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try bytes.write(to: url)
        return url
    }

    /// A *folder* — what a Finder drag of a directory hands over, which the drop refuses.
    @discardableResult
    func folder(_ relativePath: String) throws -> URL {
        let url = root.appendingPathComponent(relativePath, isDirectory: true)
        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
        return url
    }

    /// A path with nothing at it — a **genuine** import failure, the kind the folder refusal is
    /// deliberately not: the source is a file as far as anyone can tell and the copy simply fails.
    func missing(_ relativePath: String) -> URL {
        root.appendingPathComponent(relativePath)
    }
}

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)

/// The board as the loader sees it — never the store's snapshot, which a drop deliberately does not
/// touch (the one-way flow: the write lands, the watcher reloads).
private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
    try BoardLoader.load(boardRoot: fixture.root).model
}

private func lane(_ id: ItemID, in fixture: WriterFixture) throws -> Lane? {
    try loaded(fixture).lanes.first { $0.id == id }
}

/// A lane's rendered card titles, in display order — `nil` for a card carrying no `title` key.
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String?] {
    guard let lane = try lane(laneID, in: fixture) else { return [] }
    return lane.cards.filter { !$0.isDeleted }.map(\.title.value)
}

/// A lane's rendered cards, in display order.
private func cards(_ laneID: ItemID, in fixture: WriterFixture) throws -> [Card] {
    guard let lane = try lane(laneID, in: fixture) else { return [] }
    return lane.cards.filter { !$0.isDeleted }
}

// MARK: - Attaching to a card

@MainActor
@Suite("BoardStore ▸ importAttachments(toCard:)")
struct ImportAttachmentsToCardTests {

    @Test("A multi-file drop lands every file in the card's attachments, originals untouched")
    func multipleFilesLand() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png", Data([0x89, 0x50]))
        let notes = try sources.file("notes.txt", Data([0x41, 0x42]))

        store.importAttachments([shot, notes], toCard: card1)

        #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
        #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data([0x41, 0x42]))
        // The face's indicator reads the same listing the importer wrote.
        #expect(try cards(lane1, in: fixture).first?.attachments == ["notes.txt", "shot.png"])
        #expect(try Data(contentsOf: shot) == Data([0x89, 0x50]), "a copy's origin, never its casualty")
        #expect(store.banners.oneShots.isEmpty)
    }

    /// **Attaching is not creating, so the query stands** (04-interactions.md § Search): the
    /// carve-out is for an item that would otherwise be born invisible, and a drop on a card mints
    /// nothing. Its twin — the *create* half clearing — is `creatingFromFilesClearsTheSearch` below.
    @Test("A drop that only attaches leaves the search exactly where it was")
    func attachingLeavesTheSearch() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")

        store.searchQuery = "first"
        store.importAttachments([shot], toCard: card1)

        #expect(store.searchQuery == "first")
    }

    @Test("A name already taken is renamed Finder-style rather than overwritten")
    func collisionsRename() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let first = try sources.file("a/shot.png", Data([0x01]))
        let second = try sources.file("b/shot.png", Data([0x02]))
        let third = try sources.file("c/shot.png", Data([0x03]))

        // Two drops, and a two-file drop whose members collide with each other — the rename counts up
        // against what is on disk at decision time either way.
        store.importAttachments([first], toCard: card1)
        store.importAttachments([second, third], toCard: card1)

        let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
        #expect(try fixture.entryNames(attachments) == ["shot 2.png", "shot 3.png", "shot.png"])
        #expect(try fixture.data("\(attachments)/shot.png") == Data([0x01]))
        #expect(try fixture.data("\(attachments)/shot 2.png") == Data([0x02]))
        #expect(try fixture.data("\(attachments)/shot 3.png") == Data([0x03]))
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A trashed or vanished target writes nothing")
    func inertTargets() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        // "Finder file drops on trash cards are inert" (04 ▸ The trash), and a card whose lane was
        // deleted is simply not there.
        try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")

        store.importAttachments([shot], toCard: card2)                              // a trash card
        store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4))      // its lane is gone
        store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless))  // no such card
        store.importAttachments([shot], toCard: lane1)                              // a lane, not a card
        store.importAttachments([], toCard: card1)                                  // nothing dropped

        #expect(!fixture.exists(".trash/\(Ident.card2)/attachments"))
        #expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
        #expect(!fixture.exists("\(Ident.lane1)/attachments"))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
        #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
    }

    /// The *genuine* failure path, which the folder ruling deliberately leaves alone: a source that
    /// cannot be read is a write that did not happen, so it banners as a one-shot and stops the batch
    /// — 04's folder refusal happens a layer above this and never reaches it (`FinderDrop`).
    @Test("A source that cannot be imported banners and leaves nothing half-copied")
    func aFailingSourceBanners() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")
        let gone = sources.missing("gone.png")

        store.importAttachments([shot, gone], toCard: card1)

        // The batch stops at the unreadable source, and what landed before it stays landed.
        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
        #expect(store.banners.oneShots.count == 1)
        #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png"))
        #expect(store.banners.losses.isEmpty, "a failure is a one-shot; a loss row says nothing failed")
    }
}

// MARK: - Creating cards from files

@MainActor
@Suite("BoardStore ▸ createCards(fromFiles:inLane:at:)")
struct CreateCardsFromFilesTests {

    @Test("One card per file, titled without its extension, each carrying its own file")
    func oneCardPerFile() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png", Data([0x01]))
        let notes = try sources.file("meeting notes.txt", Data([0x02]))

        // Between First and Second: the drop position, the same index the card zones produce.
        store.createCards(fromFiles: [shot, notes], inLane: lane1, at: 1)

        #expect(try titles(lane1, in: fixture) == ["First", "shot", "meeting notes", "Second", "Third"])

        let landed = try cards(lane1, in: fixture)
        #expect(landed.count == 5)
        // Fresh GUIDs — nothing was reused from the board's own identities.
        let created = [landed[1], landed[2]]
        #expect(!created.contains { [Ident.card1, Ident.card2, Ident.card3].contains($0.id.rawValue) })
        #expect(created[0].attachments == ["shot.png"])
        #expect(created[1].attachments == ["meeting notes.txt"])
        #expect(try fixture.data("\(Ident.lane1)/\(created[0].id.rawValue)/attachments/shot.png") == Data([0x01]))
        #expect(try fixture.data("\(Ident.lane1)/\(created[1].id.rawValue)/attachments/meeting notes.txt") == Data([0x02]))
        // Ranks are inserted, never permuted: the run sits strictly between its neighbours.
        #expect(created.allSatisfy { $0.order > 1024 && $0.order < 2048 })
        #expect(store.banners.oneShots.isEmpty)
    }

    /// One board per case, deliberately: a drop does not touch the snapshot (the one-way flow — the
    /// write lands, the watcher reloads), so two drops against one store would both be placed against
    /// the board as it was before either of them.
    @Test("The index is the drop position — head, interior, end, and past the end all land where they name")
    func indexPositioning() throws {
        let cases: [(index: Int, expected: [String?])] = [
            (0, ["dropped", "First", "Second", "Third"]),
            (2, ["First", "Second", "dropped", "Third"]),
            (3, ["First", "Second", "Third", "dropped"]),
            // A proposal computed against a snapshot one reload old must not trap: it clamps.
            (99, ["First", "Second", "Third", "dropped"]),
        ]

        for (index, expected) in cases {
            let fixture = try makeBoard()
            defer { fixture.tearDown() }
            let sources = try DropSources()
            defer { sources.tearDown() }
            let store = try BoardStore(rootURL: fixture.root)

            store.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)

            #expect(try titles(lane1, in: fixture) == expected, "index \(index)")
            #expect(store.banners.oneShots.isEmpty)
        }
    }

    /// **"Created cards land at the drop position — resolved through the same card-grid zones an
    /// ordinary card drag uses"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28). The
    /// gesture's half of that is `FileDropZones`; the *write*'s half is this: landing at index N must
    /// produce the ranks a card dropped at index N would have produced, not merely an order that
    /// happens to read right.
    @Test("A file landing at index N takes the very rank a card dropped at index N would take")
    func ranksMatchACardDropAtTheSameIndex() throws {
        for index in 0...3 {
            // The file drop: one card minted at `index` in Todo.
            let dropped = try makeBoard()
            defer { dropped.tearDown() }
            let sources = try DropSources()
            defer { sources.tearDown() }
            let fileStore = try BoardStore(rootURL: dropped.root)
            fileStore.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)

            // The card drop: Fourth dragged out of Doing into the same slot of the same lane. Its
            // neighbours are identical, so a shared rank arithmetic must answer identically.
            let moved = try makeBoard()
            defer { moved.tearDown() }
            let cardStore = try BoardStore(rootURL: moved.root)
            cardStore.moveCards([ItemID(rawValue: Ident.card4)], toLane: lane1, at: index)

            let droppedCards = try cards(lane1, in: dropped)
            let movedCards = try cards(lane1, in: moved)
            #expect(droppedCards.map(\.title.value)
                    == movedCards.map { $0.title.value == "Fourth" ? "dropped" : $0.title.value },
                    "index \(index): the run lands in the same position")
            #expect(droppedCards.map(\.order) == movedCards.map(\.order),
                    "index \(index): and takes the same ranks")
            #expect(droppedCards[index].order == movedCards[index].order)
            #expect(fileStore.banners.oneShots.isEmpty)
            #expect(cardStore.banners.oneShots.isEmpty)
        }
    }

    @Test("An empty lane takes the drop at its only position")
    func emptyLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
        let store = try BoardStore(rootURL: fixture.root)

        store.createCards(fromFiles: [try sources.file("only.md")], inLane: lane3, at: 0)

        #expect(try titles(lane3, in: fixture) == ["only"])
        #expect(try cards(lane3, in: fixture).first?.order == 1024, "the board convention")
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A filename that trims to nothing writes no title key at all")
    func blankTitleOmitsTheKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.createCards(fromFiles: [try sources.file(" .png")], inLane: lane2, at: 1)

        #expect(try titles(lane2, in: fixture) == ["Fourth", nil], "a missing key, never an empty string")
        let created = try #require(try cards(lane2, in: fixture).last)
        #expect(!(try fixture.indexText("\(Ident.lane2)/\(created.id.rawValue)").contains("title:")))
        #expect(created.attachments == [" .png"])
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("Exhausted midpoint precision compacts the lane, then places against the fresh ranks")
    func renumberFallback() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        // Two cards sharing one rank — the duplicate-order tie, where no midpoint exists. Display
        // order falls to the folder-name tie-break, so card1 renders before card2.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
        let store = try BoardStore(rootURL: fixture.root)

        store.createCards(fromFiles: [try sources.file("between.txt")], inLane: lane1, at: 1)

        #expect(try titles(lane1, in: fixture) == ["First", "between", "Second"])
        #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
        #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048), "the lane was compacted first")
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A deleted, vanished, or empty destination writes nothing")
    func inertDestinations() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")

        store.createCards(fromFiles: [shot], inLane: lane3, at: 0)                              // no such lane
        store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0)  // no such lane
        store.createCards(fromFiles: [], inLane: lane1, at: 0)                                  // nothing dropped

        #expect(!fixture.exists(Ident.lane3))
        #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
        #expect(store.banners.oneShots.isEmpty)
    }

    /// The mint-fail-remove dance, on the one path that still needs it: a *genuine* mid-batch failure.
    /// Folders never get this far any more (the drop partitions them out — `FinderDrop`), but a source
    /// that vanished between the drag and the drop still can, and the card minted for it must not
    /// survive its own import.
    @Test("A file that fails mid-batch banners, keeps the cards already made, and leaves no half-made one")
    func partialFailureLeavesNoHalfMadeCard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")
        let gone = sources.missing("gone.png")
        let after = try sources.file("after.txt")

        store.createCards(fromFiles: [shot, gone, after], inLane: lane1, at: 3)

        // The first file's card stands, the failed one's card was removed again, and the batch
        // stopped before the third — so the lane grew by exactly one.
        #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"])
        let landed = try cards(lane1, in: fixture)
        #expect(landed.count == 4)
        #expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one")
        #expect(landed.last?.attachments == ["shot.png"])
        #expect(store.banners.oneShots.count == 1)
        #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png"))
    }

    /// **The create half is a user-initiated creation, so it clears the query** (04-interactions.md
    /// § Search, stated by mechanism: "⌘N, Return-creation, empty-space double-click, paste, and
    /// Finder file drops alike"). The card is titled `shot`, which the
    /// standing query would hide — the whole point of the carve-out.
    @Test("A file drop that creates cards clears the search — the same rule ⌘N obeys")
    func creatingFromFilesClearsTheSearch() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")

        store.searchQuery = "first"
        store.createCards(fromFiles: [shot], inLane: lane1, at: 1)

        #expect(store.searchQuery.isEmpty)
    }

    /// The other side of the same rule: a drop that creates nothing clears nothing. A destination the
    /// reload took away is not a creation either — nothing was minted, so nothing could be born
    /// invisible.
    @Test("A drop with no destination creates nothing and leaves the query standing")
    func aRefusedCreateLeavesTheSearch() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")

        store.searchQuery = "first"
        store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: "no-such-lane"), at: 0)

        #expect(store.searchQuery == "first")
    }
}

// MARK: - Folders, refused

/// **"Folders are refused at hover"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28): the
/// attachment model is flat top-level files, so a drag of folders is an incompatible payload rather
/// than a failed import. The hover half — a folders-only drag never engaging — is
/// `BoardDropContext.acceptsFileDrop`'s and needs a live window; what is testable here is the drop
/// itself: `FinderDrop.land`, the write half `commitFileDrop` runs once the URLs have resolved.
///
/// The claim every case below shares: **the files land, the folders are named, and nothing failed** —
/// a loss row (warning tone), never an error one-shot.
@MainActor
@Suite("Finder drop ▸ folders are refused")
struct FinderDropFolderTests {

    @Test("A mixed drop on a card imports its file and names the folder it skipped")
    func mixedAttachDrop() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png", Data([0x89, 0x50]))
        let directory = try sources.folder("Project")

        FinderDrop.land([shot, directory], landing: .attach(cardID: card1), into: store)

        // The file imported whole — the folder did not abort the batch, it was never in it.
        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
        #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments/Project"))
        #expect(store.banners.oneShots.isEmpty, "nothing failed: the folder was never attempted")
        #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
    }

    @Test("A mixed drop on lane empty space makes one card per file and none for the folder")
    func mixedCreateDrop() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let shot = try sources.file("shot.png")
        let directory = try sources.folder("Project")
        let after = try sources.file("after.txt")

        FinderDrop.land([shot, directory, after], landing: .create(laneID: lane1, index: 3), into: store)

        // Two cards, in drop order, and no half-made third: the file *after* the folder imports,
        // which is the whole difference from the old abort-at-the-folder behaviour.
        #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot", "after"])
        let landed = try cards(lane1, in: fixture)
        #expect(landed.count == 5)
        #expect(try fixture.entryNames(Ident.lane1).count == 6, "index.md, three cards, two new ones")
        #expect(landed[3].attachments == ["shot.png"])
        #expect(landed[4].attachments == ["after.txt"])
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
    }

    /// Unreachable through the gesture — a folders-only drag never engages — but reachable when the
    /// hover read could not classify the payload (a provider carrying only `public.file-url`), so it
    /// has a defined answer: no write at all, just the loss row.
    @Test("A folders-only drop writes nothing at all and only names the loss")
    func foldersOnly() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let first = try sources.folder("Project")
        let second = try sources.folder("Archive")

        FinderDrop.land([first, second], landing: .create(laneID: lane1, index: 0), into: store)

        #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "no card was minted")
        #expect(try fixture.entryNames(Ident.lane1).count == 4, "index.md and the three cards")
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 2 skipped"])
    }

    @Test("A folders-only drop on a card leaves it without an attachments folder at all")
    func foldersOnlyOnACard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        FinderDrop.land([try sources.folder("Project")], landing: .attach(cardID: card1), into: store)

        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"), "no write, not an empty one")
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
    }

    @Test("An all-files drop says nothing — a drop that lost nothing has nothing to report")
    func allFilesIsSilent() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try DropSources()
        defer { sources.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        FinderDrop.land([try sources.file("shot.png")], landing: .attach(cardID: card1), into: store)

        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
        #expect(store.banners.losses.isEmpty)
        #expect(store.banners.oneShots.isEmpty)
    }
}

// MARK: - What the drag is carrying

/// The two reads `FinderDrop` makes of a Finder drag: the **declared types** at hover, which decide
/// whether the drag engages at all, and the **filesystem** at the drop, which decides what is
/// written. 04's refusal rule is a hover rule, so the first one is what gives it its cursor.
@Suite("Finder drop ▸ what the drag is carrying")
struct FinderDropPayloadTests {

    /// A provider announcing one declared type and nothing else — the hover read's whole input.
    @MainActor
    private func provider(_ type: UTType) -> NSItemProvider {
        let provider = NSItemProvider()
        provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) {
            completion in
            completion(Data(), nil)
            return nil
        }
        return provider
    }

    @Test("Conformance to public.directory is the test — folders and packages alike")
    func directoryTypes() {
        #expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"]))
        // A package is a directory, and the flat attachment model has no more room for one than for
        // a plain folder: a .app, a bundle, an .rtfd.
        #expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.application-bundle", "public.file-url"]))
        #expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.package"]))
        #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.png", "public.file-url"]))
        #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.data", "public.item"]))
    }

    @Test("An unclassifiable payload reads as a file — optimistic at hover, sorted out at the drop")
    func unknownTypesAreOptimistic() {
        // The synthetic shape: a provider registering the URL type and nothing concrete.
        #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.file-url", "public.url"]))
        #expect(!FinderDrop.isDirectory(typeIdentifiers: []))
        #expect(!FinderDrop.isDirectory(typeIdentifiers: ["not.a.real.type"]))
        // `NSItemProvider(contentsOf:)` gives a folder a *dynamic* type rather than public.folder,
        // which is exactly the case the optimistic read exists for: such a drag still engages, and
        // the drop's own read is what refuses it.
        #expect(!FinderDrop.isDirectory(typeIdentifiers: ["dyn.age8u"]))
    }

    @MainActor
    @Test("The importable count is the file count — folders are not counted, so they draw no shadow")
    func importableCountCountsFilesOnly() {
        #expect(FinderDrop.importableCount([provider(.png), provider(.folder)]) == 1)
        #expect(FinderDrop.importableCount([provider(.png), provider(.plainText)]) == 2)
        // Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages.
        #expect(FinderDrop.importableCount([provider(.folder), provider(.folder)]) == 0)
        #expect(FinderDrop.importableCount([]) == 0)
    }

    /// **"One nominal-height shadow per incoming file"** (04-interactions.md ▸ Drag and drop, settled
    /// 2026-07-28 — the multi-drag precedent), **"and when macOS withholds item counts during hover
    /// the count floors at one shadow, the commit unaffected"**.
    @MainActor
    @Test("The shadow run is one shadow per importable file, floored at one")
    func shadowCountIsTheImportableCountFlooredAtOne() {
        #expect(FinderDrop.shadowCount([provider(.png)]) == 1)
        #expect(FinderDrop.shadowCount([provider(.png), provider(.plainText)]) == 2)
        #expect(FinderDrop.shadowCount([provider(.png), provider(.plainText), provider(.pdf)]) == 3)
        // A mixed drag's shadows agree with what will actually be minted: files only.
        #expect(FinderDrop.shadowCount([provider(.png), provider(.folder), provider(.folder)]) == 1)

        // The floor. A drag whose providers the system will not count still opens a slot the user can
        // aim at; the write counts resolved URLs, so a lone shadow standing in for a whole drag costs
        // the drop nothing.
        #expect(FinderDrop.shadowCount([]) == 1)
        #expect(FinderDrop.shadowCount([provider(.folder), provider(.folder)]) == 1)

        // Everywhere the count is real, it is exactly the importable count.
        for payload in [[provider(.png)], [provider(.png), provider(.folder), provider(.plainText)]] {
            #expect(FinderDrop.shadowCount(payload) == FinderDrop.importableCount(payload))
        }
    }

    @Test("The drop reads the filesystem, which is the authority a declared type is not")
    func filesystemIsAuthoritative() throws {
        let sources = try DropSources()
        defer { sources.tearDown() }
        let shot = try sources.file("shot.png")
        let notes = try sources.file("notes.txt")
        let project = try sources.folder("Project")
        let bundle = try sources.folder("Thing.app")   // a package: a directory like any other

        #expect(FinderDrop.isDirectory(at: project))
        #expect(FinderDrop.isDirectory(at: bundle))
        #expect(!FinderDrop.isDirectory(at: shot))

        let (files, folders) = FinderDrop.partition([shot, project, notes, bundle])
        #expect(files == [shot, notes], "input order survives — the create path mints in drop order")
        #expect(folders == [project, bundle])
    }
}

// MARK: - The title rule

@Suite("BoardStore ▸ cardTitle(forFile:)")
struct CardTitleForFileTests {

    @Test("The filename without its extension — URL's split, which is Finder's")
    func titles() {
        func title(_ name: String) -> String? {
            BoardStore.cardTitle(forFile: URL(fileURLWithPath: "/sources/\(name)"))
        }

        #expect(title("shot.png") == "shot")
        #expect(title("meeting notes.txt") == "meeting notes")
        // A multi-dot name loses only its last component, exactly as the collision rename splits it.
        #expect(title("archive.tar.gz") == "archive.tar")
        #expect(title("notes") == "notes", "an extension-less name keeps all of itself")
        #expect(title(".gitignore") == ".gitignore", "a dotfile is a name, not an extension")
        #expect(title(" spaced .png") == "spaced", "trimmed, like every other title the app commits")
        #expect(title(" .png") == nil, "trims to nothing: no title key, never an empty string")
    }
}

// MARK: - Raw reads

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