import AppKit
import SwiftUI
import UniformTypeIdentifiers

// MARK: - What each lane draws, as the drag reads it

/// Where each lane's card grid and title bar are drawn and how tall its cards are — the measured
/// half of the card masonry's drop geometry, one registry per board window.
///
/// **Deliberately not `@Observable`.** Nothing renders off it: it exists so a drop delegate and the
/// autoscroll driver can ask, at *event* time, where the grid is and what the resting row extents
/// are. Observing it would invalidate the strip on every layout pass, which is the animation
/// feedback loop this whole model exists to avoid.
///
/// ### What is measured here, and why that is animation-proof
///
/// 03-board-ui.md § Motion forbids reading *mid-flight* measurements. Two things are read here and
/// neither is one:
///
/// - **The grid's frame.** A lane's card area does not move while a card session is in flight: the
///   masonry reflows *inside* it, and the strip only reflows for a lane session, which reads none of
///   this.
/// - **Each card's height.** A card's height is content-driven — the column width is fixed by the
///   lane's unit count — so it does not animate under the reflow; only positions do. The positions
///   are never measured: they are replayed analytically from these heights through
///   `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
///   (DRAG-REORDER.md § The card masonry).
/// - **Each lane's header frame.** The title bar sits outside the card scroll view and above it, so
///   it neither scrolls nor reflows for anything a drop can do; it is read for one rule only — "a
///   release on the lane header resolves to the topmost position" (04-interactions.md ▸ Drag and
///   drop, settled 2026-07-28), which needs an edge the scrolling masonry cannot supply.
///
/// The one input that *is* frozen at drag start is the **dragged** cards' own heights, which live on
/// `DragSession`: the pickup transition scales the replica and corrupts its last measured frame.
@MainActor
final class LaneDropRegistry {

    /// One lane's masonry, as drawn.
    struct Grid: Equatable, Sendable {
        /// The card area's frame in the window's SwiftUI global space — the space the physical
        /// cursor is converted into (`BoardDropContext.globalCursor`).
        var frame: CGRect
        /// Interior masonry columns — the lane's width units.
        var columns: Int
        /// Spacing between columns and between stacked cards.
        var spacing: CGFloat

        /// The masonry arithmetic this grid is drawn with — `MasonryLayout`'s own expression, so
        /// the zones and the drawn grid cannot disagree (`MasonryPlacement`).
        ///
        /// **Derived at event time and deliberately never cached**, unlike the resting layout it is
        /// applied to (`RestingLayoutCache`): the grid is registered from inside the lane's scroll
        /// view, so its origin moves whenever the lane scrolls — which the autoscroll driver does on
        /// purpose, mid-drag, with the snapshot standing perfectly still. It is also free: four
        /// stores and a divide, no allocation.
        var placement: MasonryPlacement {
            MasonryPlacement(
                columnCount: columns,
                columnWidth: MasonryPlacement.columnWidth(
                    totalWidth: frame.width, columnCount: columns, spacing: spacing),
                spacing: spacing,
                origin: frame.origin
            )
        }
    }

    /// The board's current ruler, written by `BoardView` from the zoom environment (03-board-ui.md
    /// ▸ Layout — zoom).
    ///
    /// It lives here for `stripFrame`'s reason: the drop delegates run at event time, outside any
    /// body evaluation, so a number they need has to be *on* the registry rather than captured from a
    /// view's last render. The default is the system size, which is what an un-zoomed board and every
    /// test that never sets it get.
    var bodyPointSize: CGFloat = BoardMetrics.bodyPointSize

    /// The height a card with no registered measurement is assumed to have — a lane whose faces have
    /// not laid out yet. Nominal rather than zero, so the resting rows still tile.
    ///
    /// **Font-derived** (`BoardMetrics.nominalCardHeight`, 10-accessibility.md's full-relative-scaling
    /// rule), and it matters more here than at most sites: this is the stand-in the drop model tiles
    /// rows with before anything has measured itself, so a figure fixed at 13pt's 44 points would put
    /// every un-measured slot boundary in the wrong place at a large system text size — and the drop
    /// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
    /// guess is all it has until the lane lays out.
    ///
    /// An instance property rather than a static for exactly that reason extended to zoom: the guess
    /// has to be made on the ruler the board is *currently* drawing with, and the level is a property
    /// of the window this registry belongs to.
    var nominalCardHeight: CGFloat {
        BoardMetrics.nominalCardHeight(bodyPointSize: bodyPointSize)
    }

    /// The board strip's own frame in the window's SwiftUI global space — the origin the strip
    /// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
    /// than in the view's `@State` so a delegate reads the *current* rectangle at event time.
    var stripFrame: CGRect = .zero

    private(set) var grids: [ItemID: Grid] = [:]
    private(set) var heights: [ItemID: CGFloat] = [:]

    /// How many times a registered card height has actually **changed**, ever — the freshness signal
    /// `RestingLayoutCache` keys its heights on.
    ///
    /// A height is not snapshot content, and that is the whole reason this counter exists: a face
    /// measuring itself for the first time replaces the `nominalCardHeight` stand-in its lane was
    /// tiled with, and no store's `snapshotGeneration` moves for it. A layout cached across that
    /// would keep proposing against the guess.
    ///
    /// Bumped only on a real change, so the steady state — a drag over a board that has finished
    /// laying out, which is every drag after its first frames — leaves it still and the cache holds.
    private(set) var heightsGeneration = 0

    /// Each lane's title bar, in the same global space `Grid.frame` is written in — the topmost-rule
    /// stripe (`FileDropZones.landing`). Absent for a lane that has not laid its header out yet, and
    /// the file zones then let the masonry answer alone.
    private(set) var headers: [ItemID: CGRect] = [:]

    func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
    func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }

    func update(header frame: CGRect, for laneID: ItemID) { headers[laneID] = frame }
    func removeHeader(_ laneID: ItemID) { headers.removeValue(forKey: laneID) }

    func update(height: CGFloat, for cardID: ItemID) {
        guard heights[cardID] != height else { return }
        heights[cardID] = height
        heightsGeneration += 1
    }

    func removeHeight(_ cardID: ItemID) {
        guard heights.removeValue(forKey: cardID) != nil else { return }
        heightsGeneration += 1
    }
}

// MARK: - The resting layout a card drag proposes against

/// One lane's **resting layout**, as a card drag's zones read it: the cards standing in it with the
/// dragged run already lifted out, and how tall each one is.
///
/// **Ids and heights, never `Card` values.** This is what the masonry zones are built over
/// (`DropSlotMath.cardSlot`), and all they want from a card is a number; a `[Card]` of it would be
/// nine `FieldValue`s and a whole `FrontmatterDocument` of ARC traffic per card, rebuilt on every
/// mouse sample of the drag.
struct LaneRestingLayout: Equatable, Sendable {

    /// The lane's cards in logical order with the dragged run lifted out — **the index space every
    /// proposal in this lane is counted in**, which is the space `BoardStore.moveCards` /
    /// `copyCards` resolve theirs in too (each writer counted in the layout its own gesture showed).
    var cardIDs: [ItemID]

    /// Their heights, aligned 1:1 with `cardIDs`, each one the registered measurement or
    /// `LaneDropRegistry.nominalCardHeight` for a face that has not laid out yet.
    var heights: [CGFloat]
}

/// The per-lane resting layouts a drag is proposing against, built **once per snapshot** rather than
/// once per mouse sample.
///
/// ### Why this is not a hole in the re-grounding rule
///
/// "Geometry re-derives … the resting zones are recomputed against each new snapshot. Nothing is
/// cached across a reload because nothing needs to be" (DRAG-REORDER.md § The mid-drag re-grounding
/// trio, rule 1). Nothing is cached across a reload here either: an entry stands only for the
/// `snapshotGeneration` it was built from, so a foreign reload landing mid-drag retires every layout
/// derived from the snapshot before it and the next sample re-derives against the board as it now
/// is. What this removes is the *repetition* — the same lane rebuilt from the same snapshot dozens
/// of times a second, for samples whose answer `DragSession.propose` then discards as unchanged.
///
/// ### Why the applied counter is the right one
///
/// **`BoardStore.snapshotGeneration`, not `landedReloads`.** The layout is a function of the
/// snapshot's *content*, and the applied counter is exactly "the content changed": a value-equal
/// walk lands the same cards in the same order and cannot move a zone, so keying on the walk counter
/// would throw the layout away for reloads that provably changed nothing. The committed-overlay hold
/// does draw an arrangement the snapshot does not describe while it stands — but it also freezes
/// `propose` and `resolveOperation`, so nothing this cache feeds reaches a proposal for as long as
/// it does.
///
/// ### What the key has to carry, and what it provably need not
///
/// Everything the layout derives from that can move mid-drag is in the key: the snapshot
/// (`generation`, plus `boardRoot` because a cross-board drag retargets against the *hovered*
/// board's store), the lane, the registered heights (`LaneDropRegistry.heightsGeneration`), and the
/// dragged run's own hidden set — which follows the **operation**, since ⌥ pressed mid-drag leaves a
/// copy's originals standing in the layout (`DragSession.hiddenMembers`).
///
/// Two inputs are deliberately absent. The **grid** is not layout content — it is read live at every
/// sample (`LaneDropRegistry.Grid.placement`), because a lane that scrolls moves its origin without
/// touching any snapshot. And the **dragged run's frozen heights** are frozen at pickup by
/// construction (`DragSession.cardHeights`), so they cannot move for the life of an entry — which is
/// one drag, since the session clears this at `begin` and at `end`.
@MainActor
final class RestingLayoutCache {

    /// Which lane's layout, in which board window.
    ///
    /// The **registry** rather than the store: `heights` and `grids` belong to one board window, so
    /// two windows open on one board are two layouts even though they share a store and a lane id.
    private struct Key: Hashable {
        var registry: ObjectIdentifier
        var laneID: ItemID
    }

    /// A layout plus everything it was derived from — the whole of what invalidates it.
    private struct Entry {
        var boardRoot: BoardRootKey
        var generation: Int
        var heightsGeneration: Int
        var hidden: Set<ItemID>
        var layout: LaneRestingLayout
    }

    private var entries: [Key: Entry] = [:]

    /// How many layouts this drag has built, and how many samples were answered from one already
    /// standing — the observation handle that makes "the steady state is a containment scan" a test
    /// rather than a hope (`RestingLayoutCacheTests`).
    ///
    /// Instance counters rather than `BoardLoader.ParseCounter`'s injected object, because the
    /// subject is different: a walk is a stateless static called from several tasks at once and needs
    /// somewhere per-walk to tally into, while this is one object per drag whose whole lifetime is
    /// the question being asked. Nothing in the app reads them.
    private(set) var builds = 0
    private(set) var reuses = 0

    init() {}

    /// `laneID`'s resting layout on `store`'s current snapshot, built if this is the first sample to
    /// ask for it since anything it derives from moved.
    ///
    /// - Returns: `nil` when `laneID` is not in the snapshot at all — the vanished-lane case, which
    ///   is rule 2's and the caller's to answer (`BoardDropContext.revalidateProposal`).
    func layout(
        inLane laneID: ItemID,
        of store: BoardStore,
        registry: LaneDropRegistry,
        hidden: Set<ItemID>
    ) -> LaneRestingLayout? {
        let key = Key(registry: ObjectIdentifier(registry), laneID: laneID)
        if let entry = entries[key],
           entry.generation == store.snapshotGeneration,
           entry.heightsGeneration == registry.heightsGeneration,
           entry.boardRoot == store.rootKey,
           entry.hidden == hidden {
            reuses += 1
            return entry.layout
        }

        guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
            entries.removeValue(forKey: key)
            return nil
        }

        var cardIDs: [ItemID] = []
        var heights: [CGFloat] = []
        cardIDs.reserveCapacity(lane.cards.count)
        heights.reserveCapacity(lane.cards.count)
        for card in lane.cards where !hidden.contains(card.id) {
            cardIDs.append(card.id)
            heights.append(registry.heights[card.id] ?? registry.nominalCardHeight)
        }

        let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights)
        entries[key] = Entry(
            boardRoot: store.rootKey,
            generation: store.snapshotGeneration,
            heightsGeneration: registry.heightsGeneration,
            hidden: hidden,
            layout: layout
        )
        builds += 1
        return layout
    }

    /// Drops every layout — the drag that filled them is over, or a new one is starting.
    func clear() {
        entries.removeAll(keepingCapacity: true)
        builds = 0
        reuses = 0
    }
}

// MARK: - The board window's half of a drop

/// Everything a drop delegate — and the autoscroll driver — needs to answer "where would this land",
/// read at **event** time rather than captured at body-evaluation time.
///
/// The closures are the point: a captured snapshot of the strip's frame or its standard width goes
/// stale the moment the layout animates, and two delegates holding different snapshots would flap
/// the proposal between them.
@MainActor
struct BoardDropContext {

    let store: BoardStore
    let session: DragSession
    let registry: LaneDropRegistry

    /// The strip's inter-lane gap, which is also its outer margin.
    let gap: CGFloat

    /// The window hosting this board — the physical cursor is converted through it.
    let window: @MainActor () -> NSWindow?

    /// The strip's frame in the window's SwiftUI global space.
    let stripFrame: @MainActor () -> CGRect

    /// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
    let standard: @MainActor () -> CGFloat

    /// A collapsed lane's fixed strip width at this board's current zoom (03-board-ui.md § Lane ▸
    /// Collapsed lanes) — the one lane width that is not a multiple of `standard`, so every zone list
    /// and every hit test below needs it to place the lanes at all.
    ///
    /// Read off the registry rather than captured, for `bodyPointSize`'s own reason: these delegates run
    /// at event time, outside any body evaluation, and the level is a property of the window.
    var collapsedWidth: CGFloat {
        BoardMetrics.collapsedLaneWidth(bodyPointSize: registry.bodyPointSize)
    }

    /// The lanes' **drawn** widths in board order — a slot width per expanded lane, the fixed strip per
    /// folded one. The one geometry every strip-level answer here is built from.
    func drawnWidths(of lanes: [Lane]) -> [CGFloat] {
        LaneLayoutMath.drawnWidths(
            of: lanes, standard: standard(), gap: gap, collapsedWidth: collapsedWidth)
    }

    /// Whether two of these would send a drop to the same place — **the whole of what this value
    /// contributes to `LaneView.==` and `CardFaceView.==`.**
    ///
    /// `BoardView.dropContext` rebuilds this struct on every body pass, closures and all, so a naive
    /// value comparison is impossible and a reference comparison is meaningless: the three closures
    /// are freshly allocated each time and would make every lane and every card face unequal on every
    /// pass, which is exactly the rebuild the gates exist to stop. They are also the members it is
    /// safe to ignore — each one *reads* window and strip geometry at event time rather than carrying
    /// any (see this type's own note), so two contexts with the same store, session and registry
    /// resolve every hover identically whatever closure objects they happen to hold.
    nonisolated func isEquivalent(to other: BoardDropContext) -> Bool {
        store === other.store
            && session === other.session
            && registry === other.registry
            && gap == other.gap
    }

    // MARK: The cursor

    /// The physical cursor in the window's SwiftUI global space.
    ///
    /// **`NSEvent.mouseLocation`, never `DropInfo.location`** (DRAG-REORDER.md § Animation-proof
    /// inputs): the drop callback's location is expressed in the target view's space, and that view
    /// may itself be mid-reflow. This is also what `LaneResizeSession` and `MarqueeSession` already
    /// read.
    func globalCursor() -> CGPoint? {
        guard let window = window(), let content = window.contentView else { return nil }
        let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        let inContent = content.convert(inWindow, from: nil)
        // SwiftUI's global space is top-left-origin; an unflipped `NSView` is bottom-left.
        let y = content.isFlipped ? inContent.y : content.bounds.height - inContent.y
        return CGPoint(x: inContent.x, y: y)
    }

    /// The cursor in strip coordinates — 0 at the strip's leading edge, outer margin included, which
    /// is the origin `DropSlotMath.laneExtents` assumes.
    func stripCursor() -> CGPoint? {
        guard let cursor = globalCursor() else { return nil }
        let frame = stripFrame()
        return CGPoint(x: cursor.x - frame.minX, y: cursor.y - frame.minY)
    }

    // MARK: Re-grounding

    /// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md ▸ Drag and drop): a proposal
    /// whose target lane vanished in a reload is invalidated — deleted lanes are
    /// never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a
    /// live target.
    ///
    /// Run at the top of every callback *and* again at release, against the snapshot as it is then;
    /// the store's commits enforce the same rule independently, so the gesture and the write cannot
    /// disagree.
    func revalidateProposal() {
        guard let proposal = session.proposal,
              proposal.boardRoot == store.rootKey,
              let laneID = proposal.laneID
        else { return }
        guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
        session.propose(nil)
    }

    // MARK: Retargeting — the one shared answer

    /// Where a **lane** session would land on this board's strip.
    ///
    /// The zones are analytic — `DropSlotMath.laneExtents` over the remaining lanes' unit counts and
    /// this strip's standard width — and the cursor is the physical mouse, so neither input is a
    /// measured frame (03-board-ui.md § Motion).
    ///
    /// **The terminal slot is clamped before the trash.** The column consumes one unit while
    /// shown and is never a *position* on the strip (04-interactions.md ▸ The trash: "no move or
    /// paste ever targets the trash"), so it is absent from the slot list by construction and the end
    /// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
    /// propose over the column is not a slot at all — it is the column's own target answering
    /// (`retargetTrash`), and it names `.trash` rather than an index in this list.
    ///
    /// **These zones never see a re-admitted lane, and that is an invariant rather than an
    /// accident.** `hiddenMembers` now leaves a copy's originals in the resting layout, but a lane
    /// can only be re-admitted into the strip it was picked up from, and a lane drag over its own
    /// board never resolves to `.copy` (`DragLocality.operation`'s first carve-out). A cross-board
    /// lane copy does re-admit — into the **source** strip, while the cursor is over the foreign
    /// board, where no zone of this board's is being consulted at all. So the strip's slot list is
    /// still "the lanes minus the run" every time this function actually runs, and `moveLanes` /
    /// `receiveLanes` keep the index space they always had.
    func retargetLanes() {
        guard session.isDraggingLanes else { return }
        // Before the cursor, so the flip's replay address is "the surface that got the event" rather
        // than "the surface that managed to answer" (`DragSession.noteRetarget`).
        session.noteRetarget(.strip, registry: registry)
        guard let cursor = stripCursor() else { return }
        let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
        let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
        // **Drawn widths, not unit counts**: a folded lane occupies one narrow zone on the strip rather
        // than the slot its preserved `width` would buy, and a folded lane being dragged contributes its
        // strip to the run's span cap (03-board-ui.md § Lane ▸ Collapsed lanes). Both readings come off
        // the one function the strip itself lays out with, so the zones cannot disagree with the picture.
        let restingWidths = drawnWidths(of: resting)
        let slot = DropSlotMath.laneSlot(
            cursorX: cursor.x,
            restingWidths: restingWidths,
            draggedWidths: LaneLayoutMath.drawnWidths(
                units: session.laneUnits, collapsed: session.laneCollapsed,
                standard: standard(), gap: gap, collapsedWidth: collapsedWidth),
            gap: gap,
            current: session.stripProposal(onBoardRooted: store.rootKey)
        )
        guard let slot else { return }   // a dead region: hold the current proposal
        let index = min(max(0, slot), restingWidths.count)
        session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index))
    }

    /// Where a **card** session would land in `laneID`'s masonry.
    ///
    /// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
    /// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
    /// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
    ///
    /// **The zones are built over whatever `hiddenMembers` leaves standing**, which is what makes
    /// the proposal's index space follow the operation for free: a within-board move counts slots in
    /// the lane minus the dragged run, a within-board ⌥-copy counts them in the lane *including* the
    /// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion
    /// on the matter — the caller decides what the resting layout contains — and the commit's two
    /// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`).
    ///
    /// **The layout is rebuilt per snapshot, the zones are resolved per sample** (`RestingLayoutCache`
    /// — including why that keeps rule 1 of the re-grounding trio exactly). What is left here at
    /// event time is the geometry that genuinely moves under a stationary cursor: the grid, which the
    /// autoscroll driver scrolls on purpose, and `DropSlotMath`'s own containment arithmetic.
    func retargetCards(inLane laneID: ItemID) {
        // `DragSignposts` — begin/end with a `defer` rather than a scoped closure, so the guards
        // below keep their early returns and this reads as instrumentation rather than a rewrite.
        #if DEBUG
        let span = DragSignposts.beginRetargetCards()
        defer { DragSignposts.endRetargetCards(span) }
        #endif
        guard session.isDraggingCards else { return }
        // See `retargetLanes` — recorded before the cursor is read, and by the retarget rather than
        // by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
        // leave the same address behind.
        session.noteRetarget(.lane(laneID), registry: registry)
        // **A folded lane is one zone, and the zone means the end of the lane** (03-board-ui.md § Lane ▸
        // Collapsed lanes: "dropping a drag onto the collapsed strip appends the payload at the lane's
        // END, like an end-of-lane drop"). It is answered *before* the cursor is read, deliberately, and
        // the answer says why: a strip draws no cards, so there is no row geometry for a position to be
        // resolved against and nothing about the pointer's y can change the reading. The strip's own
        // accent edge is the feedback the missing shadow would have been (`LaneView.collapsedDropStroke`).
        //
        // The index is counted in the lane's **resting** layout — its cards with the dragged run lifted
        // out — because that is the space every proposal in this app is counted in and the space
        // `BoardStore.moveCards` resolves in.
        //
        // No hover-to-auto-expand in v1 (noted as future work): the drop lands, the lane stays folded,
        // and the badge on its strip goes up by one when the echo reload arrives.
        if let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
           LaneLayoutMath.isCollapsed(lane) {
            let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
            let count = lane.cards.count { !hidden.contains($0.id) }
            session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: count))
            return
        }
        guard let cursor = globalCursor() else { return }
        guard let resting = session.restingLayouts.layout(
            inLane: laneID,
            of: store,
            registry: registry,
            hidden: session.hiddenMembers(onBoardRooted: store.rootKey)
        ) else {
            revalidateProposal()
            return
        }
        guard let grid = registry.grids[laneID] else { return }

        let slot = DropSlotMath.cardSlot(
            cursor: cursor,
            placement: grid.placement,
            heights: resting.heights,
            // The run's footprint at the landing spot: the first dragged card's frozen height, which
            // is the trigger rect the cursor is over (the rest stack below it).
            draggedHeight: session.cardHeights.first ?? registry.nominalCardHeight,
            current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID)
        )
        guard let slot else { return }   // a dead region: hold
        session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: slot))
    }

    /// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
    ///
    /// This is both the safety net for a lane whose own drop region goes dead (DRAG-REORDER.md §
    /// Single-target dispatch) and the live handler for the strip's own regions. A cursor over a gap,
    /// the outer margin, or the trash column is over no lane at all — `LaneLayoutMath.laneIndex`
    /// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
    func retargetCardsFromStrip() {
        guard session.isDraggingCards, let cursor = stripCursor() else { return }
        let lanes = store.snapshot.lanes
        // Drawn widths, so a folded lane's narrow zone is where it actually is — see `retargetLanes`.
        let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
        guard let index, lanes.indices.contains(index) else { return }
        retargetCards(inLane: lanes[index].id)
    }

    /// The retarget for whichever session type is in flight, from the strip's own surfaces.
    func retargetFromStrip() {
        revalidateProposal()
        if session.isDraggingLanes {
            retargetLanes()
        } else {
            retargetCardsFromStrip()
        }
    }

    // MARK: Retargeting — the trash column

    /// Whether the trash column would take the session in flight right now — `TrashDrop.accepts`
    /// with this board's state read in (04-interactions.md ▸ The trash, settled 2026-07-28).
    ///
    /// A method rather than a property because it is not free of consequence: the operation is
    /// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest
    /// while the cursor sits over the column (`dropProposal`).
    ///
    /// `modifiers` defaults to the live flags, which is the only thing the app ever passes — the
    /// parameter is `DragSession.resolveOperation`'s own seam, one rung out, so the refusal this
    /// gate exists for (⌥ over the column) is checkable without a keyboard (`ModifierFlipTests`).
    func acceptsTrashDrop(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) -> Bool {
        guard let sourceRoot = session.sourceRoot else { return false }
        return TrashDrop.accepts(
            kind: session.kind,
            container: session.container,
            isWithinBoard: sourceRoot == store.rootKey,
            operation: session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers),
            isTrashShown: store.transient.isTrashVisible,
            acceptsMutations: store.acceptsBoardMutations
        )
    }

    /// Where a session over the **trash column** would land: the topmost row, or nowhere.
    ///
    /// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
    /// which is precisely what this column did before it had a drop target of its own: a cursor over
    /// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
    /// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the
    /// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
    /// trash proposes nothing for you": the column declines to be a target, it does not cancel the
    /// drag the user is still holding. So a lane drag reorders across the column exactly as it always
    /// did, and an ⌥-copy released over it still lands where its shadows are.
    func retargetTrash(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
        // A session, or there is nothing to record an address for. Provably a no-op otherwise —
        // every branch below is kind-guarded one level down — and stated so that "an origin means a
        // drag" holds (`DragSession.noteRetarget`).
        guard session.isActive else { return }
        revalidateProposal()
        // The fall-through below may overwrite this address with the surface that actually answered,
        // and that is exactly right: a flip replays whatever resolved the proposal last. The one
        // case this gate exists for keeps it — a cursor over the column resolves to no lane, so the
        // strip's card fall-through holds without recording, and the column stays the address the
        // ⌥-release has to be re-asked at.
        session.noteRetarget(.trash, registry: registry)
        guard acceptsTrashDrop(modifiers: modifiers) else {
            retargetFromStrip()
            return
        }
        session.propose(DropTarget(
            boardRoot: store.rootKey,
            container: .trash,
            index: TrashDrop.landingIndex
        ))
    }

    // MARK: Retargeting — the modifier flip

    /// **The stationary modifier flip's answer** (04-interactions.md ▸ Drag and drop: "⌥ always
    /// forces copy and ⌘ always forces move … the badge tracks the effective operation live").
    ///
    /// The modifiers are sampled inside `resolveOperation`, which is reached from `dropUpdated` and
    /// the commit — and drop callbacks arrive only while the mouse **moves**. So ⌥ pressed with the
    /// pointer perfectly still used to change nothing visible until the next twitch: the source
    /// board's originals stayed lifted, the trash kept refusing (or kept offering) against the old
    /// operation, and the shadows stood in an index space the release would no longer count in.
    /// This is the missing sample, arriving from the keyboard instead of from the mouse.
    ///
    /// **The third client of the one shared retarget** (DRAG-REORDER.md § Edge autoscroll: "the
    /// lane's drop delegate and the autoscroll driver must go through one shared retarget, so they
    /// can never disagree"). It resolves nothing itself — it re-runs the retarget that resolved the
    /// standing proposal, at the surface that resolved it (`DragSession.RetargetOrigin`), so a flip
    /// and a mouse sample land in exactly the same place by construction.
    ///
    /// **No location comes off the flip event, and none is needed.** Every retarget reads the
    /// physical cursor through this window (`globalCursor`), which is precisely why the autoscroll
    /// driver can re-propose against a stationary pointer while the content moves under it; here the
    /// content stands still and the *operation* moves instead.
    ///
    /// Three guards, in the order that makes each one cheap:
    ///
    /// - **A settled release is past retargeting.** `resolveOperation` and `propose` both freeze
    ///   under a hold, so this would be inert anyway; refusing outright means a flip during the
    ///   settle costs nothing at all, and states the freeze where it can be read.
    /// - **Only the window under the cursor answers.** The origin names a `LaneDropRegistry`, which
    ///   is per board *window* — so a cross-board drag re-proposes against the board being hovered
    ///   rather than the one it was picked up from, two windows on one board do not both answer, and
    ///   a window closed mid-drag drops out silently (the reference is weak).
    /// - **The operation is re-resolved before the retarget, not after it.** `dropUpdated` runs them
    ///   the other way round and can afford to: at 60+ samples a second the next event repairs the
    ///   one-sample lag. A flip gets one shot, and the retarget's index space is a function of the
    ///   operation (`retargetCards` → `hiddenMembers`), so the operation has to be current *first*
    ///   or the shadows would land in the space the flip just left.
    func retargetAfterModifierFlip(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
        guard session.isActive, session.hold == nil else { return }
        guard let origin = session.retargetOrigin, origin.registry === registry else { return }

        session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers)

        switch origin.surface {
        case .strip:
            retargetFromStrip()
        case let .lane(laneID):
            // The lane delegate's own pair, in its order: rule 2 first, then the masonry's zones.
            revalidateProposal()
            retargetCards(inLane: laneID)
        case .trash:
            retargetTrash(modifiers: modifiers)
        }
    }

    // MARK: Retargeting — external Finder file sessions

    /// Whether `info` is an **external Finder file** session rather than one of ours.
    ///
    /// Never ambiguous: our own drags arm `DragSession` synchronously at `.onDrag` time, before any
    /// drop callback can arrive, so a session that is not active but carries `.fileURL` items came
    /// from outside the app. (A board drag also carries a plain-text representation and no file URL,
    /// so the two type sets never overlap.)
    func isFileSession(_ info: DropInfo) -> Bool {
        !session.isActive && info.hasItemsConforming(to: [.fileURL])
    }

    /// Whether this board accepts a file drop at all — **the mutating-gesture rule, applied to the
    /// one gesture that arrives from outside the app**: under the read-only lock (02-architecture.md
    /// § The lock's scope) or with an inline title editor focused (04-interactions.md ▸ Grammar's
    /// focused-editor rule) a file drop refuses at the board, with no highlight and no proposal —
    /// the same refusal `.onDrag` makes by handing back an empty item provider.
    var acceptsFileDrops: Bool {
        !store.isReadOnly && !store.isEditingInline
    }

    /// Whether this board accepts *this* drag — the board's own state **and the payload's**
    /// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover").
    ///
    /// A drag carrying nothing but folders is refused here, at every delegate's `validateDrop` and
    /// again before any retarget, which is the whole of "a drag containing only folders never
    /// engages — no highlight, no drop proposal, the standard incompatible-payload read". A mixed
    /// drag engages for its files alone: it has something to import, and the folders are named at
    /// the drop (`FinderDrop.land`).
    func acceptsFileDrop(_ info: DropInfo) -> Bool {
        acceptsFileDrops && FinderDrop.importableCount(info.itemProviders(for: [.fileURL])) > 0
    }

    /// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`,
    /// resolved against the very same analytic masonry geometry.
    ///
    /// The three answers and the order they are asked in are `FileDropZones.landing`'s, kept there so
    /// the ruling is checkable without a window; this is the adapter that feeds it the snapshot and
    /// the registry and turns its answer into a proposal. In short: the **header** is the topmost
    /// position, a **card under the cursor** attaches, and everything else is the **create slot** the
    /// ordinary card zones produce.
    ///
    /// **Created cards land at the drop position** (04-interactions.md ▸ Drag and drop, settled
    /// 2026-07-28): "resolved through the same card-grid zones an ordinary card drag uses, shadow
    /// included — drops are positional everywhere, and append-at-bottom stays the creation *trio*'s
    /// rule, not the drop's."
    ///
    /// **The landing shadow is the create path's whole feedback** (settled, same bullet): no lane-level
    /// highlight is proposed here or drawn anywhere, because "each target gets one clear signal, and
    /// the card-attach highlight exists precisely because that target has no shadow".
    ///
    /// While a create shadow is open the *drawn* cards sit lower than their resting frames, which is
    /// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the
    /// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
    func retargetFile(inLane laneID: ItemID, info: DropInfo) {
        guard acceptsFileDrop(info),
              let lane = store.snapshot.lanes.first(where: { $0.id == laneID })
        else {
            session.proposeFile(nil)
            return
        }
        // **A folded lane is one zone, and it creates at the lane's end** — `retargetCards`' collapsed
        // branch, in the file mode's vocabulary and for its reasons: no card faces means no attach
        // target and no row geometry, so neither of `FileDropZones.landing`'s first two answers can
        // apply and the third has nothing to resolve against. The count is the files', as always.
        if LaneLayoutMath.isCollapsed(lane) {
            session.proposeFile(FileDropTarget(
                boardRoot: store.rootKey,
                landing: .create(laneID: laneID, index: lane.cards.count),
                fileCount: FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
            ))
            return
        }
        guard let cursor = globalCursor(), let grid = registry.grids[laneID] else {
            session.proposeFile(nil)
            return
        }

        let rendered = lane.cards
        let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
        let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))

        let landing = FileDropZones.landing(
            cursor: cursor,
            headerBottom: registry.headers[laneID]?.maxY,
            placement: grid.placement,
            heights: heights,
            nominalHeight: registry.nominalCardHeight,
            current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index
        )

        switch landing {
        case .hold:
            return   // a dead region: hold whatever the create slot already was
        case let .attach(index):
            guard rendered.indices.contains(index) else { return }
            session.proposeFile(FileDropTarget(
                boardRoot: store.rootKey,
                landing: .attach(cardID: rendered[index].id),
                fileCount: count
            ))
        case let .create(index):
            session.proposeFile(FileDropTarget(
                boardRoot: store.rootKey,
                landing: .create(laneID: laneID, index: index),
                fileCount: count
            ))
        }
    }

    /// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
    ///
    /// Both a safety net for a lane whose own drop region goes dead (DRAG-REORDER.md § Single-target
    /// dispatch) and the live handler for the strip's own surfaces — and unlike a card session, those
    /// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
    /// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
    /// `nil` there, since the trash column is absent from the lane list by construction), so the
    /// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
    /// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md ▸ The
    /// trash).
    func retargetFileFromStrip(_ info: DropInfo) {
        guard acceptsFileDrop(info), let cursor = stripCursor() else {
            session.proposeFile(nil)
            return
        }
        let lanes = store.snapshot.lanes
        let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
        guard let index, lanes.indices.contains(index) else {
            session.proposeFile(nil)
            return
        }
        retargetFile(inLane: lanes[index].id, info: info)
    }

    /// What `dropUpdated` answers for a file session: always `.copy` — importing a file leaves the
    /// original where it was, which is what the badge should say — and `.cancel` when nothing under
    /// the cursor will take it.
    func fileDropProposal() -> DropProposal {
        session.fileTarget != nil ? DropProposal(operation: .copy) : DropProposal(operation: .cancel)
    }

    /// Commits a file drop: the files land where the highlight or the shadows showed.
    ///
    /// **The target is captured and the hover state cleared before the load starts.** A provider's
    /// file URL loads asynchronously — it is never synchronous for a Finder drag — and a fast second
    /// drag arriving in the meantime must not find a stale target sitting there. The store call then
    /// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file
    /// count (`BoardStore.importAttachments` / `createCards`).
    ///
    /// **The resolved URLs are the authority on what is a folder** (04-interactions.md ▸ Drag and
    /// drop): the hover read is a declared-type guess and the drop is a filesystem fact, so
    /// `FinderDrop.land` re-partitions here and writes only the files — see its own note on why the
    /// two reads cannot be one.
    func commitFileDrop(_ info: DropInfo) -> Bool {
        guard acceptsFileDrops,
              let target = session.fileTarget,
              target.boardRoot == store.rootKey
        else {
            session.proposeFile(nil)
            return false
        }
        let providers = info.itemProviders(for: [.fileURL])
        session.proposeFile(nil)
        guard !providers.isEmpty else { return false }

        let store = self.store
        Task { @MainActor in
            var urls: [URL] = []
            for provider in providers {
                if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
            }
            guard !urls.isEmpty else { return }

            // The sandbox's half: a Finder drag hands the app an extension for what it dropped, and
            // the copy is the read that needs it. `start…` answers false for a URL that carries no
            // scope of its own — an ordinary in-container path — so only the ones that opened are
            // closed again.
            let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
            defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }

            // Inside the scope: the directory read is itself a read of the dropped item, and the
            // extension the drag handed over is what makes it answer honestly.
            FinderDrop.land(urls, landing: target.landing, into: store)
        }
        return true
    }

    // MARK: The drop proposal the badge tracks

    /// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
    /// `.cancel` otherwise.
    ///
    /// The operation is re-resolved here rather than at pickup, which is what makes the badge track
    /// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop).
    func dropProposal() -> DropProposal {
        guard let proposal = session.proposal,
              proposal.boardRoot == store.rootKey
        else { return DropProposal(operation: .cancel) }
        let operation = session.resolveOperation(destinationRoot: store.rootKey)
        return DropProposal(operation: operation == .copy ? .copy : .move)
    }

    // MARK: The commit

    /// Commits the current proposal — **the drop always lands exactly where the shadows show**
    /// (DRAG-REORDER.md § The pieces).
    ///
    /// The three re-grounding rules are applied here, against the snapshot as it is *now* rather than
    /// against whatever the last render believed:
    ///
    /// 1. the geometry was re-derived on every sample and the proposal is what it produced;
    /// 2. a proposal naming a vanished lane is invalidated, and **release with no valid
    ///    proposal cancels** — items return, nothing is written;
    /// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
    ///
    /// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the
    /// set's size (DRAG-REORDER.md § The drop commits).
    ///
    /// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
    /// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` /
    /// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
    /// item deleted by drop is indistinguishable on disk from one deleted by keystroke
    /// (04-interactions.md ▸ The trash, settled 2026-07-28; lanes extended 2026-07-29).
    ///
    /// The write is the first half; the second is the **committed-overlay hold**
    /// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
    /// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at
    /// their landing slots, the originals lifted out — until this store's echo reload lands.
    func commitDrop() -> Bool {
        // The synchronous half of the release, every refusing branch included (`DragSignposts`); the
        // *pause* the user feels starts inside, at `DragSession.commit(into:)`, and outlives this.
        #if DEBUG
        let span = DragSignposts.beginCommitDrop()
        defer { DragSignposts.endCommitDrop(span) }
        #endif
        guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
            return false
        }
        revalidateProposal()
        guard let target = session.proposal,
              target.boardRoot == store.rootKey
        else {
            cancelDrop()
            return false
        }
        let survivors = session.survivors
        guard !survivors.isEmpty else {
            cancelDrop()
            return false
        }
        // **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".
        //
        // One guard for every out-of-trash target, because every one of them funnels through this
        // commit — the lane masonries, the strip, the cross-board arrivals, and the window fallback
        // alike. A *within*-trash release cannot reach it: the column declines `.trash`-container
        // sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash`
        // proposal, and "within-trash drops stay inert" needs no clause here.
        //
        // The notice is the destination board's strip, which is where the user is looking.
        guard !(session.container == .trash && session.mixesKinds) else {
            store.banners.postMixedTrashDrag()
            cancelDrop()
            return false
        }
        let ids = survivors.map { session.members[$0] }
        let folders = survivors.map { session.folders[$0] }
        let within = sourceRoot == store.rootKey
        let operation = session.resolveOperation(destinationRoot: store.rootKey)

        switch kind {
        case .lanes:
            if target.isTrash {
                // **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The
                // trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
                // delete alongside its strip slots"): release moves the lane's folder — subtree
                // intact — into `.trash/`, exactly the ⌫ delete (`BoardStore.deleteLanesByDrag`).
                //
                // The gate is re-asked here rather than trusted from the hover, the card branch's
                // rule for its reason: the modifiers can change after the proposal stood, and no
                // callback reports it. A refusal cancels — the lane returns, nothing is written.
                guard TrashDrop.accepts(
                    kind: kind,
                    container: session.container,
                    isWithinBoard: within,
                    operation: operation,
                    isTrashShown: store.transient.isTrashVisible,
                    acceptsMutations: store.acceptsBoardMutations
                ) else {
                    cancelDrop()
                    return false
                }
                store.deleteLanesByDrag(laneIDs: ids)
                break
            }
            // **A lane drag never targets a masonry** — a lane session proposes lane slots and the
            // trash column, and nothing else (04-interactions.md ▸ The trash). True by construction,
            // since `retargetLanes` and `retargetTrash` are the only things that propose for one;
            // written down because a commit that trusted the container implicitly would be the one
            // place the invariant could break silently.
            guard target.container == .strip else {
                cancelDrop()
                return false
            }
            if within {
                // **The trash side is the restore, and it is a different write** (04 ▸ The trash:
                // "a trashed lane row [dropped] onto its own board's strip — is an ordinary move to
                // the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
                // rank arithmetic — a permutation of the strip's own lanes — has nothing to say
                // about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
                if session.container == .trash {
                    store.restoreLanes(Set(ids), toIndex: target.index)
                } else {
                    store.moveLanes(Set(ids), toIndex: target.index)
                }
            } else {
                // Cross-board, from either container: the ordinary arrival, copy by default and
                // move under ⌘ — "Dropped on *another* board it follows the copy default … ⌘-drag
                // forces the true cross-board restore-move" (04 ▸ The trash).
                store.receiveLanes(folders, operation: operation, at: target.index)
            }

        case .cards:
            if target.isTrash {
                // **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
                // 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫
                // delete.
                //
                // The gate is re-asked here rather than trusted from the hover, because the one input
                // that can change between them arrives through no callback at all: ⌥ pressed after
                // the proposal stood would otherwise delete an original the copy grammar had just
                // promised to leave alone. A refusal cancels — items return, nothing is written.
                guard TrashDrop.accepts(
                    kind: kind,
                    container: session.container,
                    isWithinBoard: within,
                    operation: operation,
                    isTrashShown: store.transient.isTrashVisible,
                    acceptsMutations: store.acceptsBoardMutations
                ) else {
                    cancelDrop()
                    return false
                }
                store.deleteByDrag(cardIDs: ids)
                break
            }
            guard let laneID = target.laneID else {
                cancelDrop()
                return false
            }
            // **The trash side needs no branch of its own any more** (04-interactions.md ▸ The
            // trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
            // trash card into one of its own board's lanes is an ordinary move to the drop
            // position"). `moveCards`/`copyCards` resolve their members in either container, so a
            // restore *is* the within-board move and a cross-board restore *is* the ordinary
            // arrival — which is exactly what retiring the restore-specific machinery bought.
            if within {
                // **Each writer is counted in the layout its own gesture was showing.** The zones
                // that produced `target.index` were built over the resting layout for *this*
                // operation (`retargetCards` → `DragSession.hiddenMembers`): a move's excludes the
                // dragged run, a copy's includes the originals, because a copy leaves them there.
                // So the number goes to each writer unrewritten and the drop lands where the shadow
                // was — the two index spaces differ, and neither writer has to guess which it got.
                if operation == .copy {
                    store.copyCards(Set(ids), toLane: laneID, at: target.index)
                } else {
                    store.moveCards(Set(ids), toLane: laneID, at: target.index)
                }
            } else {
                store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
            }
        }

        // The committed-overlay hold: keep drawing the arrangement until this store's next snapshot.
        session.commit(into: store)
        return true
    }

    /// Release with nothing valid to write: the items return and nothing is written.
    func cancelDrop() {
        withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { session.end() }
    }
}

// The Finder-drag payload rules this file used to hold — `FileDropLoading` and `FinderDrop` — moved
// to `Kanban/UI/FinderDrop.swift` verbatim when the card window's whole-window attachment drop
// (05-card-window.md ▸ Attachments) became their second caller. Everything below still calls them by
// the same names; only their file changed.

// MARK: - Drop delegates

// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
// even when that target's declared content types don't match the session's payload. So every
// delegate below accepts **all three** session types — cards, lanes, and external Finder file drags
// — and routes internally; a region whose topmost target understood only some of them would be a
// dead zone for the rest — no hover callbacks, and a release there would snap back instead of
// committing.
//
// **The file routing is resolved inside these delegates, not by a drop target of the card's own.**
// A per-face `onDrop` would be the deepest region under the cursor and would therefore have to
// re-implement card and lane routing too, just to avoid becoming a dead zone for them — a second
// copy of the dispatch, able to disagree with this one. Instead a card under the cursor is found by
// hit-testing the *analytic* masonry frames the card zones are already built from
// (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the
// geometry the shadows use.
//
// **A file session is validated by its payload, not only by its type** (04-interactions.md ▸ Drag
// and drop: "Folders are refused at hover"). Every `validateDrop` below asks
// `BoardDropContext.acceptsFileDrop`, which answers false for a drag carrying nothing but folders —
// so the system reads the board as an incompatible target for it: no `dropEntered`, no highlight, no
// shadows, and a refusal cursor at the drop. A *mixed* drag validates, proposes for its files alone,
// and names the folders it left behind when it lands (`FinderDrop`).

/// The board's own drag types. Spelled once so no target can accidentally accept fewer.
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]

/// Every type a board surface's `onDrop` declares: our own two, plus the external Finder file drag
/// (04-interactions.md ▸ Drag and drop). **A target that left `.fileURL` off would be a dead zone
/// that strands a file session** — no hover callbacks, and a refused release with nothing to explain
/// why (DRAG-REORDER.md § Single-target dispatch).
let boardDropTypes: [UTType] = boardDragTypes + [.fileURL]

/// One lane's drop target, attached to the whole lane body.
///
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
/// keeps working while the cursor crosses lane bodies. **File sessions** resolve against those same
/// masonry zones — onto a card they become attachments, onto the grid one card per file at the drop
/// position, and onto the **header** the topmost position, since the target is the whole lane body
/// and a dead stripe across its top would be the one place a file drop refused for no reason
/// (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
struct LaneDropDelegate: DropDelegate {

    let context: BoardDropContext
    let laneID: ItemID

    func validateDrop(info: DropInfo) -> Bool {
        if context.isFileSession(info) { return context.acceptsFileDrop(info) }
        return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
    }

    func dropEntered(info: DropInfo) { retarget(info) }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        // The per-event span, and the input sample (`DragSignposts`). One delegate sees any one
        // event — single-target dispatch has no fall-through — so the cadence is not double-counted.
        #if DEBUG
        let span = DragSignposts.beginDropUpdated()
        DragSignposts.sampleInput()
        defer { DragSignposts.endDropUpdated(span) }
        #endif
        retarget(info)
        return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
    }

    /// **Only file sessions have an exit hook.** A card or lane proposal is meant to *hold* while the
    /// cursor leaves for ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md §
    /// Hysteresis) — while a file proposal is only ever "what is under the cursor now", so leaving
    /// the lane body clears the highlight.
    func dropExited(info: DropInfo) {
        guard context.isFileSession(info) else { return }
        context.session.proposeFile(nil)
    }

    func performDrop(info: DropInfo) -> Bool {
        context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
    }

    private func retarget(_ info: DropInfo) {
        if context.isFileSession(info) {
            context.retargetFile(inLane: laneID, info: info)
            return
        }
        context.revalidateProposal()
        if context.session.isDraggingLanes {
            context.retargetLanes()
        } else {
            context.retargetCards(inLane: laneID)
        }
    }
}

/// The strip's drop target — the backdrop, the gaps and the outer margin.
///
/// The trash column used to fall through to here too, being no landing spot of its own; it has its
/// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target
/// hands every session it declines straight back to this logic, so the behaviour over the column is
/// unchanged for everything but the one gesture that is new.
///
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
/// same shared function the lane delegates use, resolving the lane under the cursor analytically —
/// the safety net for a lane whose own drop region goes dead.
/// File sessions are the strip's twin safety net, and here its own surfaces are a *live* handler for
/// them rather than only a fallback: a file drag has no proposal to hold, so hovering a gap, the
/// outer margin, or the trash column clears the target outright.
struct StripDropDelegate: DropDelegate {

    let context: BoardDropContext

    func validateDrop(info: DropInfo) -> Bool {
        if context.isFileSession(info) { return context.acceptsFileDrop(info) }
        return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
    }

    func dropEntered(info: DropInfo) { retarget(info) }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        #if DEBUG
        let span = DragSignposts.beginDropUpdated()
        DragSignposts.sampleInput()
        defer { DragSignposts.endDropUpdated(span) }
        #endif
        retarget(info)
        return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
    }

    /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions.
    func dropExited(info: DropInfo) {
        guard context.isFileSession(info) else { return }
        context.session.proposeFile(nil)
    }

    func performDrop(info: DropInfo) -> Bool {
        context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
    }

    private func retarget(_ info: DropInfo) {
        if context.isFileSession(info) {
            context.retargetFileFromStrip(info)
        } else {
            context.retargetFromStrip()
        }
    }
}

/// The **trash column's** drop target — the pointer's delete gesture (04-interactions.md ▸ The
/// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it").
///
/// It exists only while the column does. Hidden, the trash renders nothing, so there is no region
/// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no
/// code — `TrashDrop.accepts` restates it anyway, because an invariant that is only true by
/// construction is worth being able to point at.
///
/// Like every other delegate it accepts **all three** session types, because single-target dispatch
/// gives the deepest region the session whether it wants it or not (see the note above), and it
/// routes them three ways:
///
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
///   ones the trash takes — a live item from this board, unmodified, either kind (lanes extended
///   2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being
///   dragged out keeps reordering against the strip across the column exactly as it did when the
///   column was a hole in the strip's target;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
///   import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer
///   them — no lane, no card, nothing to attach to.
struct TrashDropDelegate: DropDelegate {

    let context: BoardDropContext

    func validateDrop(info: DropInfo) -> Bool {
        if context.isFileSession(info) { return context.acceptsFileDrop(info) }
        return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
    }

    func dropEntered(info: DropInfo) { retarget(info) }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        #if DEBUG
        let span = DragSignposts.beginDropUpdated()
        DragSignposts.sampleInput()
        defer { DragSignposts.endDropUpdated(span) }
        #endif
        retarget(info)
        return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
    }

    /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions,
    /// whose proposals are meant to hold across ambiguous territory.
    func dropExited(info: DropInfo) {
        guard context.isFileSession(info) else { return }
        context.session.proposeFile(nil)
    }

    /// A release on the column commits whatever stands — the delete when the trash is the
    /// proposal, and otherwise the proposal the column declined to displace, which is the same
    /// "the drop lands where the shadows show" promise as anywhere else.
    func performDrop(info: DropInfo) -> Bool {
        context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
    }

    private func retarget(_ info: DropInfo) {
        if context.isFileSession(info) {
            context.session.proposeFile(nil)
            return
        }
        context.retargetTrash()
    }
}

/// The window-level fallback, behind every specific target: a release over any in-window region they
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
/// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the
/// shadows already show, which is what the shadows promise.
/// A file session released over uncovered window chrome commits whatever the file target currently
/// names, exactly as this commits the current card/lane proposal — and with no target standing it
/// simply refuses, which is the honest answer for a release over nothing.
struct BoardFallbackDropDelegate: DropDelegate {

    let context: BoardDropContext

    func validateDrop(info: DropInfo) -> Bool {
        if context.isFileSession(info) { return context.acceptsFileDrop(info) }
        return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
    }

    /// The drag left the window's last covering region — with nothing below this one to take over,
    /// a standing file highlight would be stale.
    func dropExited(info: DropInfo) {
        guard context.isFileSession(info) else { return }
        context.session.proposeFile(nil)
    }

    func performDrop(info: DropInfo) -> Bool {
        context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
    }
}
