import AppKit
import Foundation
import Testing
@testable import Kanban

/// The drag session's **value** halves — the pasteboard payload, the locality model, and the
/// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// The session object itself, the drop delegates and the gestures are not unit-testable — they are
/// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls
/// where it does.

// MARK: - The payload

@Suite("DragPayload")
struct DragPayloadTests {

    private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload {
        DragPayload(
            boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
            kind: kind,
            container: container,
            items: [
                DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
                DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
            ]
        )
    }

    @Test("A payload round-trips through its JSON representation unchanged")
    func roundTrip() throws {
        for kind in [DragKind.cards, .lanes] {
            for container in [ItemContainer.board, .trash] {
                let original = Self.payload(kind: kind, container: container)
                let data = try #require(original.encoded())
                #expect(DragPayload(data: data) == original)
            }
        }
    }

    @Test("Garbage decodes to nothing rather than to an empty drag")
    func garbageDecodesToNil() {
        #expect(DragPayload(data: Data("not json".utf8)) == nil)
        #expect(DragPayload(data: Data()) == nil)
    }

    @Test("The ids, folders and root are read back off the strings, in flatten order")
    func derivedValues() {
        let payload = Self.payload()
        #expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")])
        #expect(payload.folders.map(\.path) == [
            "/Boards/Work.kanban/lane/aaa",
            "/Boards/Work.kanban/lane/bbb"
        ])
        #expect(payload.rootURL.path == "/Boards/Work.kanban")
    }

    @Test("The plain-text representation is the dragged titles, one per line")
    func plainText() {
        // The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders
        // it — "Untitled" is a rendering, never a value (03-board-ui.md § Card face).
        #expect(Self.payload().plainText == "First\nUntitled")
    }

    @Test("The container survives the round trip, because it is what makes a trash drag a trash drag")
    func containerSurvives() throws {
        let data = try #require(Self.payload(container: .trash).encoded())
        #expect(DragPayload(data: data)?.container == .trash)
    }
}

// MARK: - Board identity

/// **The one canonical spelling every locality comparison is made against** (`BoardRootKey`).
///
/// Minting is where the filesystem is touched — once per board, at `BoardStore.rootKey` — and these
/// are the claims that one touch has to buy: every spelling of a place is one key, and two places
/// are two keys.
@Suite("BoardRootKey")
struct BoardRootKeyTests {

    // Instance members, not `static`: every case below names them bare, and a static member is not
    // reachable unqualified from an instance method. Swift Testing builds a fresh instance per test,
    // so these are as constant either way.
    private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
    private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)

    @Test("Roots key on their standardized path, so the same board is the same board")
    func standardization() {
        #expect(BoardRootKey(here) == BoardRootKey(here))
        #expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/")))
        #expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
        #expect(BoardRootKey(here) != BoardRootKey(there))
    }

    /// Why the key resolves at all: "a board reached through a pinned symlink is the same board as
    /// the one reached directly" (01-storage-format.md's symlink pins). A real link over a real
    /// folder, because the claim is a filesystem fact and nothing else can stand in for one.
    @Test("A board reached through a pin is the board the pin points at")
    func pinsNameTheirTarget() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let real = fixture.url("Work.kanban")
        try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
        let pin = fixture.url("Pinned.kanban")
        try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real)

        #expect(BoardRootKey(pin) == BoardRootKey(real))
        // The key names the target rather than the link — the pin is a spelling, not a second board.
        #expect(BoardRootKey(pin).path.hasSuffix("Work.kanban"))
        // A sibling the link does not point at stays a board of its own.
        #expect(BoardRootKey(pin) != BoardRootKey(fixture.url("Other.kanban")))
    }

    /// **The store mints once and keeps both**: the key is the board's identity, and `rootURL` keeps
    /// the user's spelling because the folder name is the board's display-name fallback
    /// (01-storage-format.md § Frontmatter) — canonicalizing there would visibly rename a board
    /// opened through a pin.
    @MainActor
    @Test("A board opened through a pin keeps the pin's spelling and the target's identity")
    func theStoreKeepsTheSpellingAndCachesTheIdentity() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let real = try fixture.item("Work.kanban", Item.board)
        let pin = fixture.url("Pinned.kanban")
        try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real)

        let store = try BoardStore(rootURL: pin)
        #expect(store.rootKey == BoardRootKey(real))
        #expect(store.rootURL.lastPathComponent == "Pinned.kanban")
    }

    /// The key follows the folder. A board renamed away and a new board opened at the vacated path
    /// are two boards, and nothing about the drag they share may say otherwise — which is only true
    /// if the relocation re-mints.
    @MainActor
    @Test("An absorbed relocation re-mints the key, so the vacated path is somebody else's board")
    func relocationRemintsTheKey() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let original = try fixture.item("Work.kanban", Item.board)
        let store = try BoardStore(rootURL: original)
        let before = store.rootKey

        let moved = fixture.url("Renamed.kanban")
        try FileManager.default.moveItem(at: original, to: moved)
        store.relocate(to: moved)

        #expect(store.rootKey == BoardRootKey(moved))
        #expect(store.rootKey != before)

        let successor = try BoardStore(rootURL: try fixture.item("Work.kanban", Item.board))
        #expect(successor.rootKey != store.rootKey)
    }
}

// MARK: - Locality

@Suite("DragLocality")
struct DragLocalityTests {

    private let none: NSEvent.ModifierFlags = []
    private let option: NSEvent.ModifierFlags = [.option]
    private let command: NSEvent.ModifierFlags = [.command]

    /// The Finder volume model: within a board a drag rearranges, between boards it transfers.
    @Test("Locality picks the default — within is a move, across is a copy")
    func theDefault() {
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move)
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
        #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy)
    }

    @Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
    func modifiersOverride() {
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy)
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move)
        // The no-ops.
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move)
        #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy)
    }

    @Test("⌘ wins over ⌥ when both are held")
    func commandWinsOverOption() {
        // Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
        #expect(DragLocality.operation(
            kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move)
    }

    /// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
    /// the drag stays a clean reorder and the badge never shows copy."
    @Test("A within-board lane drag ignores ⌥ entirely")
    func laneDragsNeverCopyWithinTheirBoard() {
        for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
            #expect(
                DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move,
                "a within-board lane drag is a reorder whatever is held"
            )
        }
        // Across boards the lane obeys the ordinary grammar again.
        #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy)
        #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move)
    }

    /// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
    /// own board the default is the restore — a move, no badge; across boards the default is the live
    /// copy that leaves the tombstone standing. ⌘ forces the true restore-move either way, and ⌥ the
    /// live copy either way.
    @Test("A trash row drags as a restore at home and as a copy-out abroad")
    func trashDragDefaults() {
        #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move)
        #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy)
        #expect(DragLocality.operation(
            kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
        #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy)
    }
}

// MARK: - Dropping on the trash

/// **The delete gesture's gate** (04-interactions.md ▸ The trash, settled 2026-07-28: "dropping a
/// live card on the shown trash deletes it").
///
/// One pure function decides it, and it is asked twice — once at hover for the shadow and once at
/// release for the write — so every clause below is a claim about both.
@Suite("TrashDrop")
struct TrashDropTests {

    /// The accepted session, with one clause at a time knocked out by the cases.
    private func accepts(
        kind: DragKind? = .cards,
        container: ItemContainer = .board,
        isWithinBoard: Bool = true,
        operation: TransferOperation = .move,
        isTrashShown: Bool = true,
        acceptsMutations: Bool = true
    ) -> Bool {
        TrashDrop.accepts(
            kind: kind,
            container: container,
            isWithinBoard: isWithinBoard,
            operation: operation,
            isTrashShown: isTrashShown,
            acceptsMutations: acceptsMutations
        )
    }

    /// "The shadow always takes the topmost position — which the sort makes honest, not arbitrary:
    /// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top."
    @Test("The landing is the topmost row, always")
    func theTopmostRow() {
        #expect(TrashDrop.landingIndex == 0)
    }

    @Test("A live, same-board, unmodified card drag is the one session the trash takes")
    func theOneItTakes() {
        #expect(accepts())
        // ⌘ forces move, which is already the default here, so it changes nothing.
        #expect(accepts(operation: .move))
    }

    /// "Dropping a live card — **or lane** — on the shown trash deletes it … a lane drag over the
    /// shown trash proposes the delete alongside its strip slots" (04-interactions.md ▸ The trash,
    /// lanes extended 2026-07-29, retiring "a lane drag proposes only lane slots").
    @Test("A live lane drag proposes the delete too — and no session proposes nothing")
    func lanesAreDeliverable() {
        #expect(accepts(kind: .lanes))
        // Every other clause binds the lane exactly as it binds the card: a trashed row dragged out
        // is not deletable back into the place it already is, a foreign board's lane is refused, and
        // a hidden column takes nothing.
        #expect(!accepts(kind: .lanes, container: .trash))
        #expect(!accepts(kind: .lanes, isWithinBoard: false))
        #expect(!accepts(kind: .lanes, isTrashShown: false))
        #expect(!accepts(kind: .lanes, acceptsMutations: false))
        // And no session at all is no proposal either — the column is inert between drags.
        #expect(!accepts(kind: nil))
    }

    /// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
    /// never proposes.
    @Test("A trash card dropped back on the trash is refused")
    func theTrashContainerIsRefused() {
        #expect(!accepts(container: .trash))
        #expect(!accepts(container: .trash, isWithinBoard: false))
    }

    /// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
    /// would be a transfer-and-delete compound, which the design names nowhere.
    @Test("A foreign board's card is refused")
    func crossBoardIsRefused() {
        #expect(!accepts(isWithinBoard: false))
        // Not even with ⌘, which forces the move a cross-board drag would otherwise only copy.
        #expect(!accepts(isWithinBoard: false, operation: .move))
    }

    /// Copying into the trash is not a thing — and the alternative would be tombstoning an original
    /// the copy grammar had just promised to leave exactly where it was.
    @Test("⌥ is refused rather than reinterpreted")
    func optionCopyIsRefused() {
        #expect(!accepts(operation: .copy))
    }

    /// "The trash stays undroppable-into while hidden, like every gesture."
    @Test("Hidden, the trash is invisible to the gesture")
    func hiddenIsInert() {
        #expect(!accepts(isTrashShown: false))
    }

    @Test("The mutating-gesture rule applies, like every other write the pointer can start")
    func theLockAndTheEditorRefuse() {
        #expect(!accepts(acceptsMutations: false))
    }
}

// MARK: - The mixed-kind drag out of the trash

/// **"A mixed-kind drag never leaves the trash"** (04-interactions.md ▸ The trash, ruled 2026-07-31
/// with kind-blind trash selection): "pickup is allowed — the selection is legal — but every
/// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining
/// the rule … the refused drag ends like any refusal, rows staying put".
///
/// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not
/// unit-testable — the same split every other drop suite makes. What is testable is the whole of
/// what the refusal is *made* of: the flag a pickup records, and the notice the release posts.
@MainActor
@Suite("The mixed-kind drag out of the trash")
struct MixedTrashDragTests {

    private static let card1 = ItemID(rawValue: Ident.card1)
    private static let lane1 = ItemID(rawValue: Ident.lane1)

    /// One lane and one trashed card — enough for a store to exist and a session to name folders.
    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(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed"))
        return fixture
    }

    /// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a
    /// per-kind payload — so nothing falls silently out of the drag.
    @Test("A pickup records whether its selection spanned both kinds")
    func theFlagTravels() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)

        session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store)
        #expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag")

        session.beginCards(
            [Self.card1], folders: [folder], heights: [44],
            container: .trash, source: store, mixesKinds: true
        )
        #expect(session.mixesKinds)
        #expect(session.container == .trash)

        // The lane level records it the same way, and a trashed lane row's session is in `.trash` —
        // which is what routes its release to the restore rather than to a strip permutation.
        session.beginLanes(
            [Self.lane1], folders: [folder], units: [1],
            container: .trash, source: store, mixesKinds: true
        )
        #expect(session.mixesKinds)
        #expect(session.container == .trash)

        // And an ordinary strip drag is unaffected: board container, no flag.
        session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store)
        #expect(!session.mixesKinds)
        #expect(session.container == .board)
    }

    /// The notice is 04's own sentence, and it is a **loss row** — nothing failed and no write was
    /// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register).
    @Test("The release's notice is the rule, in the design's own words")
    func theNoticeExplainsTheRule() {
        let banners = BannerCenter()
        banners.postMixedTrashDrag()

        #expect(banners.losses.map(\.message)
            == ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"])
        #expect(banners.oneShots.isEmpty, "no write failed — this is not an error row")
    }
}

// MARK: - The committed-overlay hold

@Suite("CommittedHold")
struct CommittedHoldTests {

    private static let here = BoardRootKey(URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true))
    private static let there = BoardRootKey(URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true))

    private static let hold = CommittedHold(boardRoot: here, generation: 7)

    @Test("The hold stands until the destination board applies a *newer* snapshot")
    func retiredByTheNextSnapshot() {
        // The generation at the commit is the one already on screen — it is the pre-drop arrangement,
        // and retiring on it would drop the overlay before the write has round-tripped.
        #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7))
        #expect(Self.hold.isRetired(byRoot: Self.here, generation: 8))
        // *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first
        // re-grounds everything anyway.
        #expect(Self.hold.isRetired(byRoot: Self.here, generation: 99))
    }

    @Test("A reload on another board says nothing about this one")
    func otherBoardsDoNotRetireIt() {
        #expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99))
    }

    @Test("The board is matched by identity, not by string")
    func rootMatchingUsesTheLocalityComparison() {
        let sameBoard = BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/"))
        #expect(Self.hold.isRetired(byRoot: sameBoard, generation: 8))
    }

    @Test("A stale generation never retires it")
    func staleGenerations() {
        #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0))
        #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6))
    }

    /// The figure 03-board-ui.md fixes for a hold with no echo coming: long enough for a write plus a
    /// watcher round trip, short enough that a refused write does not leave the board drawing an
    /// arrangement it never got.
    @Test("The deadline is the design's own figure")
    func theTimeoutFigure() {
        #expect(CommittedHold.timeout == .milliseconds(1500))
    }
}

// MARK: - The committed-overlay hold, in the session

/// **What the session renders between the release and the echo** (DRAG-REORDER.md § The
/// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session
/// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals
/// lifted out — until the destination store applies its next snapshot, or the deadline says none is
/// coming. The claims here are the pure state the board's surfaces read (`laneProposal`,
/// `trashProposal`, `hiddenMembers`), so the whole ruling is checkable without a view.
///
/// A **real store over a real temp board**, like the write suites: `commit` names the destination
/// store, and the session's own re-grounding reads that store's transient state, so a stub would be
/// standing in for exactly the thing under test. Nothing here writes.
@MainActor
@Suite("The committed hold")
struct DropSettleTests {

    private static let lane1 = ItemID(rawValue: Ident.lane1)
    private static let card1 = ItemID(rawValue: Ident.card1)
    private static let card2 = ItemID(rawValue: Ident.card2)

    /// Two cards in the first lane — enough for a run of two, and for one of them to vanish
    /// mid-flight while the other still lands.
    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"))
        return fixture
    }

    /// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`.
    ///
    /// The titles ride along only so the fixtures read as the cards they name — nothing in the
    /// session reads them.
    private func proposing(
        _ store: BoardStore,
        members: [(id: ItemID, title: String?)] = [(card1, "First")],
        at index: Int = 2
    ) -> DragSession {
        let session = DragSession()
        pickUp(session, from: store, members: members)
        session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index))
        return session
    }

    /// The pickup half, on a session that may already have had a life — the second drag in
    /// `aRetiredHoldsTimeoutIsCancelled` is the whole reason it is separable.
    private func pickUp(
        _ session: DragSession,
        from store: BoardStore,
        members: [(id: ItemID, title: String?)] = [(card1, "First")]
    ) {
        session.beginCards(
            members.map(\.id),
            folders: members.map {
                store.rootURL
                    .appendingPathComponent(Ident.lane1, isDirectory: true)
                    .appendingPathComponent($0.id.rawValue, isDirectory: true)
            },
            heights: members.map { _ in 44 },
            container: .board,
            source: store
        )
    }

    /// Polls for `condition`, because the timeout's discard is a `Task` on this very actor: the test
    /// has to yield for it to run at all. Bounded, so a discard that never comes fails rather than
    /// hangs.
    private func settles(_ condition: () -> Bool) async -> Bool {
        for _ in 0..<200 {
            if condition() { return true }
            try? await Task.sleep(for: .milliseconds(5))
        }
        return condition()
    }

    // MARK: In flight

    @Test("While the drag is in flight the shadow run opens at the proposal and the originals are lifted out")
    func inFlightDrawsShadows() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)

        #expect(!session.isSettled)
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }

    // MARK: The hold

    /// "The session flips from proposing to committed and keeps rendering the arrangement it was
    /// showing": the write is on its way but the snapshot has not moved, so nothing about the
    /// release may change what is on screen — the shadows stay at their landing slot and the
    /// originals stay lifted out until the echo reload brings the real faces.
    @Test("A committed hold keeps the shadows at their landing slot and the originals lifted out")
    func theHoldKeepsTheArrangement() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)

        session.commit(into: store)

        #expect(session.isSettled)
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }

    @Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
    func settledProposalsAreFinal() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        session.commit(into: store)

        session.propose(nil)
        session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0))

        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
    }

    // MARK: The trash's landing

    /// A session proposing into the trash column rather than into a lane — the delete gesture
    /// (04-interactions.md ▸ The trash).
    private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession {
        let session = DragSession()
        pickUp(session, from: store, members: members)
        session.propose(DropTarget(
            boardRoot: store.rootKey,
            container: .trash,
            index: TrashDrop.landingIndex
        ))
        return session
    }

    @Test("The trash's shadow opens at the topmost row, and no lane draws one")
    func trashInFlightDrawsTheTopRow() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposingIntoTheTrash(store)

        #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
        // The proposal names one container and one only: the lane the cards came out of draws
        // nothing, and neither does the strip.
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
        #expect(session.stripProposal(onBoardRooted: store.rootKey) == nil)
        // And they are lifted out of the lane, as ever.
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }

    /// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
    /// the space they landed in until the echo brings the real tombstones.
    @Test("A committed trash drop keeps its shadow rows on top and the originals lifted")
    func trashHoldKeepsTheRows() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])

        session.commit(into: store)

        #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
        #expect(session.shadowCount == 2)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])
    }

    /// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
    /// 2026-07-29): the accessor asks "is this proposal mine", and the kind question belongs to
    /// `TrashDrop.accepts`, which is asked at hover and again at release.
    @Test("A lane session's trash proposal reaches the column, and only on its own board")
    func laneSessionsProposeIntoTheTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        session.beginLanes(
            [Self.lane1],
            folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
            units: [1],
            source: store
        )
        session.propose(DropTarget(boardRoot: store.rootKey, container: .trash, index: 0))

        #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
        #expect(session.shadowCount == 1)
        // Another board's column draws nothing, like every other proposal accessor.
        #expect(session.trashProposal(onBoardRooted: BoardRootKey(URL(filePath: "/tmp/other-board"))) == nil)
    }

    // MARK: The hand-off

    @Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again")
    func handOffClearsEverything() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        session.commit(into: store)

        session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1)

        #expect(!session.isSettled)
        #expect(!session.isActive)
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
    }

    @Test("Ending the session outright ends the hold with it")
    func endClearsTheHold() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        session.commit(into: store)

        session.end()

        #expect(!session.isSettled)
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
        #expect(store.transient.dragMembers.ids.isEmpty)
    }

    // MARK: The timeout — the failed write's path

    /// "A failed write discards the proposal and the board animates back to snapshot order"
    /// (03-board-ui.md § Motion). A write refused outright produces no reload at all, so the deadline
    /// is the only thing standing between the board and an arrangement it never got.
    @Test("A hold with no echo coming times out, and the board is left with its snapshot order")
    func theTimeoutDiscardsTheHold() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        // The seam: the real figure is `CommittedHold.timeout`, and waiting it out would be 1.5 s of
        // wall clock in the suite for a claim about the discard rather than about the clock.
        session.holdTimeout = .milliseconds(20)

        session.commit(into: store)
        #expect(session.isSettled)

        #expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
        #expect(!session.isActive)
        #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
        #expect(store.transient.dragMembers.ids.isEmpty)
    }

    /// The discard's own body, called directly — the half of the timeout that is a decision rather
    /// than a wait, and the guard that makes the wait harmless.
    @Test("The discard ends the hold it was armed for, and no other")
    func theDiscardEndsOnlyItsOwnHold() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        session.commit(into: store)
        let hold = try #require(session.hold)

        session.expire(CommittedHold(boardRoot: store.rootKey, generation: 999))
        #expect(session.isSettled, "a hold this session is not holding is not this session's to end")

        session.expire(hold)
        #expect(!session.isSettled)
        #expect(!session.isActive)
    }

    @Test("A retired hold's deadline never reaches the next drag")
    func aRetiredHoldsTimeoutIsCancelled() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = proposing(store)
        session.holdTimeout = .milliseconds(20)
        session.commit(into: store)

        // The echo lands well inside the deadline, and the user starts another drag immediately —
        // the lifecycle trap the watchdog was written for, at the hold's end of the session.
        session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1)
        pickUp(session, from: store, members: [(Self.card2, "Second")])

        try? await Task.sleep(for: .milliseconds(80))
        #expect(session.isActive, "the retired hold's deadline must not end the drag that followed it")
        #expect(session.hold == nil)
    }
}

// MARK: - What the source board's resting layout holds

/// **The resting layout follows the effective operation** (DRAG-REORDER.md § Resting-layout zones,
/// ruled 2026-08-01; 04-interactions.md ▸ Drag and drop: "originals stay" for both copies): a move
/// lifts the dragged run out of the source board, a copy leaves it standing there, because that is
/// what the release will actually leave behind. The whole rule is `hiddenMembers(onBoardRooted:)`,
/// and every surface that builds a resting layout — the lane masonries, the strip, the drop zones —
/// reads it, so pinning it here pins all of them.
///
/// The modifiers are passed to `resolveOperation` rather than held down: it is the same seam
/// `DragLocalityTests` uses one level down, and it is what makes the flip checkable without a
/// keyboard.
@MainActor
@Suite("The source board's resting layout")
struct DragRestingLayoutTests {

    private static let lane1 = ItemID(rawValue: Ident.lane1)
    private static let card1 = ItemID(rawValue: Ident.card1)
    private static let card2 = ItemID(rawValue: Ident.card2)

    private let none: NSEvent.ModifierFlags = []
    private let option: NSEvent.ModifierFlags = [.option]
    private let command: NSEvent.ModifierFlags = [.command]

    /// Another board entirely — the right-hand side of every cross-board resolution below. It needs
    /// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
    private let elsewhere = BoardRootKey(URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true))

    /// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
    /// real to name.
    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(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
        return fixture
    }

    /// A card session over `members`, picked up out of `lane1`.
    private func cardSession(
        _ store: BoardStore,
        members: [ItemID] = [card1],
        container: ItemContainer = .board
    ) -> DragSession {
        let session = DragSession()
        session.beginCards(
            members,
            folders: members.map {
                store.rootURL
                    .appendingPathComponent(Ident.lane1, isDirectory: true)
                    .appendingPathComponent($0.rawValue, isDirectory: true)
            },
            heights: members.map { _ in 44 },
            container: container,
            source: store
        )
        return session
    }

    // MARK: The two layouts

    @Test("A move lifts the dragged run out — the source board shows what it is giving away")
    func aMoveLiftsTheRunOut() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store, members: [Self.card1, Self.card2])

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])

        // ⌘ over a foreign board is the cross-board move, and it lifts them out just the same.
        #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])
    }

    /// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here.
    @Test("A copy hides nothing — the originals re-admit into the source board's layout")
    func aCopyReAdmitsTheOriginals() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store, members: [Self.card1, Self.card2])

        // The within-board ⌥-copy.
        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)

        // And the cross-board default, which is a copy with no modifier at all.
        #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
    }

    /// The flip is a deliberate user action and the one-shot reflow is its feedback — which means
    /// the layout has to answer *both* ways, as many times as the user asks.
    @Test("Flipping the modifier mid-drag re-admits and re-lifts, every time")
    func theFlipGoesBothWays() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store)

        for modifiers in [none, option, none, option] {
            session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers)
            let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
            #expect(hidden == (modifiers == option ? [] : [Self.card1]))
        }
    }

    // MARK: The two things the rule does not reach

    /// The trash column's rows never lift, whatever the operation: they render in the column rather
    /// than in a lane's masonry, so there is no resting layout of a lane's for them to leave. They
    /// dim in place instead (`CardFaceView`, `TrashLaneRowView`).
    @Test("A trash-container session hides nothing, under either operation")
    func trashSessionsAreUnchanged() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
    }

    @Test("Only the source board hides anything, whatever the operation")
    func onlyTheSourceBoardHides() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store)

        session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
        #expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }

    /// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
    /// `.copy`, so the strip's zones under the cursor never see a re-admitted lane and `moveLanes`
    /// keeps the index space it always had. A **cross-board** lane copy does re-admit — into the
    /// source strip, while the cursor is over the foreign board.
    @Test("A within-board lane drag never re-admits; a cross-board lane copy does")
    func laneSessionsFollowTheCarveOut() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        session.beginLanes(
            [Self.lane1],
            folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
            units: [1],
            source: store
        )

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1])

        #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
    }

    // MARK: The hold freezes it

    /// A release settles the arrangement, and the operation is part of the arrangement now: the
    /// write that went out named one, and a stray `dropUpdated` sampling a modifier the user has
    /// already let go of must not redraw the board against the other one.
    @Test("A settled copy keeps its originals on screen, whatever the modifiers do next")
    func aSettledCopyStaysReAdmitted() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store)

        session.resolveOperation(destinationRoot: store.rootKey, modifiers: option)
        session.commit(into: store)

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .copy)
        #expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
        #expect(session.operation == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
    }

    @Test("A settled move keeps its originals lifted, whatever the modifiers do next")
    func aSettledMoveStaysLifted() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = cardSession(store)

        session.resolveOperation(destinationRoot: store.rootKey, modifiers: none)
        session.commit(into: store)

        #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move)
        #expect(session.operation == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }
}

// MARK: - The alignment haptic

/// **A tick per genuine new landing spot, never per pixel** (`DragSession.propose`): the
/// hysteresis/dead-region model above already makes proposal changes sparse, which is what lets a
/// tick fire on every one of them without landing in Apple's own "don't overuse haptics" caution for
/// `.alignment`.
///
/// The seam is `hapticTick`, a closure substituted for the real `NSHapticFeedbackManager` call —
/// `holdTimeout`'s own reason: a test bundle has no Force Touch trackpad to drive, so a test counts
/// firings instead of feeling them.
@MainActor
@Suite("The alignment haptic")
struct HapticTickTests {

    private static let lane1 = ItemID(rawValue: Ident.lane1)
    private static let card1 = ItemID(rawValue: Ident.card1)

    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"))
        return fixture
    }

    /// A picked-up card session with the tick wired to a counter instead of the real performer.
    private func pickedUp(_ store: BoardStore) -> (session: DragSession, ticks: () -> Int) {
        let session = DragSession()
        var count = 0
        session.hapticTick = { count += 1 }
        session.beginCards(
            [Self.card1],
            folders: [store.rootURL
                .appendingPathComponent(Ident.lane1, isDirectory: true)
                .appendingPathComponent(Ident.card1, isDirectory: true)],
            heights: [44],
            container: .board,
            source: store
        )
        return (session, { count })
    }

    private func target(_ store: BoardStore, index: Int) -> DropTarget {
        DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index)
    }

    @Test("A fresh proposal ticks once")
    func newProposalTicksOnce() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (session, ticks) = pickedUp(store)

        session.propose(target(store, index: 0))

        #expect(ticks() == 1)
    }

    @Test("Re-proposing the same slot ticks zero more times")
    func sameValueReproposeDoesNotTick() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (session, ticks) = pickedUp(store)

        session.propose(target(store, index: 0))
        session.propose(target(store, index: 0))

        #expect(ticks() == 1, "the second propose named the slot the first already stood at")
    }

    @Test("Withdrawing the proposal does not tick — losing a target isn't an alignment")
    func withdrawalDoesNotTick() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (session, ticks) = pickedUp(store)

        session.propose(target(store, index: 0))
        let afterLanding = ticks()

        session.propose(nil)

        #expect(ticks() == afterLanding, "the withdrawal itself must add nothing")
    }

    @Test("A proposal refused by a committed hold does not tick")
    func proposeUnderHoldDoesNotTick() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (session, ticks) = pickedUp(store)

        session.propose(target(store, index: 0))
        session.commit(into: store)
        let afterCommit = ticks()

        session.propose(target(store, index: 1))

        #expect(ticks() == afterCommit, "propose already refuses once a release has settled")
    }

    @Test("A change from one slot to another ticks once")
    func slotToSlotChangeTicksOnce() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (session, ticks) = pickedUp(store)

        session.propose(target(store, index: 0))
        let afterFirstLanding = ticks()

        session.propose(target(store, index: 1))

        #expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
    }
}

// MARK: - The seeded pickup

/// **The shadow replaces the item's original space at drag start** (DRAG-REORDER.md § The pieces):
/// `begin` takes the pickup's own slot as a seed, so the lift-out and the shadow's arrival are one
/// transaction with identical geometry instead of a gap that closes un-animated and springs back
/// open at the first `dropUpdated`.
///
/// The seed bypasses `propose` deliberately — a pickup is not a *new landing spot*, so the
/// alignment tick stays reserved for genuine retargets, and the first real sample's re-propose of
/// the same slot is `propose`'s ordinary silent early-out.
@MainActor
@Suite("The seeded pickup")
struct SeededPickupTests {

    private static let lane1 = ItemID(rawValue: Ident.lane1)
    private static let card1 = ItemID(rawValue: Ident.card1)

    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"))
        return fixture
    }

    private func cardFolder(_ store: BoardStore) -> URL {
        store.rootURL
            .appendingPathComponent(Ident.lane1, isDirectory: true)
            .appendingPathComponent(Ident.card1, isDirectory: true)
    }

    @Test("A seeded pickup proposes the own slot from the first frame")
    func seedIsTheProposal() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)

        session.beginCards(
            [Self.card1], folders: [cardFolder(store)], heights: [44],
            container: .board, source: store, seed: seed
        )

        #expect(session.proposal == seed, "the shadow holds the vacated space before any sample")
        #expect(
            session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 0,
            "the lane reads the seed through the same accessor every retargeted proposal uses"
        )
    }

    @Test("The seed is silent, and the first sample's re-propose stays silent")
    func seedDoesNotTick() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        var count = 0
        session.hapticTick = { count += 1 }
        let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)

        session.beginCards(
            [Self.card1], folders: [cardFolder(store)], heights: [44],
            container: .board, source: store, seed: seed
        )
        #expect(count == 0, "a pickup is not a new landing spot")

        session.propose(seed)
        #expect(count == 0, "the first dropUpdated re-proposes the seeded slot — the early-out holds")

        session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 1))
        #expect(count == 1, "a genuine retarget still ticks exactly as before")
    }

    @Test("An unseeded pickup still begins with no proposal")
    func noSeedIsTheOldContract() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)

        session.beginCards(
            [Self.card1], folders: [cardFolder(store)], heights: [44],
            container: .board, source: store, seed: seed
        )
        // A second begin without a seed — the ⌥-pickup's shape, and every trash pickup's — must not
        // inherit the previous session's proposal.
        session.beginCards(
            [Self.card1], folders: [cardFolder(store)], heights: [44],
            container: .trash, source: store
        )

        #expect(session.proposal == nil, "a fresh session before the first sample, exactly as documented")
    }

    @Test("A lane pickup seeds its strip slot the same way")
    func laneSeedNamesTheStrip() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let seed = DropTarget(boardRoot: store.rootKey, container: .strip, index: 0)

        session.beginLanes(
            [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
            units: [1], source: store, seed: seed
        )

        #expect(session.stripProposal(onBoardRooted: store.rootKey) == 0)
    }
}
