import AppKit
import Foundation
import Observation
import os

// MARK: - ClipboardStore

/// Cut / copy / paste for cards **and lanes** — the hybrid clipboard (04-interactions.md ▸
/// Clipboard).
///
/// ### Hybrid, and what each half is for
///
/// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* — whole
/// folder trees, attachments and strays and all — is **staged** under
/// `<Application Support>/Clipboard/<copyID>/`, so a paste reproduces the item byte-for-byte across
/// boards rather than reconstructing it from a summary. The manifest's embedded `index.md` per entry
/// is **identification metadata only** — menu validation, the refusal's wording, the plain-text
/// flavor — and never a materialization source: a paste whose staged snapshot is missing or
/// unreadable **refuses whole and writes nothing** (04-interactions.md ▸ Clipboard, re-ruled
/// 2026-07-29 — Finder's invariant: an item arrives whole or not at all).
///
/// ### The staging lifecycle, settled
///
/// - **Eager**: the snapshot is taken at ⌘C/⌘X time, so a copy captures the source as it was at the
///   gesture and is immune to a later deletion or unmount.
/// - **At most the current copy**: every copy sweeps, and a sweep keeps only the `copyID` the
///   pasteboard still names. A launch sweeps too, which is what collects the trees another app
///   orphaned by taking the pasteboard while this app was not running.
/// - **Survives relaunch exactly as long as the pasteboard points at it** — which is the sweep rule
///   read from the other side, not a second mechanism.
///
/// The copies run **off the main actor** on a serialized chain (`stagingChain`): a card holding a
/// large video would otherwise freeze the app for the length of the ⌘C. The pasteboard is written
/// synchronously in front of that — ⌘C is instant and Edit ▸ Paste lights up immediately — and a
/// paste awaits the same chain, so it can never read a half-written snapshot, and a sweep can never
/// delete a tree a copy is still writing.
///
/// ### The deferred cut
///
/// ⌘X stages, writes the pasteboard, and arms the source board's `transient.pendingCut` — the items
/// dim in place. The cut is **armed** while the pasteboard still holds its `copyID`, the source store
/// is still open, and the pending cut still names something; "deletion voids per item" needs no code
/// here at all, because `TransientBoardState.resolve` already ejects a member that moved to the trash or vanished
/// on every reload, so "paste moves only the survivors" is the reload rule read at paste time.
/// Voiding undims and downgrades the paste to a copy from staging.
///
/// ### Takeover detection is lazy, and there is no timer
///
/// `NSPasteboard.changeCount` is a machine-wide counter, so a value that moved without this store
/// moving it means another app owns the pasteboard now. It is checked exactly where 04 says — menu
/// validation, app activation, before every paste, and a window becoming key — and nowhere else.
/// `payload` is observable state rather than a computed pasteboard read precisely so the menu items'
/// enablement re-evaluates when it changes rather than whenever SwiftUI happens to rebuild them.
///
/// **"Menu validation" is two checkpoints here, not a hook**: the commands validate by conditional
/// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which
/// reads the cached observables and offers AppKit no validation-time callback. So the cache is
/// re-read at the two moments a command could be about to fire: a menu beginning to track (the
/// mouse's path), and **⌘ going down** (the key equivalent's path — the modifier lands a beat
/// before its letter, and the observation's re-render re-arms the responder inside that beat).
/// Without the second checkpoint the one pasteboard writer that never deactivates this app — the
/// screenshot hotkey, ⌃⇧⌘4 — would leave ⌘V dead until the next app switch, which is the image
/// branch's headline gesture failing in the exact case it was built for. Both checkpoints are one
/// `changeCount` read in the common case, which is why they can afford to fire on every ⌘-chord.
///
/// **A window becoming key is the fourth checkpoint** (widened 2026-08-09 for the card window's
/// paste-image affordance, `CardPasteImageAffordance`): a visible control, unlike a menu item, has to
/// read true continuously while its window is frontmost rather than only at the instant something
/// probes it. Switching between two already-open windows of this app — no menu opened, no ⌘ pressed
/// — crosses neither of the first two checkpoints and stays inside one app activation, so the third
/// checkpoint misses it too; `NSWindow.didBecomeKeyNotification` is the moment that is left. Fired
/// for every window rather than filtered to card windows, for the same cost reason: one more
/// `changeCount` read on an already-cheap path.
@MainActor
@Observable
public final class ClipboardStore {

    // MARK: State

    /// What the pasteboard offers this app, as of the last `refresh()` — `nil` when it holds
    /// somebody else's content, or nothing this build can read.
    ///
    /// **Observed**, which is the whole point: Edit ▸ Paste's availability is a function of this
    /// value, and a computed pasteboard read would leave the item stale until something else
    /// happened to invalidate the menu.
    public private(set) var payload: ClipboardManifest?

    /// **The image-data branch's reading of the same pasteboard**, as of the same `refresh()` —
    /// `nil` when there is no picture to paste, when a board payload outranks one, or when the
    /// pasteboard carries file URLs (04-interactions.md ▸ Clipboard, ruled 2026-08-09;
    /// `PastedImage.flavor(hasBoardItems:types:)` holds the precedence and this holds its answer).
    ///
    /// Observed beside `payload` and refreshed in the same breath, for `payload`'s exact reason: the
    /// three surfaces that turn on it — the board's ⌘V fallback, the card window's ⌘V, and Edit ▸
    /// Paste as Board Background — are menu-validated, and a computed pasteboard read would leave
    /// every one of them stale until something else happened to rebuild the menu.
    ///
    /// **Two readings, never two reads**: one `refresh()` reads the pasteboard once and fills both,
    /// which is what makes "a board payload wins" a property of the code rather than an ordering two
    /// call sites have to remember.
    public private(set) var imagePayload: PastedImage.Flavor?

    /// **The file-URL branch's reading of the same pasteboard**, as of the same `refresh()` — `true`
    /// when there are file URLs to paste as attachments and no board payload outranks them
    /// (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `imagePayload`'s clause 2, finally with an
    /// answer instead of a decline — `PastedImage.carriesFileURL` still decides the presence).
    ///
    /// A bare `Bool` rather than a value carrying the URLs themselves: unlike a picture's `Flavor`,
    /// there is no format or name to decide ahead of the paste, so there is nothing worth caching
    /// beyond "is the branch live". The URLs themselves are read fresh at paste time
    /// (`ClipboardPasteboard.fileURLs()`) — real work across every pasteboard item, not just the
    /// first, worth doing once at the gesture rather than on every menu revalidation.
    public private(set) var fileURLPayload = false

    /// The staging directory — public because the tests assert on what it holds after a copy, a
    /// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
    @ObservationIgnored public let stagingRoot: URL

    @ObservationIgnored private let pasteboard: any ClipboardPasteboard

    /// The `changeCount` at the last refresh. Starts below any real value so the first refresh always
    /// reads through.
    @ObservationIgnored private var lastChangeCount = Int.min

    /// The deferred cut awaiting its paste, or `nil` when the clipboard holds a copy (or a cut has
    /// been consumed or voided).
    ///
    /// The *members* are not here: they live in the source board's `transient.pendingCut`, where the
    /// reload rule can eject vanished ones and where the dimming reads them. This is only the pairing
    /// — which copy, and whose board.
    @ObservationIgnored private var armedCut: ArmedCut?

    private struct ArmedCut {
        let copyID: String
        /// Weak: a board window can close mid-cut, and a closed board voids the cut by definition.
        weak var source: BoardStore?
    }

    /// Tail of the serialized staging chain. Every mutation of the staging directory — a copy's
    /// snapshots, a sweep — is appended here and runs strictly after the previous one, which is what
    /// makes "a sweep can never delete a tree a copy is still writing" a property of the code rather
    /// than a race nobody has lost yet.
    @ObservationIgnored private var stagingChain: Task<Void, Never>?

    /// `nonisolated(unsafe)` for one reason and one only: `deinit` is nonisolated and these are the
    /// tokens it has to hand back. Written exactly once, in `init` on the main actor, and read
    /// exactly once, in `deinit` after the last reference is gone — there is no window in which two
    /// contexts could touch them.
    @ObservationIgnored private nonisolated(unsafe) var stalenessObservers: [any NSObjectProtocol] = []

    /// The ⌘-down checkpoint's monitor token (see the type comment's takeover section) — same
    /// lifetime story as `stalenessObservers`, and `LocalModifierFlipWatch`'s warning applies: a
    /// token dropped on the floor is a block that keeps firing for the rest of the process.
    @ObservationIgnored private nonisolated(unsafe) var commandKeyToken: Any?

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")

    /// `<Application Support>/Clipboard/`, beside the board registry — the same home, for the same
    /// reason (`AppStateHome`; 02-architecture.md § Per-board app state, "App-wide state has the same
    /// home").
    public static var defaultStagingRoot: URL {
        AppStateHome.directory.appendingPathComponent("Clipboard", isDirectory: true)
    }

    /// The app builds one of these with the system pasteboard and the real staging directory; a test
    /// passes its own of each, for the reason `BoardRegistry` takes a storage URL at all — injecting
    /// them is how a suite stays out of the real Application Support home *and* off the machine's one
    /// pasteboard.
    ///
    /// **The launch sweep is here** (04: "a sweep at launch and on each copy"): a fresh store reads
    /// the pasteboard once and collects every staged tree it no longer names, which is exactly the
    /// residue a crash or a previous launch leaves behind.
    public init(
        pasteboard: any ClipboardPasteboard = SystemPasteboard(),
        stagingRoot: URL = ClipboardStore.defaultStagingRoot,
        observesActivation: Bool = true
    ) {
        self.pasteboard = pasteboard
        self.stagingRoot = stagingRoot
        refresh()
        sweep()

        guard observesActivation else { return }
        // Returning to the foreground is when another app's copy becomes this app's problem: the
        // cached payload is re-read, a cut whose pasteboard entry is gone is voided and undimmed, and
        // the staged tree that can never be pasted again goes now rather than lingering.
        stalenessObservers.append(NotificationCenter.default.addObserver(
            forName: NSApplication.didBecomeActiveNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated {
                guard let self else { return }
                self.refresh()
                self.sweep()
            }
        })
        // The two validation checkpoints (type comment ▸ takeover): a menu beginning to track, and
        // ⌘ going down. Refresh only — the activation sweep is about reclaiming staged trees, and a
        // ⌘-chord is not the moment to enqueue disk work on the off chance the pasteboard moved.
        stalenessObservers.append(NotificationCenter.default.addObserver(
            forName: NSMenu.didBeginTrackingNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated { self?.refresh() }
        })
        // **The fourth checkpoint** (type comment ▸ takeover, widened 2026-08-09 for the card
        // window's paste-image affordance): any window becoming key. The first three checkpoints
        // exist for *menu* validation, which only ever runs when something is about to fire — but a
        // visible control has to read true continuously while its window is frontmost, not only at
        // the moment a chord or a menu happens to probe it. Switching between two already-open
        // windows of this app crosses neither of those moments and stays inside one activation, so it
        // needed a trigger of its own. Every window, not just card windows: cheap (one `changeCount`
        // read in the common case, `refresh()`'s own guarantee) and simpler than teaching this store
        // which window kind is asking.
        stalenessObservers.append(NotificationCenter.default.addObserver(
            forName: NSWindow.didBecomeKeyNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated { self?.refresh() }
        })
        commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
            // Local monitors run on the main thread, before the event reaches its window
            // (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for
            // its reason too: ⌘ means things to the rest of the app.
            if event.modifierFlags.contains(.command) {
                MainActor.assumeIsolated { self?.refresh() }
            }
            return event
        }
    }

    deinit {
        for observer in stalenessObservers {
            NotificationCenter.default.removeObserver(observer)
        }
        if let commandKeyToken {
            NSEvent.removeMonitor(commandKeyToken)
        }
    }

    // MARK: - Copy and cut

    /// ⌘C — stages `store`'s selection and writes the pasteboard. Any pending cut is voided: its
    /// pasteboard entry has just been overwritten, so the items it dimmed are staying put.
    public func copy(from store: BoardStore) {
        write(from: store, targeting: store.selection, cut: false)
    }

    /// ⌘X — the same write, plus the deferred move: the items stay where they are, dimmed, until a
    /// paste relocates them (04: "Cut is Finder-style deferred").
    public func cut(from store: BoardStore) {
        write(from: store, targeting: store.selection, cut: true)
    }

    /// **The card context menu's own Copy/Cut** (`CardFaceView`, 2026-08-09 ▸ "redesign context menu
    /// for cards") — Delete's widening rule (`targetIDs`, `styleTarget`) extended to the clipboard for
    /// the first time: "right-clicking something outside the selection acts on what was clicked"
    /// (standard macOS context-menu targeting). Same write, same staging, same pasteboard, same armed
    /// cut — `copy(from:)`/`cut(from:)` above are simply this with `store.selection` as the target;
    /// this overload exists so a caller whose target is *not* the live selection (a card clicked
    /// outside it) never has to fight `store.selection` to get there.
    public func copy(from store: BoardStore, targeting target: ItemReferenceSet) {
        write(from: store, targeting: target, cut: false)
    }

    /// `copy(from:targeting:)`'s cut twin — `cut(from:)`'s deferred-move behavior, on an explicit
    /// target.
    public func cut(from store: BoardStore, targeting target: ItemReferenceSet) {
        write(from: store, targeting: target, cut: true)
    }

    /// The one write every gesture and every menu row shares.
    ///
    /// The order is the contract: capture from the snapshot (main actor, no I/O — every item's
    /// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns
    /// it verbatim), schedule the snapshots behind it, then write the pasteboard, then sweep. The
    /// pasteboard is written *before* the copies land, which is safe because a paste **awaits the same
    /// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the
    /// staging has not finished. This used to lean on the manifest's fallback text instead; with
    /// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one.
    private func write(from store: BoardStore, targeting target: ItemReferenceSet, cut: Bool) {
        guard let capture = Self.capture(selection: target, snapshot: store.snapshot) else { return }

        let copyID = UUID().uuidString.lowercased()
        let stagingDir = stagingRoot.appendingPathComponent(copyID, isDirectory: true)
        let jobs = capture.subjects.map { subject in
            StagingJob(
                source: subject.path.folder(under: store.rootURL),
                destination: stagingDir.appendingPathComponent(subject.id.rawValue, isDirectory: true)
            )
        }
        let manifest = ClipboardManifest(
            copyID: copyID,
            boardRoot: store.rootURL,
            kind: capture.kind,
            container: capture.container,
            entries: capture.subjects.map(\.entry)
        )
        guard let data = manifest.encoded() else { return }

        stage(jobs, into: stagingDir)

        // Whatever was armed is void the instant its copyID leaves the pasteboard — done here rather
        // than discovered later so the old board undims in the same turn as the new copy.
        voidCut()
        lastChangeCount = pasteboard.write(manifest: data, text: manifest.plainText)
        payload = manifest

        if cut {
            armedCut = ArmedCut(copyID: copyID, source: store)
            store.transient.pendingCut = ItemReferenceSet(
                ids: Set(capture.subjects.map(\.id)),
                container: capture.container
            )
        }
        // "A sweep at launch and on each copy purges entries the pasteboard no longer references."
        // Queued behind the snapshots above, so it can only ever collect trees older than this one.
        sweep()
    }

    // MARK: - Availability

    /// Whether Edit ▸ Copy applies — a non-empty selection that still names something, in either
    /// container ("⌘C copies a trash card — a live copy lands wherever pasted, like copying out of
    /// Finder's Trash" — 04-interactions.md ▸ The trash).
    ///
    /// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
    /// copying out all stay live" (02-architecture.md § The lock's scope) — a copy is a read. The
    /// focused-editor rule does close it: while an inline title editor holds the keyboard, ⌘C acts on
    /// the text (04 ▸ Grammar). The field consumes the selector natively, so this guard is belt over
    /// braces — but a board command that stayed armed under an editor would be exactly the kind of
    /// fall-through 04 is careful about.
    ///
    /// **A mixed trash selection closes it — this is one of the two exits the kind guard moved to**
    /// (04-interactions.md ▸ The trash, ruled 2026-07-31 with kind-blind trash selection): "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
    /// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both
    /// never reaches it.
    public func canCopy(from store: BoardStore) -> Bool {
        canCopy(from: store, targeting: store.selection)
    }

    /// `canCopy(from:)` on an explicit target — the card context menu's own reading
    /// (`copy(from:targeting:)`'s doc comment), same three clauses, aimed at whatever the caller
    /// widened to rather than always `store.selection`.
    public func canCopy(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
        guard !store.isEditingInline else { return false }
        guard !SelectionGrammar.mixesKinds(target, in: store.snapshot) else { return false }
        return SelectionGrammar.kind(of: target, in: store.snapshot) != nil
    }

    /// Whether Edit ▸ Cut applies. Copy's conditions plus the one a *move* adds: the board must
    /// accept writes, since a cut mutates its source.
    ///
    /// **The trash no longer disqualifies it** (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". So there is no
    /// container clause here at all, which is the pivot showing up as a deleted line.
    public func canCut(from store: BoardStore) -> Bool {
        canCut(from: store, targeting: store.selection)
    }

    /// `canCut(from:)` on an explicit target — `canCopy(from:targeting:)`'s own reasoning, plus the
    /// read-only clause.
    public func canCut(from store: BoardStore, targeting target: ItemReferenceSet) -> Bool {
        canCopy(from: store, targeting: target) && !store.isReadOnly
    }

    /// Whether Edit ▸ Paste applies to `store`.
    ///
    /// Three clauses, all 04's: the board accepts board mutations (the lock and the focused-editor
    /// rule, `acceptsBoardMutations`); there is a payload at all; and — for a **card** payload only —
    /// there is somewhere to put it, which is false on a zero-lane board. A **lane** payload is
    /// always enabled, zero-lane board included: it is the other way out of one.
    public func canPaste(into store: BoardStore) -> Bool {
        guard store.acceptsBoardMutations, let payload else { return false }
        switch payload.kind {
        case .lane:
            return true
        case .card:
            return PasteTarget.cards(
                selection: store.selection,
                lastActiveLaneID: store.transient.lastActiveLaneID,
                snapshot: store.snapshot
            ) != nil
        }
    }

    // MARK: - Paste ▸ the image-data branch

    // **⌘V's fallback, not a second command** (04-interactions.md ▸ Clipboard, ruled 2026-08-09).
    // A pasteboard carrying raw image data and no file URL pastes the picture into a card's
    // `attachments/` — the screenshot, the browser's Copy Image, Preview's ⌘C. Everything about it
    // is deliberately the *existing* machinery seen from one branch over:
    //
    // - **The precedence is `refresh()`'s**, which fills `payload` and `imagePayload` from one read
    //   and can therefore never let a picture divert a board paste.
    // - **The write is `BoardStore.importAttachments(_:toCard:)`** — the same call a Finder file drop
    //   and the card window's ⇧⌘A make. That is what buys the Finder-style collision rename, the
    //   `performWrite` bracket (one app-mediated reload, the read-only lock, the banner on failure),
    //   the echo ledger's receipt, and the staging rules, without a second import path in the app to
    //   keep in step with the first.
    // - **The name is minted by writing a temp file** rather than by asking the Writer for a free
    //   name and then writing under it: `importFiles` takes source URLs and climbs its own ladder, so
    //   handing it a file already called "Pasted Image.png" is how a paste gets Finder's answer
    //   rather than a second implementation of it.
    // - **It registers no undo step**, exactly like every other attachment arrival (13-native-undo.md
    //   ▸ Out of scope: "attachment add/remove registers no undo step in v1"). A paste that landed a
    //   file is an import, and imports are not on the stack — half a pair would be worse than none.
    // - **It announces exactly as a file drop does**, which is to say the arrival is silent: the
    //   write is app-mediated, so the reload it produces carries receipts and the announcer's ladder
    //   is quiet by construction (10-accessibility.md — "app-mediated echoes never do"). What the
    //   user gets is what a drop gives them: the row appearing in the attachments section and the
    //   card face's chip counting one higher.

    /// Whether ⌘V would paste a picture into `store`'s **anchor card** — the board window's branch.
    ///
    /// Three clauses. The board accepts board mutations (the lock and the focused-editor rule, exactly
    /// as `canPaste(into:)` reads them); there is a picture on the pasteboard; and the selection
    /// anchors a *card*, because an attachment belongs to one. A lane selection, an empty selection
    /// and a trash selection all anchor no card and therefore offer nothing here — which is
    /// `PasteTarget.card`'s answer, so the item's availability and the paste's own refusal are the
    /// same expression.
    public func canPasteImage(into store: BoardStore) -> Bool {
        guard store.acceptsBoardMutations, imagePayload != nil else { return false }
        return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
    }

    /// ⌘V's image branch on the board — resolves the anchor card and pastes into it.
    @discardableResult
    public func pasteImage(into store: BoardStore) -> Bool {
        refresh()
        guard canPasteImage(into: store),
              let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
        else { return false }
        return pasteImage(intoCard: cardID, in: store)
    }

    /// Whether ⌘V would paste a picture into this **named** card — the card window's branch, where
    /// the target is the window's own card rather than a selection's anchor.
    ///
    /// The lock clause is `!store.isReadOnly` rather than `acceptsBoardMutations`, which is
    /// `CardAttachments.isEditable`'s reading and the right one here: the focused-editor half of
    /// `acceptsBoardMutations` is about *this board window's* inline title editor, and a card window
    /// has no business going dead because a board window behind it is mid-rename. A focused text
    /// field in the card window still wins ⌘V natively, which is the rule that actually matters here
    /// and needs no arithmetic (`ClipboardCommands`' focused-editor note).
    ///
    /// The card must be on the **board side**: `BoardStore.importAttachments` refuses a trashed card
    /// outright, and offering a row that would no-op is exactly what the codebase's named predicates
    /// exist to prevent.
    public func canPasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
        guard !store.isReadOnly, imagePayload != nil else { return false }
        return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
    }

    /// Pastes the pasteboard's picture into `cardID`'s `attachments/`.
    ///
    /// - Returns: whether a file was handed to the import path. `false` is every way this can decline
    ///   — no picture, a card that is not there, a pasteboard that declared a type it could not back
    ///   up, or a temp file that would not write — and every one of them writes nothing at all.
    @discardableResult
    public func pasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
        refresh()
        guard canPasteImage(intoCard: cardID, in: store), let flavor = imagePayload else { return false }
        guard let raw = pasteboard.data(forType: flavor.type),
              let bytes = PastedImage.encode(raw, as: flavor)
        else {
            // The pasteboard named a flavor it cannot produce, or produced bytes that are not an
            // image. Nothing is written and nothing is said: the honest outcome of a pasteboard that
            // lied is the one where the card is untouched.
            Self.logger.debug("image paste declined — the declared flavor produced no usable bytes")
            return false
        }
        guard let staged = Self.stageForImport(bytes, named: flavor.fileName) else { return false }
        defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) }

        store.importAttachments([staged], toCard: cardID)
        return true
    }

    /// Writes `bytes` to a private temp folder under `name`, and answers the file's URL.
    ///
    /// **A folder per paste, not a shared scratch directory**: the file has to carry the exact name
    /// the import ladder will start from ("Pasted Image.png"), so two pastes in flight would collide
    /// on it — and the folder is what the caller removes afterwards, which is one `removeItem`
    /// instead of a file plus whatever else ended up beside it.
    ///
    /// The app's own container temp directory, so this needs no sandbox grant and no bookmark: the
    /// bytes came off the pasteboard, they are going into the board the app already holds, and this
    /// is the few milliseconds in between.
    private static func stageForImport(_ bytes: Data, named name: String) -> URL? {
        let folder = FileManager.default.temporaryDirectory
            .appendingPathComponent("PastedImage-\(UUID().uuidString)", isDirectory: true)
        guard (try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
        else { return nil }
        let url = folder.appendingPathComponent(name)
        guard (try? bytes.write(to: url)) != nil else {
            try? FileManager.default.removeItem(at: folder)
            return nil
        }
        return url
    }

    // MARK: - Paste ▸ the file-URL branch

    // **The image branch's reserved clause 2, finally with an answer** (04-interactions.md ▸
    // Clipboard, ruled 2026-08-09). A pasteboard carrying one or more file URLs — a Finder copy,
    // foremost — pastes those *files themselves* into a card's `attachments/`, the same way a Finder
    // drop does:
    //
    // - **Outranks the image branch, never the board's own payload.** `refresh()` fills `imagePayload`
    //   and `fileURLPayload` from the one reading that already orders them (`PastedImage.flavor`'s own
    //   clause order): a board payload wins outright over either, and a file URL wins over raw image
    //   data riding beside it — a Finder-copied image file carries both, and the actual file is the
    //   more honest answer than a re-encoded copy of its bytes landing under a different name.
    // - **The write is `BoardStore.importAttachments(_:toCard:)`, through `FinderDrop.partition`** —
    //   the very split a Finder drag makes at the drop, applied to the paste the same way: files land,
    //   folders are named in a loss row and nothing about them is attempted. One collision ladder, one
    //   set of banners, no second folder-refusal rule to keep in step with the drop's.
    // - **Multiple files, in pasteboard order.** Unlike the image branch's single flavor, a paste here
    //   can be a whole multi-select Finder copy — `ClipboardPasteboard.fileURLs()` reads every item,
    //   not just the first (`availableTypes()`'s own first-item carve-out does not apply to this read).
    // - **It registers no undo step and announces exactly as a drop does** — the image branch's own
    //   reasons, unchanged: an import is not on the undo stack (13-native-undo.md ▸ Out of scope), and
    //   the write is app-mediated so the arrival is the row appearing and the chip counting one higher.

    /// Whether ⌘V would paste one or more files into `store`'s **anchor card** — the board window's
    /// branch, `canPasteImage(into:)`'s own shape one clause over.
    public func canPasteFiles(into store: BoardStore) -> Bool {
        guard store.acceptsBoardMutations, fileURLPayload else { return false }
        return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
    }

    /// ⌘V's file-URL branch on the board — resolves the anchor card and pastes into it.
    @discardableResult
    public func pasteFiles(into store: BoardStore) -> Bool {
        refresh()
        guard canPasteFiles(into: store),
              let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
        else { return false }
        return pasteFiles(intoCard: cardID, in: store)
    }

    /// Whether ⌘V would paste files into this **named** card — the card window's branch,
    /// `canPasteImage(intoCard:in:)`'s own reasoning verbatim (the lock clause is `!store.isReadOnly`
    /// rather than `acceptsBoardMutations`; the card must be on the board side, since
    /// `BoardStore.importAttachments` refuses a trashed one outright).
    public func canPasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
        guard !store.isReadOnly, fileURLPayload else { return false }
        return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
    }

    /// Pastes the pasteboard's file URLs into `cardID`'s `attachments/` — `FinderDrop.land`'s write
    /// half, reached from the pasteboard instead of a drag.
    ///
    /// - Returns: whether at least one file was handed to the import path. `false` covers every way
    ///   this declines — no file URLs, a card that is not there, or a pasteboard offering only
    ///   folders — and every one of them writes nothing; a folders-only pasteboard still posts the
    ///   loss row naming what was skipped, exactly as a folders-only Finder drop does.
    @discardableResult
    public func pasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
        refresh()
        guard canPasteFiles(intoCard: cardID, in: store) else { return false }
        let (files, folders) = FinderDrop.partition(pasteboard.fileURLs())
        if !files.isEmpty {
            store.importAttachments(files, toCard: cardID)
        }
        store.banners.postSkippedFolders(count: folders.count)
        return !files.isEmpty
    }

    // MARK: - Paste ▸ the board backdrop

    /// Whether Edit ▸ Paste as Board Background applies to `store` (03-board-ui.md § Styling ▸
    /// Capabilities; the `background` mapping's `image` subkey).
    ///
    /// Two clauses and no third: a board that accepts mutations, and a picture on the pasteboard.
    /// There is no target to resolve — a board has exactly one backdrop — which is what makes this
    /// the one image-paste surface that stays live on a zero-lane board.
    public func canPasteBoardBackground(into store: BoardStore) -> Bool {
        store.acceptsBoardMutations && imagePayload != nil
    }

    /// Edit ▸ Paste as Board Background — the picture into the board folder, `background.image`
    /// pointed at it.
    ///
    /// The bytes are prepared exactly as the attachment branch's are (same classification, same
    /// format rule) and then handed to `BoardStore.applyPastedBackground(data:fileExtension:)`, which
    /// owns the naming, the one bracket and the undo step. Nothing about the *file* is decided here.
    @discardableResult
    public func pasteBoardBackground(into store: BoardStore) -> Bool {
        refresh()
        guard canPasteBoardBackground(into: store), let flavor = imagePayload else { return false }
        guard let raw = pasteboard.data(forType: flavor.type),
              let bytes = PastedImage.encode(raw, as: flavor)
        else {
            Self.logger.debug("background paste declined — the declared flavor produced no usable bytes")
            return false
        }
        return store.applyPastedBackground(data: bytes, fileExtension: flavor.fileExtension)
    }

    // MARK: - Paste

    /// Where this paste is going, resolved **now** — from the selection as it stands when ⌘V is
    /// pressed, even though the paste itself completes once the staging chain has settled.
    private enum Plan {
        case cards(PasteTarget.Cards)
        case lanes(index: Int)
    }

    /// ⌘V — pastes into `store`, once the snapshots this pasteboard promised are actually on disk.
    ///
    /// Returns the task so a caller that must observe the end state (the tests) can await it; the app
    /// discards it. `nil` means there was nothing to do — no payload, or no target — which is the
    /// same condition the menu item's `disabled` reads, so a disabled command that somehow fires is a
    /// silent no-op rather than a surprise.
    @discardableResult
    public func paste(into store: BoardStore) -> Task<Void, Never>? {
        refresh()
        guard canPaste(into: store), let manifest = payload else { return nil }

        let plan: Plan
        switch manifest.kind {
        case .card:
            guard let target = PasteTarget.cards(
                selection: store.selection,
                lastActiveLaneID: store.transient.lastActiveLaneID,
                snapshot: store.snapshot
            ) else { return nil }
            plan = .cards(target)
        case .lane:
            plan = .lanes(index: PasteTarget.lanes(selection: store.selection, snapshot: store.snapshot))
        }

        let staging = stagingChain
        return Task { @MainActor [weak self, weak store] in
            await staging?.value
            guard let self, let store else { return }
            perform(manifest, plan: plan, into: store)
            // The snapshots just did their job — reclaim everything the pasteboard no longer points
            // at rather than waiting for the next copy or launch. The current copy survives: ⌘V twice
            // is a legitimate flow, and the second one needs it.
            sweep()
        }
    }

    /// The paste itself: main actor, snapshots settled.
    ///
    /// **The armed cut is tried first and consumed on success** — "first armed paste MOVES the
    /// surviving originals … a second paste materializes copies from staging" — and everything else
    /// is the copy path, which is also where a voided cut lands.
    ///
    /// **A paste is a user-initiated creation, so it clears the destination's search**
    /// (04-interactions.md § Search, stated by mechanism — "⌘N, Return-creation, the header button,
    /// empty-space double-click, paste, and Finder file drops alike"). Cards and lanes alike — the
    /// clipboard holds one or the other, and either arrives as an item the query may well not match.
    /// Unqualified, too: 04 names the *mechanism*, so the armed cut's move clears exactly as the copy
    /// does rather than earning a sub-rule for the one case where the items were already on this
    /// board. It is cleared here rather than at ⌘V so the two staleness guards keep their meaning: a
    /// paste the pasteboard moved under lands nothing, and so clears nothing.
    ///
    /// **A paste is an import boundary, so normalization applies** (04 ▸ Clipboard, settled
    /// 2026-07-28 — 01-storage-format.md's loose-file carve-out): every arrival below passes
    /// `normalizingLooseFiles: true`, so a loose file the staged snapshot faithfully carried beside
    /// a card's `index.md` lands inside the pasted card's `attachments/`, Finder-renamed on
    /// collision. Both branches and both operations, unqualified, because 04's sentence is
    /// unqualified. Nothing is dropped and nothing is announced: the snapshot preserved the file,
    /// the paste kept it, and it is where the schema says it belongs — the carve-out's notice is for
    /// files the app moves *without* being asked, which is the loader's path, not this one.
    private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) {
        refresh()
        // The pasteboard moved under this paste (another app copied while the chain settled): the
        // payload the user asked to paste is no longer the payload, and inventing one is worse than
        // doing nothing.
        guard payload?.copyID == manifest.copyID else { return }

        if let move = armedMove(for: manifest) {
            store.transient.noteUserCreation()
            let sources = move.folders.map(BoardStore.ItemSource.folder)
            switch plan {
            case let .cards(target):
                store.receiveCards(
                    sources,
                    operation: .move,
                    toLane: target.laneID,
                    at: target.index,
                    normalizingLooseFiles: true
                )
            case let .lanes(index):
                store.receiveLanes(
                    sources,
                    operation: .move,
                    at: index,
                    normalizingLooseFiles: true
                )
            }
            consumeCut()
            return
        }

        // **The copy path's preflight: refuse, never degrade** (04-interactions.md ▸ Clipboard,
        // re-ruled 2026-07-29). Every entry must have its staged snapshot on disk *before* anything is
        // materialized — the first one that does not refuses the whole paste, names itself from the
        // manifest's metadata, and writes nothing at all. All-or-nothing for the whole paste, which is
        // the copies-are-transactions posture (01-storage-format.md § Frontmatter) read one level up:
        // the transaction is the gesture, not the entry.
        let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true)
        var sources: [BoardStore.ItemSource] = []
        for entry in manifest.entries {
            let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true)
            guard FileManager.default.fileExists(
                atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path
            ) else {
                // The offending entry, named — and the destination's search is left exactly as it was.
                // "Any user-initiated creation on the board clears the query" (04 ▸ Search) is a rule
                // about creations, and this paste created nothing; the preflight therefore runs *before*
                // `noteUserCreation`, so a refusal costs the user neither content nor their filter.
                store.banners.postRefusedPaste(title: entry.title, stagedAt: staged.path)
                return
            }
            sources.append(.folder(staged))
        }
        store.transient.noteUserCreation()

        // A card copied out of the trash needs nothing done to it on arrival: it carries no
        // `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
        // 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it.
        switch plan {
        case let .cards(target):
            store.receiveCards(
                sources,
                operation: .copy,
                toLane: target.laneID,
                at: target.index,
                normalizingLooseFiles: true
            )
        case let .lanes(index):
            store.receiveLanes(
                sources,
                operation: .copy,
                at: index,
                normalizingLooseFiles: true
            )
        }
    }

    /// The armed cut's surviving originals, in flatten order and as folders under the **source**
    /// board's root — or `nil` when the cut is not armed for this manifest.
    ///
    /// The four ways it is not armed are 04's four ways a cut voids, and three of them are simply
    /// the absence of something: another app took the pasteboard (the copyID no longer matches), the
    /// source board closed (the weak reference is gone), and the cut emptied — "a cut voided down to
    /// nothing is simply void". The fourth, per-item deletion, is already applied: the pending cut
    /// has been re-resolved against every reload since, so what is left in it *is* the survivors.
    private func armedMove(for manifest: ClipboardManifest) -> (source: BoardStore, folders: [URL])? {
        guard let cut = armedCut, cut.copyID == manifest.copyID, let source = cut.source else { return nil }
        let survivors = source.transient.pendingCut
        guard !survivors.isEmpty else { return nil }

        // `ItemPath.resolve` walks the container in display order, which is the flatten order the
        // drop commits insert in — and the pending cut is homogeneous by container, so it is asked
        // for exactly the side the cut was made on. A cut made in the trash therefore hands the
        // paste the trash folders it must move out, which is the keyboard restore (04 ▸ The trash).
        let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: source.snapshot)
            .map { $0.folder(under: source.rootURL) }
        guard !folders.isEmpty else { return nil }
        return (source, folders)
    }

    /// The cut has been paid out: the originals moved, so nothing is pending and nothing dims. A
    /// second ⌘V then falls through to the copy path, which is exactly what 04 asks for.
    private func consumeCut() {
        armedCut?.source?.transient.pendingCut = .empty
        armedCut = nil
    }

    /// The cut is void: undim and forget it. Clearing the source's pending set is what "voiding
    /// undims" means in code — everything else about a void cut is the absence of an arm.
    private func voidCut() {
        guard let cut = armedCut else { return }
        cut.source?.transient.pendingCut = .empty
        armedCut = nil
        Self.logger.debug("pending cut voided")
    }

    // MARK: - Pasteboard freshness

    /// Re-reads the pasteboard **if and only if it has changed**, and voids a cut the change orphaned.
    ///
    /// One `changeCount` read in the common case, which is what makes it cheap enough for every
    /// caller 04 names: app activation, the front of every paste, and menu validation's two
    /// checkpoints — a menu beginning to track, and ⌘ going down (the type comment's takeover
    /// section).
    public func refresh() {
        let count = pasteboard.changeCount
        guard count != lastChangeCount else { return }
        lastChangeCount = count
        payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
        // The image branch's whole precedence, applied here so it is applied once: a board payload
        // outranks a picture, and a file URL means this is not the image branch's pasteboard at all.
        let types = pasteboard.availableTypes()
        imagePayload = PastedImage.flavor(hasBoardItems: payload != nil, types: types)
        // The file-URL branch's own answer, same reading: a board payload still wins outright, and a
        // file URL is what the image branch's clause 2 defers to rather than nothing.
        fileURLPayload = payload == nil && PastedImage.carriesFileURL(types)
        if let cut = armedCut, payload?.copyID != cut.copyID {
            voidCut()
        }
    }

    // MARK: - Staging

    /// One item's snapshot: where it lives, where its copy goes.
    private struct StagingJob: Sendable {
        let source: URL
        let destination: URL
    }

    /// Appends this copy's snapshots to the staging chain. Best-effort per item, and the *consequence*
    /// of a failure changed with the refuse-don't-degrade ruling: an item whose snapshot never landed
    /// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than
    /// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
    /// as loud as it should be, one gesture later.
    /// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md
    /// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as
    /// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is
    /// one monolithic `copyItem` — and stripped through the Writer's own call so the rule is one
    /// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like
    /// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that
    /// materializes from it strips again at its own boundary.
    private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
        enqueue { [jobs, stagingDir] in
            guard (try? FileManager.default.createDirectory(
                at: stagingDir,
                withIntermediateDirectories: true
            )) != nil else { return }
            for job in jobs {
                try? FileManager.default.copyItem(at: job.source, to: job.destination)
                try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil))
            }
        }
    }

    /// Deletes every staged tree the pasteboard no longer names — **one rule, three callers**: app
    /// launch (trees orphaned by a crash or a previous session), returning to the foreground (another
    /// app took the pasteboard while we were away, so ours can never be pasted again), and the tail
    /// of every copy and every paste.
    ///
    /// Runs on the staging chain, off the main actor: deleting a gigabyte-scale tree is as slow as
    /// writing one, and the chain is what keeps this from ever overtaking the copy that is producing
    /// the tree it is being told to keep.
    public func sweep() {
        refresh()
        let keep = payload?.copyID
        let root = stagingRoot
        enqueue { await Self.prune(root, keeping: keep) }
    }

    /// Where a tree goes to die: a hidden sibling inside the staging root, so a removal is **two
    /// steps, the first of them atomic**.
    ///
    /// Hidden (`.`-prefixed) on purpose — `prune` lists with `.skipsHiddenFiles`, so this folder is
    /// invisible to the sweep that owns it and can never be mistaken for a staged copy.
    ///
    /// `nonisolated` because `prune` is: the sweep runs off the main actor by design, and a constant
    /// has no isolation to need.
    private nonisolated static let sweepFolderName = ".sweeping"

    /// The sweep, written **claim-then-delete** rather than delete-in-place.
    ///
    /// There is one app and macOS runs one instance of it, so this is not the concurrency guard it was
    /// written as (12-editions.md ▸ App-side state, re-ruled 2026-07-30 — there is no sibling app to
    /// race). It is kept because what it buys is cheap and still true of one process:
    ///
    /// 1. **The claim is a rename, and a rename is atomic.** A tree either leaves the staging root
    ///    whole or stays there whole — it is never briefly *visible half-removed*, which is the one
    ///    state a reader could misread. That covers a crash mid-delete, and it covers the developer's
    ///    own second copy launched with `open -n`, which shares this container because it is the same
    ///    app.
    /// 2. **A missing entry means already swept, never an error.** Every failure here is swallowed:
    ///    the listing is stale by the time it is walked, and a tree that vanished between the two is
    ///    precisely the outcome asked for.
    ///
    /// Leftovers in `.sweeping/` are collected on the next pass. A crash between the rename and the
    /// delete therefore costs disk until the next sweep, which is the same guarantee the staging store
    /// already gives about its own orphans.
    private nonisolated static func prune(_ root: URL, keeping keep: String?) async {
        let sweepFolder = root.appendingPathComponent(sweepFolderName, isDirectory: true)

        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: root,
            includingPropertiesForKeys: nil,
            options: [.skipsHiddenFiles]
        ) else { return }

        var claimed: [URL] = []
        for entry in entries where entry.lastPathComponent != keep {
            // Created lazily: a sweep with nothing to collect must not leave a folder behind as proof
            // it ran.
            if claimed.isEmpty {
                try? FileManager.default.createDirectory(at: sweepFolder, withIntermediateDirectories: true)
            }
            let claim = sweepFolder.appendingPathComponent(UUID().uuidString, isDirectory: true)
            guard (try? FileManager.default.moveItem(at: entry, to: claim)) != nil else {
                // Gone, or claimed by another pass. Either way it is not ours to delete and nothing
                // is wrong.
                continue
            }
            claimed.append(claim)
        }

        for claim in claimed {
            try? FileManager.default.removeItem(at: claim)
        }

        // Anything a previous pass claimed and did not finish — a crash between the claim and the
        // delete. Best-effort, and an empty or missing folder is nothing to do.
        if let stragglers = try? FileManager.default.contentsOfDirectory(
            at: sweepFolder,
            includingPropertiesForKeys: nil,
            options: []
        ) {
            for straggler in stragglers {
                try? FileManager.default.removeItem(at: straggler)
            }
            try? FileManager.default.removeItem(at: sweepFolder)
        }
    }

    /// Appends `work` to the staging chain. `Task.detached` rather than `Task { }`: a task created in
    /// a `@MainActor` method inherits that isolation and would run the file work on the main actor —
    /// the exact thing this chain exists to avoid.
    private func enqueue(_ work: @escaping @Sendable () async -> Void) {
        let previous = stagingChain
        stagingChain = Task.detached(priority: .userInitiated) {
            await previous?.value
            await work()
        }
    }

    /// Awaits every snapshot and sweep queued so far. The app never needs this — a paste awaits the
    /// chain itself — but the tests use it to observe the staging directory once the dust has settled.
    public func stagingSettled() async {
        await stagingChain?.value
    }

    // MARK: - What a copy captures

    /// One item a copy is about to stage: its identity, its folder, and the manifest entry it
    /// produces.
    struct Subject {
        let id: ItemID
        let path: ItemPath
        let entry: ClipboardManifest.Entry
    }

    /// The selection, resolved into copy subjects in the order the clipboard records them — or `nil`
    /// when it names nothing its container holds.
    ///
    /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
    /// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
    /// trash's own rank order for its cards and its lane rows alike. Deriving it here would be a
    /// second definition of an order the app already states once.
    ///
    /// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
    /// span, so `serialized()` on an untouched document returns the file's bytes exactly — which makes
    /// the manifest's embedded text a faithful record of the item while costing ⌘C no file I/O at all,
    /// even for a lane carrying two hundred cards. It is **identification metadata**, not a
    /// materialization source (see the type comment): the refusal's wording and the plain-text flavor
    /// read it, and nothing writes it.
    static func capture(
        selection: ItemReferenceSet,
        snapshot: BoardModel
    ) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? {
        guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
        let container = selection.container
        let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot)
            .filter { selection.ids.contains($0) }
        guard !ordered.isEmpty else { return nil }

        var subjects: [ItemID: Subject] = [:]
        func addCard(_ card: Card, at path: ItemPath) {
            subjects[card.id] = Subject(
                id: card.id,
                path: path,
                entry: ClipboardManifest.Entry(
                    id: card.id.rawValue,
                    folder: card.id.rawValue,
                    title: card.title.value,
                    index: card.document.serialized(),
                    attachmentCount: card.attachments.count
                )
            )
        }

        switch container {
        case .trash:
            for card in snapshot.trash {
                addCard(card, at: .trashCard(card.id))
            }
            // **A trashed lane row copies and cuts like any other lane** (04-interactions.md ▸ The
            // trash: "a trashed lane pastes after the anchor lane (the lane-paste rule above,
            // verbatim)"), which makes ⌘X here the keyboard-native restore at the lane level.
            //
            // **No `cards` in the entry, and that is the opaque unit showing through**: a trashed
            // lane's subtree is deliberately not in the snapshot (`TrashedLane`), so there is nothing
            // here to describe it with — and nothing is lost by that, because the manifest's embedded
            // text is identification metadata only and the *content* comes from the staged folder,
            // which is copied whole, cards and all. An entry that guessed at a card list would be the
            // one place in the app claiming to know what an opaque unit holds.
            for lane in snapshot.trashedLanes {
                subjects[lane.id] = Subject(
                    id: lane.id,
                    path: .trashLane(lane.id),
                    entry: ClipboardManifest.Entry(
                        id: lane.id.rawValue,
                        folder: lane.id.rawValue,
                        title: lane.title.value,
                        index: lane.document.serialized(),
                        attachmentCount: 0
                    )
                )
            }
        case .board:
            for lane in snapshot.lanes {
                if kind == .lane {
                    subjects[lane.id] = Subject(
                        id: lane.id,
                        path: .lane(lane.id),
                        entry: ClipboardManifest.Entry(
                            id: lane.id.rawValue,
                            folder: lane.id.rawValue,
                            title: lane.title.value,
                            index: lane.document.serialized(),
                            attachmentCount: 0,
                            // Every card the lane has — "a lane carries exactly its cards", and the
                            // trash is board-level, so there is nothing nested to strip
                            // (04-interactions.md ▸ Drag and drop, resettled 2026-07-28).
                            cards: lane.cards.map { card in
                                ClipboardManifest.Entry.Card(
                                    id: card.id.rawValue,
                                    title: card.title.value,
                                    index: card.document.serialized(),
                                    attachmentCount: card.attachments.count
                                )
                            }
                        )
                    )
                    continue
                }
                for card in lane.cards {
                    addCard(card, at: .card(lane: lane.id, id: card.id))
                }
            }
        }

        let resolved = ordered.compactMap { subjects[$0] }
        guard !resolved.isEmpty else { return nil }
        return (kind, container, resolved)
    }
}
