import AppKit
import Foundation
import Observation
import SwiftUI

// MARK: - Where a drag would land

/// The drop proposal: which board, which container, which slot.
///
/// One value for every layout, because they differ only in what the container is, and `boardRoot` is
/// what makes a proposal cross-board-aware: only the board whose root it names renders the shadows,
/// and only that board's delegate may commit it.
struct DropTarget: Equatable, Sendable {

    /// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
    /// written down at all.
    ///
    /// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
    /// folders directly — same shape as a lane's children, no `index.md` of its own"
    /// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
    /// not a position the pointer chose either (see `TrashDrop`).
    enum Container: Equatable, Sendable {
        /// The **lane strip**: the index counts the board's lanes with the dragged run removed.
        case strip
        /// That lane's **masonry**: the index is a position in its logical card order
        /// (DRAG-REORDER.md § The card masonry).
        case lane(ItemID)
        /// The **trash column**, which a board card — or lane — drag proposes into to delete it
        /// (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
        /// is always the topmost row.
        case trash
    }

    var boardRoot: BoardRootKey
    var container: Container
    var index: Int

    /// The lane this proposal names, or `nil` for the strip and the trash — the shape the container
    /// wore before there were three of them, kept because most readers only ask this one question.
    var laneID: ItemID? {
        if case let .lane(id) = container { return id }
        return nil
    }

    /// Whether this proposal names the trash column, and therefore means *delete*.
    var isTrash: Bool { container == .trash }
}

// MARK: - Dropping on the trash

/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended
/// 2026-07-29: "Dropping a live card — or lane — on the shown trash deletes it … a lane drag over the
/// shown trash proposes the delete alongside its strip slots"), as the two pure facts the gesture is
/// made of (`TrashDropTests`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a
/// modifier pressed *after* the proposal stood, which no `dropUpdated` need ever report.
enum TrashDrop {

    /// The row the shadow takes, always: **the topmost**.
    ///
    /// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
    /// and the trash sorts newest-first by that stamp" (04-interactions.md ▸ The trash, re-ruled
    /// 2026-07-31), so a fresh delete genuinely lands on top. The drop therefore still lands exactly
    /// where the shadow shows — the one positional promise every other drop in this app makes —
    /// while being the only proposal on the board the *pointer* does not choose.
    static let landingIndex = 0

    /// Whether the shown trash takes this session — the whole of the gate, and every clause is a
    /// refusal 04 states in its own words:
    ///
    /// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
    ///   only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
    ///   slots", which is the same sentence the card gesture has always had one level down. A session
    ///   in flight is a session of one of the two kinds, so the clause is `kind != nil` — there is no
    ///   third kind to admit by accident, and a file session never arms this object at all.
    /// - **A trash item is already there.** A `.trash` session's vocabulary is restore and copy-out,
    ///   at either level; dropping it back where it came from writes nothing.
    /// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign item
    ///   delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
    ///   design gives no name and no undo story. It stays where it is.
    /// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
    ///   original stays exactly where it was, and there is nothing to delete but the original. (A
    ///   within-board lane drag never resolves to `.copy` anyway: ⌥ is ignored there,
    ///   `DragLocality.operation`. The clause is the card gesture's and covers the lane for free.)
    /// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
    ///   not rendered, so it has no drop region), and stated here so the claim is testable.
    /// - **The mutating-gesture rule**, like every other write the pointer can start.
    static func accepts(
        kind: DragKind?,
        container: ItemContainer,
        isWithinBoard: Bool,
        operation: TransferOperation,
        isTrashShown: Bool,
        acceptsMutations: Bool
    ) -> Bool {
        guard isTrashShown, acceptsMutations else { return false }
        guard kind != nil, container == .board, isWithinBoard else { return false }
        return operation == .move
    }
}

// MARK: - Where an external file drag would land

/// Where an external **Finder file** session would land (04-interactions.md ▸ Drag and drop, "Files
/// from Finder").
///
/// A separate type from `DropTarget` because a file session is a separate *mode*: nothing of ours is
/// in flight, so there is no dragged run to lift out of the resting layout, no operation to resolve
/// against modifiers, and no source board to compare roots with — only a destination and what the
/// files would become there.
struct FileDropTarget: Equatable, Sendable {

    /// The two landings 04 gives a file drop, and the whole of its behavioural split.
    enum Landing: Equatable, Sendable {
        /// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
        /// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
        /// This is the one file landing that **highlights**, and only because it has no shadow to
        /// show instead (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
        case attach(cardID: ItemID)
        /// **Onto the lane's card grid**: one card per file, landing at this position in the lane's
        /// logical card order.
        ///
        /// **Positional, everywhere the grid reaches** (04-interactions.md ▸ Drag and drop, settled
        /// 2026-07-28): "created cards land at the drop position — resolved through the same
        /// card-grid zones an ordinary card drag uses, shadow included", and a release on the lane
        /// **header** resolves to index 0, the topmost position. Append-at-bottom is the creation
        /// trio's rule (⌘N, Return, a double click), never the drop's. `FileDropZones.landing` is
        /// where the index comes from.
        case create(laneID: ItemID, index: Int)
    }

    /// The board under the cursor — only that board's delegates may commit, and only its lanes draw
    /// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
    var boardRoot: BoardRootKey

    var landing: Landing

    /// How many files ride along — **one nominal-height shadow per incoming file** on the create path
    /// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), one shadow
    /// per card that will land. Read off the session's item providers at hover time
    /// (`FinderDrop.shadowCount`), **floored at one** for the drag whose count macOS withholds — the
    /// commit is unaffected either way, since the write counts resolved URLs, not providers.
    ///
    /// **Folders are not counted** (same bullet: "a mixed drag proposes for its files only"):
    /// `FinderDrop.importableCount` reads the providers' declared types, so a mixed drag draws
    /// shadows for its files alone and a folders-only drag never proposes at all.
    var fileCount: Int
}

// MARK: - The committed-overlay hold

/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
/// committed-overlay hold), as a value so the state machine is testable without a filesystem.
///
/// At release the write goes to disk and the *snapshot does not change* — the one-way flow means the
/// board only shows the new order once the watcher's reload lands (02-architecture.md). Dropping the
/// drag state at release would snap every sibling back to the pre-drop layout for a frame. So the
/// session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing,
/// and stands until the destination store applies its next snapshot — **any** snapshot: the
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
/// anyway.
///
/// It watches `BoardStore.snapshotGeneration`, which since 2026-07-31 does not move for a reload whose
/// tree came back value-equal, and that is the right counter rather than a hazard: a hold is only ever
/// armed by a drop whose write actually rearranged something — every drop path refuses a no-op
/// arrangement *before* it opens a write bracket (`BoardStore.moveCards`, `moveLanes`, `moveLane`) —
/// so a drop that would land value-equal never writes and never reloads, and its hold is the
/// watchdog's to retire exactly as it was before the skip existed.
///
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
/// hand-off coming must still dissolve and let the snapshot be the authority again.
struct CommittedHold: Equatable, Sendable {

    /// The board whose next applied snapshot retires this hold.
    var boardRoot: BoardRootKey

    /// That board's `snapshotGeneration` at the moment of the commit.
    var generation: Int

    /// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus
    /// a watcher round trip, short enough that a refused write does not leave the board drawing an
    /// arrangement it never got.
    static let timeout: Duration = .milliseconds(1500)

    /// Whether a snapshot applied on `root` at `generation` retires this hold. A snapshot on another
    /// board says nothing about this one, and the *same* generation is the one already on screen at
    /// the commit.
    func isRetired(byRoot root: BoardRootKey, generation: Int) -> Bool {
        root == boardRoot && generation > self.generation
    }
}

// MARK: - Locality

/// **Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop,
/// settled), as pure functions (`DragLocalityTests`).
///
/// Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring,
/// with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘
/// always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior
/// is already the default. The operation is therefore a function of (source board, board under the
/// cursor, modifiers) sampled every frame, not a decision taken at pickup, which is what lets the
/// badge track live as the cursor crosses a boundary.
///
/// Which board a root names is `BoardRootKey`'s question, and it is asked as an `==`: the two sides
/// of every comparison below are keys their own stores minted once (`BoardStore.rootKey`), never
/// paths re-resolved under the cursor.
enum DragLocality {

    /// The effective operation, live.
    ///
    /// Two carve-outs, both 04-interactions.md's:
    ///
    /// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
    ///   clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
    ///   its home is the clipboard (▸ Clipboard, Lane paste).
    /// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an
    ///   ordinary move to the drop position"; across boards the default is the copy that leaves the
    ///   original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both
    ///   fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
    ///   whole point.
    static func operation(
        kind: DragKind,
        container: ItemContainer,
        isWithinBoard: Bool,
        modifiers: NSEvent.ModifierFlags
    ) -> TransferOperation {
        let forcesCopy = modifiers.contains(.option)
        let forcesMove = modifiers.contains(.command)

        // The lane carve-out comes first, because it is an outright refusal of the modifier rather
        // than a different default: a within-board lane drag is a reorder and nothing else.
        if kind == .lanes, isWithinBoard { return .move }

        if forcesMove { return .move }
        if forcesCopy { return .copy }
        _ = container   // the container changes which commit runs, never which operation the badge shows
        return isWithinBoard ? .move : .copy
    }
}

// MARK: - DragSession

/// The app's one drag session — what is in flight, where it would land, and what a release means
/// (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// **App-wide, because a drag crosses windows.** The identities are UUIDs and the folders are paths,
/// so the source board hides the dragged items while *any* open board's drop delegates propose a
/// landing spot for them. It is owned by `AppModel` and reached through the environment; the system
/// `NSItemProvider` payload is the formal drop data (`DragPayload`), and this is what every hover
/// actually reads — decoding a provider is asynchronous, and `dropUpdated` must answer synchronously
/// to drive the reflow.
///
/// ### What is frozen and what is not
///
/// 03-board-ui.md § Motion's animation-proof rule, made structural: the **only** inputs frozen at
/// drag start are the *dragged items'* own sizes — card heights and lane unit counts, which the
/// pickup transition corrupts the instant it starts. Everything else (the resting zones, the
/// destination's standard width, which lanes are live) is recomputed from the current snapshot on
/// every sample, which is rule 1 of the mid-drag re-grounding trio: a foreign reload just moves the
/// zones.
///
/// ### Termination is structural
///
/// `begin` arms a watchdog that polls the physical mouse-button state and clears the session shortly
/// after the button is released, no matter where the drop landed — on a delegate (whose
/// `performDrop` already cleaned up, making the watchdog a no-op), on empty window space, in another
/// window, outside every window, or on a cancelled drag. This is the pathfinder's hard-won lifecycle
/// pair: cleanup on session-phase events must be gated on the button being physically up (a finished
/// session's events can arrive *after* the next drag has started), and the watchdog is the
/// guaranteed path for the sessions SwiftUI never reports at all.
@MainActor
@Observable
final class DragSession {

    // MARK: What is in flight

    /// Which level is being dragged; `nil` when no session is in flight — which is what "no drag"
    /// means here rather than a separate flag.
    private(set) var kind: DragKind?

    /// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
    /// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
    private(set) var container: ItemContainer = .board

    /// **Whether the selection this drag was picked up from spanned both kinds** — only ever true in
    /// the trash, whose selection went kind-blind (04-interactions.md ▸ The trash, ruled 2026-07-31).
    ///
    /// A session carries items of exactly one `kind` — the pasteboard type is per-kind and a
    /// `DragPayload` names one — so a mixed selection dragged from a trash row starts a session over
    /// *its* kind and silently leaves the other behind. That silence is what this flag exists to
    /// prevent: "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 refusal
    /// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels,
    /// rather than at hover — 04 puts the explanation at the release.
    private(set) var mixesKinds = false

    /// The dragged items in **flatten order** — the order they will land in.
    private(set) var members: [ItemID] = []

    /// Fast identity test for hiding the originals wherever they render.
    private(set) var memberSet: Set<ItemID> = []

    /// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take.
    @ObservationIgnored private(set) var folders: [URL] = []

    /// The board the drag started in. Root and store are kept separately because the store may go
    /// away with its window mid-drag while the root — the left-hand side of the locality
    /// comparison — stays perfectly usable.
    private(set) var sourceRoot: BoardRootKey?

    @ObservationIgnored private(set) weak var sourceStore: BoardStore?

    /// The dragged cards' heights, **frozen at drag start**. `DropSlotMath.cardSlot` caps the
    /// vertical trigger region at the first one (the shadow the cursor is over), and the shadows are
    /// drawn at all of them.
    @ObservationIgnored private(set) var cardHeights: [CGFloat] = []

    /// The dragged lanes' width units, frozen at drag start — the run's span, measured against
    /// whichever board's standard width it is being proposed into.
    @ObservationIgnored private(set) var laneUnits: [Int] = []

    /// Which of the dragged lanes are **collapsed**, aligned 1:1 with `laneUnits` — frozen at drag
    /// start beside them (03-board-ui.md § Lane ▸ Collapsed lanes).
    ///
    /// A second array rather than a richer element type, because it is read in exactly the two places
    /// `laneUnits` is and always together with it: the shadow's width, and the run's span cap
    /// (`BoardView.stripSlots`, `DropSlotMath.laneSlot`). A folded lane's shadow is its slim strip, not
    /// the slot its preserved `width` would have bought — the drop leaves it folded, so a wider shadow
    /// would promise a lane the strip is not about to draw.
    ///
    /// **Empty is "none of them"**, which is what a card session and a cleared session both hold, and
    /// what a `beginLanes` caller with no collapse question to ask leaves it as.
    @ObservationIgnored private(set) var laneCollapsed: [Bool] = []

    /// The per-lane resting layouts this drag's retargets propose against, built once per snapshot
    /// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1
    /// of the re-grounding trio exactly as it was).
    ///
    /// `@ObservationIgnored` for the reason `LaneDropRegistry` is not `@Observable` at all: it is
    /// written from *event* handlers, and a cache fill that invalidated the strip would be the
    /// animation feedback loop this whole model exists to avoid (03-board-ui.md § Motion).
    ///
    /// **Session-scoped**, so it cannot outlive the drag it was filled for — and so a layout can
    /// assume the one input that is neither in its key nor read live, `cardHeights`, is the frozen
    /// set this session picked up with. `begin` and `end` bracket every session this object has, and
    /// both clear it.
    @ObservationIgnored let restingLayouts = RestingLayoutCache()

    // MARK: Where it would land

    /// The current proposal, or `nil` when the drag has none — a fresh session before the first
    /// sample, or one whose target lane vanished in a reload (rule 2 of the re-grounding
    /// trio). **Release with no valid proposal cancels.**
    private(set) var proposal: DropTarget?

    /// The effective operation, re-resolved on every `dropUpdated` so the system badge tracks live.
    private(set) var operation: TransferOperation = .move

    /// The committed-overlay hold, or `nil` while the session is still proposing. See
    /// `CommittedHold`.
    private(set) var hold: CommittedHold?

    // MARK: The modifier flip

    /// **The stationary-flip nudge**: bumped once for every change to ⌥/⌘ while a session is in
    /// flight and unsettled (`ModifierFlipSource`, armed at `begin` and stopped at `end`).
    ///
    /// The whole of what this object can say about a flip, and deliberately so. The *effect* of a
    /// flip is a re-proposal, and re-proposing needs a board window's geometry — which board is
    /// under the cursor, where its lanes are drawn — none of which an app-wide session has. So the
    /// flip is published as a counter and the hovered board's own drop context turns it back into
    /// the one shared retarget (`BoardDropContext.retargetAfterModifierFlip`), which is the same
    /// seam the autoscroll driver's every scroll step goes through.
    ///
    /// Observed, unlike everything else the event handlers write here: it exists to invalidate a
    /// board window's body. That costs a strip body pass per **keystroke**, not per mouse sample —
    /// and the flip changes `hiddenMembers`, so that pass was happening anyway.
    private(set) var modifierGeneration = 0

    /// The board surface that last resolved this session's proposal — which window, and which of
    /// its drop surfaces — so a flip can re-run *that* retarget rather than guess at one.
    ///
    /// **The window is matched by registry identity, never by board root.** Two windows open on one
    /// board share a store and a root but not a `LaneDropRegistry` (the cache's key already turns on
    /// exactly this), and only one of them has the cursor over it; a root comparison would have the
    /// other one retargeting against a cursor that is nowhere near its lanes.
    ///
    /// **Weak**, because a board window can close mid-drag: the session outlives it, and a flip
    /// afterwards simply finds no one to answer — which is the honest answer, since the surface that
    /// was resolving the proposal is gone.
    ///
    /// `@ObservationIgnored` for `LaneDropRegistry`'s own reason: it is written from *event*
    /// handlers, on every sample of every drag, and a body that re-ran for it would be the animation
    /// feedback loop the drop model exists to avoid.
    @ObservationIgnored private(set) var retargetOrigin: RetargetOrigin?

    /// Which board window ran the last retarget, and which of its surfaces.
    struct RetargetOrigin {

        /// The three surfaces that resolve a proposal — the three retargets a flip can replay.
        /// Each names the function that recorded it: `.strip` is `retargetLanes`, `.lane` is
        /// `retargetCards(inLane:)`, `.trash` is `retargetTrash`.
        enum Surface: Equatable {
            case strip
            case lane(ItemID)
            case trash
        }

        weak var registry: LaneDropRegistry?
        var surface: Surface
    }

    // MARK: The external file mode

    /// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
    /// over nothing that accepts it (`FileDropTarget`).
    ///
    /// **A distinct mode, deliberately kept out of everything above.** A file session arms none of
    /// this object's own state — `kind` stays `nil`, so `isActive` stays false, no member is hidden
    /// from any resting layout, and the marquee and card/lane machinery carry on as if no drag
    /// existed. It lives here rather than in a drop delegate for the reason the rest does: the
    /// highlight and the shadows render off it, so it has to be observable and it has to be one
    /// value the whole window agrees on.
    private(set) var fileTarget: FileDropTarget?

    @ObservationIgnored private var watchdog: Task<Void, Never>?
    @ObservationIgnored private var fileWatchdog: Task<Void, Never>?
    @ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?

    /// Where the flips come from, and the running watch — armed at `begin`, stopped at `end`, and
    /// nowhere else (see `armFlipWatch`).
    @ObservationIgnored private let flipSource: any ModifierFlipSource
    @ObservationIgnored private var flipWatch: (any ModifierFlipWatch)?

    /// The ⌥/⌘ state the last flip reported, so a `.flagsChanged` that moved neither — ⇧ for the
    /// marquee's own grammar, ⌃, caps lock, a function key — costs nothing at all.
    @ObservationIgnored private var flipFlags: NSEvent.ModifierFlags = []

    /// How long this session's holds may stand with no snapshot arriving — `CommittedHold.timeout`,
    /// and a `var` for one reason only: the discard path is a `Task` sleeping on the main actor, and
    /// a test that had to wait the real figure out would be a 1.5 s wall clock in the suite
    /// (`DragSessionTests`). Nothing in the app writes it.
    @ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout

    /// Fires the alignment tick for a genuine new landing spot (`propose`). A `var` for the reason
    /// `holdTimeout` is one: the real `NSHapticFeedbackManager` call needs a Force Touch trackpad a
    /// test bundle has no way to feed, so a test substitutes a closure that counts firings instead.
    @ObservationIgnored var hapticTick: () -> Void = {
        NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default)
    }

    /// **The two flags the effective operation is a function of** — `DragLocality.operation` reads
    /// these and nothing else, so these are the whole of what "a flip" means here.
    static let operationFlags: NSEvent.ModifierFlags = [.option, .command]

    /// `flipSource` is defaulted to the shipping local monitor; it is a parameter for the reason
    /// `DragAutoScroller`'s tick source is one — the real thing needs a live `NSApplication` event
    /// stream, which a test bundle has no way to feed (`ModifierFlipTests`).
    init(flipSource: any ModifierFlipSource = LocalModifierFlipSource()) {
        self.flipSource = flipSource
    }

    // MARK: Queries

    var isActive: Bool { kind != nil }
    var isDraggingCards: Bool { kind == .cards }
    var isDraggingLanes: Bool { kind == .lanes }

    /// Whether the release has **settled**: the write is issued and the arrangement the session was
    /// showing is being held as overlay state until the echo reload lands (`CommittedHold`).
    var isSettled: Bool { hold != nil }

    /// N — the number of contiguous shadows the proposal draws.
    var shadowCount: Int { members.count }

    /// Whether `id` is one of the dragged items.
    func isDragging(_ id: ItemID) -> Bool { memberSet.contains(id) }

    /// The items to **leave out of the resting layout** on the board rooted at `root`.
    ///
    /// Only the source board hides anything, and only for a board-side session: a trash card's drag
    /// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
    /// card to it. Those rows dim in place instead (`CardFaceView`, `TrashLaneRowView`), which is
    /// the same treatment a re-admitted board original now wears — one vocabulary for "this is the
    /// source of the drag", wherever the source stays visible.
    ///
    /// **A move lifts the run out; a copy leaves it in** (04-interactions.md ▸ Drag and drop:
    /// "originals stay", for the within-board ⌥-drag and for the cross-board default alike — ruled
    /// 2026-08-01, retiring the operation-blind carve-out this used to be). The layout on screen is
    /// therefore always the arrangement the release would produce: a copy's source board shows what
    /// it will still hold, a move's shows what it will have given away. Flipping ⌥ or ⌘ mid-drag
    /// reflows the source board once, and that one-shot reflow *is* the feedback the modifier is
    /// asking for — the previous rule's fear of "flapping" traded the answer to "what will this
    /// drop leave behind?" for a stillness nobody asked for.
    ///
    /// Reading `operation` here is what makes the flip visible at all: the property is observed, so
    /// every surface that builds a resting layout off this method re-renders when it changes. The
    /// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved —
    /// **or, with the mouse perfectly still, on the flip's own nudge**: drop callbacks arrive only
    /// while the mouse moves, so a `.flagsChanged` monitor bumps `modifierGeneration` and the
    /// hovered board re-runs the retarget the last callback ran
    /// (`BoardDropContext.retargetAfterModifierFlip`). A stationary ⌥ is therefore answered at the
    /// keystroke rather than at the next mouse sample.
    ///
    /// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
    /// has settled: a settled copy keeps its originals on screen and a settled move keeps them
    /// lifted, in both cases until the echo snapshot lands and the real faces take over.
    func hiddenMembers(onBoardRooted root: BoardRootKey) -> Set<ItemID> {
        guard isActive, container == .board, let sourceRoot, root == sourceRoot
        else { return [] }
        return operation == .copy ? [] : memberSet
    }

    /// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
    /// run position.
    func stripProposal(onBoardRooted root: BoardRootKey) -> Int? {
        guard kind == .lanes, let proposal, proposal.container == .strip,
              proposal.boardRoot == root
        else { return nil }
        return proposal.index
    }

    /// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
    /// run position, in the lane's logical card order.
    func laneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> Int? {
        guard kind == .cards, let proposal, proposal.container == .lane(laneID),
              proposal.boardRoot == root
        else { return nil }
        return proposal.index
    }

    /// The proposal's index when it names this board's **trash column**, else `nil` — the delete
    /// gesture's shadow row (04-interactions.md ▸ The trash).
    ///
    /// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
    /// same "is it me?" question every other container asks, and gets the position from the same
    /// place.
    ///
    /// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
    /// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
    /// second copy of its answer written here could only ever disagree with it.
    func trashProposal(onBoardRooted root: BoardRootKey) -> Int? {
        guard isActive, let proposal, proposal.container == .trash,
              proposal.boardRoot == root
        else { return nil }
        return proposal.index
    }

    /// The members that are still there — **rule 3 of the re-grounding trio**: drag membership is a
    /// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the
    /// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
    /// the pending-cut precedent.
    ///
    /// The source store answers because it is the one re-grounding the set against each reload; with
    /// its window gone there is nothing left to invalidate the set, and the folders on disk are as
    /// good as they were.
    var survivors: [Int] {
        guard let store = sourceStore else { return Array(members.indices) }
        let live = store.transient.dragMembers.ids
        return members.indices.filter { live.contains(members[$0]) }
    }

    // MARK: The file mode's queries and lifecycle

    /// The card an external file drag is hovering **on this board**, or `nil` — the attach
    /// highlight's one input (`CardFaceView`).
    func fileAttachTarget(onBoardRooted root: BoardRootKey) -> ItemID? {
        guard let fileTarget,
              fileTarget.boardRoot == root,
              case let .attach(cardID) = fileTarget.landing
        else { return nil }
        return cardID
    }

    /// The shadow run a file drop would open in `laneID` on this board — where the created cards
    /// land and how many there are — or `nil` when the proposal is elsewhere.
    ///
    /// **This run is the create path's whole feedback** (04-interactions.md ▸ Drag and drop, settled
    /// 2026-07-28): there is no lane-level highlight to go with it, deliberately — each target gets
    /// one clear signal, and `fileAttachTarget` above is the highlight precisely because a card
    /// target has no shadow.
    ///
    /// The floor restates `FinderDrop.shadowCount`'s, at the render end rather than in place of it: a
    /// proposal that somehow carried a zero would otherwise open a run of no shadows at all, which is
    /// a landing spot the user cannot see.
    func fileLaneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> (index: Int, count: Int)? {
        guard let fileTarget,
              fileTarget.boardRoot == root,
              case let .create(lane, index) = fileTarget.landing,
              lane == laneID
        else { return nil }
        return (index: index, count: max(1, fileTarget.fileCount))
    }

    /// Records where the files would land. `nil` withdraws the proposal — over a gap, the outer
    /// margin, the trash column, or a board that refuses the drop outright.
    ///
    /// **No hysteresis, unlike our own sessions.** A card or lane proposal deliberately *holds* while
    /// the cursor crosses ambiguous territory, because the drop must land where the shadows show even
    /// if the cursor drifted off a live zone. A file drop has no such contract: it is a plain "what is
    /// under the cursor right now", so leaving every target simply clears the highlight and the drop
    /// is refused (the pathfinder's `retargetFile` precedent).
    func proposeFile(_ target: FileDropTarget?) {
        guard fileTarget != target else { return }
        let wasHovering = fileTarget != nil
        fileTarget = target
        if target == nil {
            fileWatchdog?.cancel()
            fileWatchdog = nil
        } else if !wasHovering {
            armFileWatchdog()
        }
    }

    // MARK: Lifecycle

    /// Begins a card session — board faces or trash cards.
    ///
    /// - Parameters:
    ///   - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
    ///     trash's own order for a trash-card drag).
    ///   - heights: their measured heights, captured **before** the pickup transition starts.
    ///   - seed: the run's own resting slot, proposed from the first frame — see `begin`.
    func beginCards(
        _ members: [ItemID],
        folders: [URL],
        heights: [CGFloat],
        container: ItemContainer,
        source: BoardStore,
        mixesKinds: Bool = false,
        seed: DropTarget? = nil
    ) {
        begin(
            kind: .cards, members: members, folders: folders,
            container: container, source: source, mixesKinds: mixesKinds, seed: seed
        )
        cardHeights = heights
        laneUnits = []
        laneCollapsed = []
    }

    /// Begins a lane session.
    ///
    /// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
    /// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
    ///
    /// `collapsed` is the run's fold state, aligned with `units` — see `laneCollapsed`. It defaults to
    /// empty, which reads as "none of them": a trashed lane row has no `collapsed` reading to carry
    /// (03-board-ui.md § Trash — a trashed lane is an opaque row, never a lane on the board).
    func beginLanes(
        _ members: [ItemID],
        folders: [URL],
        units: [Int],
        collapsed: [Bool] = [],
        container: ItemContainer = .board,
        source: BoardStore,
        mixesKinds: Bool = false,
        seed: DropTarget? = nil
    ) {
        begin(
            kind: .lanes, members: members, folders: folders,
            container: container, source: source, mixesKinds: mixesKinds, seed: seed
        )
        laneUnits = units
        laneCollapsed = collapsed
        cardHeights = []
    }

    /// `seed` is the pickup's own-slot proposal — "at drag start it replaces the item's original
    /// space" (DRAG-REORDER.md § The pieces), made true from the very first frame rather than from
    /// the first `dropUpdated`. Without it the lift-out and the shadow's arrival land in different
    /// transactions: the masonry closes the vacated gap (un-animated — `shadowRun` hasn't moved) and
    /// then springs it back open when the first hover sample proposes the own slot, a shuffle for no
    /// information. Seeded, the lift and the shadow are one transaction with identical geometry —
    /// the run's frozen sizes at the run's own resting position — so nothing on the board moves at
    /// pickup, which is what the own-slot fixed point always promised.
    ///
    /// Set directly rather than through `propose`, because a pickup is not a *new landing spot*:
    /// the alignment tick stays reserved for genuine retargets. The first real `dropUpdated`
    /// re-proposes the same slot and `propose`'s early-out makes it a silent no-op.
    private func begin(
        kind: DragKind,
        members: [ItemID],
        folders: [URL],
        container: ItemContainer,
        source: BoardStore,
        mixesKinds: Bool,
        seed: DropTarget?
    ) {
        // A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
        #if DEBUG
        DragSignposts.resetInputSampling()
        #endif
        endHold()
        restingLayouts.clear()
        self.kind = kind
        self.members = members
        self.memberSet = Set(members)
        self.folders = folders
        self.container = container
        self.mixesKinds = mixesKinds
        self.sourceStore = source
        self.sourceRoot = source.rootKey
        self.proposal = seed
        self.operation = .move
        // The reload-resolved drag set: vanished members leave it silently, which is what
        // `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
        source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
        self.retargetOrigin = nil
        armWatchdog()
        armFlipWatch()
    }

    /// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
    ///
    /// **A settled release is past retargeting**: the write naming that slot is already on its way,
    /// so a late callback arriving after the commit — a stray `dropUpdated`, a revalidation — must
    /// not move or withdraw the arrangement the hold is drawing.
    func propose(_ target: DropTarget?) {
        guard isActive, hold == nil, proposal != target else { return }
        proposal = target
        // One tick per genuine new landing spot, never per pixel: the hysteresis/dead-region model
        // above already makes retargets sparse, which is exactly the case Apple gives `.alignment`
        // for rather than the "don't overuse haptics" one a per-sample tick would land in. A
        // withdrawal (`target == nil`) is losing a target, not landing on one, so it stays silent.
        if target != nil {
            hapticTick()
        }
    }

    /// Records which board window's surface just resolved the proposal (`RetargetOrigin`) — the
    /// address a stationary modifier flip replays its retarget at.
    ///
    /// Written by the three retargets themselves rather than by their five callers, so the drop
    /// delegates, the strip's fall-through and the autoscroll driver cannot disagree about where a
    /// flip should land any more than they can disagree about where the drop should.
    ///
    /// **Recorded even when the retarget goes on to hold**, which is the point: a cursor over a gap
    /// or a dead region leaves the proposal exactly where it was, and a flip there must re-ask the
    /// same surface the same question — with the modifiers now saying something else.
    ///
    /// Unchanged addresses are not re-stored, `propose`'s own habit and for a sharper reason here:
    /// this runs on every mouse sample *and* every autoscroll frame, and the steady state of a drag
    /// is the same surface answering over and over — a weak reference restored per frame for no
    /// change at all is exactly the kind of hot-path cost the drag model has been shedding.
    func noteRetarget(_ surface: RetargetOrigin.Surface, registry: LaneDropRegistry) {
        if let origin = retargetOrigin, origin.surface == surface, origin.registry === registry {
            return
        }
        retargetOrigin = RetargetOrigin(registry: registry, surface: surface)
    }

    /// Re-resolves the effective operation against the board under the cursor and the modifiers
    /// **right now**, and hands it back for the `DropProposal` the badge tracks.
    ///
    /// **A settled release freezes it**, `propose`'s guard for a reason that is now the same one:
    /// the operation decides what the source board's resting layout holds (`hiddenMembers`) as well
    /// as which write went out, so a stray `dropUpdated` arriving after the commit — with the user's
    /// finger already off ⌥ — would re-lift originals the write is about to leave in place. The
    /// write named an operation; the overlay draws that operation until the echo lands.
    ///
    /// `modifiers` defaults to the live flags, which is the only thing the app ever passes; it is a
    /// parameter so the resolution is checkable without a keyboard, exactly as `DragLocality`'s own
    /// pure function is.
    @discardableResult
    func resolveOperation(
        destinationRoot: BoardRootKey,
        modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
    ) -> TransferOperation {
        guard let kind, let sourceRoot, hold == nil else { return operation }
        let resolved = DragLocality.operation(
            kind: kind,
            container: container,
            isWithinBoard: sourceRoot == destinationRoot,
            modifiers: modifiers
        )
        if resolved != operation { operation = resolved }
        return resolved
    }

    /// Ends the session outright — a cancel, a release with no valid proposal, or the watchdog.
    func end() {
        sourceStore?.transient.dragMembers = .empty
        kind = nil
        members = []
        memberSet = []
        folders = []
        cardHeights = []
        laneUnits = []
        laneCollapsed = []
        restingLayouts.clear()
        proposal = nil
        operation = .move
        sourceStore = nil
        sourceRoot = nil
        retargetOrigin = nil
        watchdog?.cancel()
        watchdog = nil
        // The monitor's lifetime is the session's, and this is the sentence that makes it true:
        // every path that ends a drag — a delegate's cancel, the button-up belt, the watchdog, the
        // hold's hand-off, the hold's timeout — funnels through here.
        stopFlipWatch()
        endHold()
    }

    /// Enters the committed phase: the arrangement the session was showing stays on screen until
    /// `store` applies its next snapshot (`CommittedHold`).
    ///
    /// Everything that drives the rendering — the members, the proposal, the source root, the
    /// operation — is kept exactly as it was, so "keeps rendering the arrangement it was showing"
    /// needs no second mechanism: the shadows stay at the landing slots and the originals stay
    /// exactly where the operation put them — lifted out for a move, standing dimmed in place for a
    /// copy — until the snapshot carrying the write arrives and the real faces take their place.
    /// Freezing the operation is `resolveOperation`'s own guard; the hold this method arms is what
    /// that guard reads.
    func commit(into store: BoardStore) {
        guard isActive else { return }
        // **The release pause starts here** (`DragSignposts`): this is the one path that arms a hold,
        // so every begin has an end — a refused release opens nothing. Closed at `handOff`,
        // `expire`, or `endHold`, each with its own outcome.
        #if DEBUG
        DragSignposts.beginReleasePause()
        #endif
        sourceStore?.transient.dragMembers = .empty
        watchdog?.cancel()
        watchdog = nil
        let hold = CommittedHold(boardRoot: store.rootKey, generation: store.snapshotGeneration)
        self.hold = hold
        let timeout = holdTimeout
        holdTimeoutTask?.cancel()
        holdTimeoutTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: timeout)
            guard !Task.isCancelled, let self else { return }
            self.expire(hold)
        }
    }

    /// **The failed write's path**: no echo is coming, so the hold discards and the board animates
    /// back to snapshot order (03-board-ui.md § Motion — "the width-drag rollback posture: the
    /// action visibly doesn't happen"). The one path in the settle that *is* motion, and the reason
    /// it wears `Motion.dragReflow`: what moves is the arrangement un-happening.
    ///
    /// The timeout's own body, spelled as a method rather than inlined in the `Task`, so the discard
    /// can be pinned directly as well as through the clock (`DragSessionTests`). A hold that has
    /// already been handed off — or replaced by a second drag's — is not this one's to end.
    func expire(_ hold: CommittedHold) {
        guard self.hold == hold else { return }
        #if DEBUG
        DragSignposts.endReleasePause(outcome: "timeout")
        #endif
        withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
    }

    /// The hand-off: a snapshot landed on `root`, so an overlay standing in for it dissolves.
    ///
    /// Called from every board window's own snapshot-generation watch, which is why the hold names
    /// the board it belongs to — a reload on some other board says nothing about this one.
    func handOff(root: BoardRootKey, generation: Int) {
        guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
        // The span the release-pause signpost exists for: commit → the covering snapshot.
        #if DEBUG
        DragSignposts.endReleasePause(outcome: "echo")
        #endif
        end()
    }

    private func endHold() {
        // A no-op for the two paths above, which have already closed their interval with a more
        // specific outcome; the backstop for every other teardown (`end()` from the watchdog, a
        // cancel, a second drag beginning).
        #if DEBUG
        DragSignposts.endReleasePause(outcome: "cleared")
        #endif
        hold = nil
        holdTimeoutTask?.cancel()
        holdTimeoutTask = nil
    }

    /// Cleanup for a session-phase event SwiftUI reports.
    ///
    /// **Gated on the physical button being up**, because a finished session's `.ended` /
    /// `.dataTransferCompleted` events can be delivered *after the user has already started the next
    /// drag*, and a naive handler wipes the new session's state — no shadow, drop dead. The watchdog
    /// covers every genuinely-ended session anyway, so refusing here costs nothing.
    func endIfButtonReleased() {
        guard NSEvent.pressedMouseButtons == 0 else { return }
        guard isActive, hold == nil else { return }
        withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
    }

    // MARK: The watchdog — the single guaranteed termination path

    /// Polls the physical mouse-button state while a drag is in flight. When the button is released
    /// and, after a short grace period (long enough for a landing `performDrop` to run first), a
    /// session still lingers, it is cleared here. Backstops every path a drop delegate cannot see.
    private func armWatchdog() {
        watchdog?.cancel()
        watchdog = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(120))
                guard let self, self.isActive, self.hold == nil else { return }
                guard NSEvent.pressedMouseButtons == 0 else { continue }
                try? await Task.sleep(for: .milliseconds(250))
                guard !Task.isCancelled, self.isActive, self.hold == nil else { return }
                withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() }
                return
            }
        }
    }

    // MARK: The modifier flip — the watch, and what a flip means

    /// Starts watching ⌥/⌘ for this session (`ModifierFlipSource`).
    ///
    /// **Armed at `begin` and stopped at `end`, deliberately nowhere else.** The watchdog already
    /// guarantees `end` runs for every session macOS never reports the finish of, so tying the
    /// monitor to that same bracket is what makes "it cannot outlive the drag" structural rather
    /// than a list of call sites to keep in step — the resting-layout cache's precedent exactly.
    /// A previous watch is stopped first for `armWatchdog`'s reason: a second `begin` with a session
    /// somehow still standing must not leave the first one's monitor installed.
    ///
    /// The **committed hold** deliberately does not stop it: a hold is still this session, and the
    /// freeze it applies is `resolveOperation`'s and `propose`'s, asked at the flip rather than
    /// spelled a second time in the lifecycle (`noteFlip` restates it anyway, so a flip during a
    /// settle costs not even a body pass).
    private func armFlipWatch() {
        stopFlipWatch()
        // The baseline is the modifier state the drag is *starting* under — a ⌥ that was already
        // down at pickup is not a flip, and `DragLocality` has already seen it.
        flipFlags = NSEvent.modifierFlags.intersection(Self.operationFlags)
        flipWatch = flipSource.watch { [weak self] flags in
            self?.noteFlip(flags)
        }
    }

    private func stopFlipWatch() {
        flipWatch?.stop()
        flipWatch = nil
    }

    /// A `.flagsChanged` arrived. Publishes the nudge the hovered board turns back into a
    /// re-proposal, and only when something the operation depends on actually moved.
    ///
    /// **The event's location is deliberately unread.** A flip is not a pointer event: the mouse is
    /// exactly where the last drop callback left it, and every retarget reads the *physical* cursor
    /// through its own window (`BoardDropContext.globalCursor`) rather than any event's coordinates.
    /// That is the same reason the autoscroll driver needs no events at all to keep re-proposing.
    ///
    /// **A settled release ignores flips**, the third face of the freeze `resolveOperation` and
    /// `propose` already wear: the write named an operation and the overlay draws that operation
    /// until the echo lands, so a ⌥ released between the drop and the reload must not re-lift
    /// originals the write is leaving in place.
    private func noteFlip(_ flags: NSEvent.ModifierFlags) {
        let relevant = flags.intersection(Self.operationFlags)
        guard relevant != flipFlags else { return }
        flipFlags = relevant
        guard isActive, hold == nil else { return }
        modifierGeneration &+= 1
    }

    /// The file mode's own watchdog, and its only guaranteed termination path.
    ///
    /// An external session is not ours to end: no `performDrop` runs when the user drops the files
    /// somewhere else entirely, and `onDragSessionUpdated` reports only sessions this app started. A
    /// drag is a button held down, so the same poll the internal watchdog uses answers here — when
    /// the button has been up for a grace period and a target is still standing, the highlight is
    /// stale and goes.
    private func armFileWatchdog() {
        fileWatchdog?.cancel()
        fileWatchdog = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(120))
                guard let self, self.fileTarget != nil else { return }
                guard NSEvent.pressedMouseButtons == 0 else { continue }
                try? await Task.sleep(for: .milliseconds(250))
                guard !Task.isCancelled, self.fileTarget != nil else { return }
                withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) {
                    self.proposeFile(nil)
                }
                return
            }
        }
    }
}

// MARK: - Watching the modifiers

/// Where `DragSession` learns that ⌥ or ⌘ moved — the seam under the local `.flagsChanged` monitor,
/// and `DragAutoScrollTickSource`'s exact cousin: the shipping implementation needs a live
/// `NSApplication` event stream, which a test bundle has no way to feed.
///
/// `Sendable` so a session's stored source is, like the tick source's; the watch it hands back is
/// main-actor state and the handler runs there, which is where every drag input is read.
protocol ModifierFlipSource: Sendable {

    /// Starts reporting modifier changes, until the returned watch is stopped. The flags are the
    /// event's own — the *whole* set, since deciding which bits matter is the session's job
    /// (`DragSession.operationFlags`).
    @MainActor
    func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch
}

/// A running modifier watch. `stop()` is what retires the underlying monitor, and it is idempotent
/// so the session's teardown can call it on every path without asking whether one is installed.
@MainActor
protocol ModifierFlipWatch: AnyObject {
    func stop()
}

/// The shipping source: a **local** `NSEvent` monitor for `.flagsChanged`.
///
/// Local rather than global, for two reasons that point the same way. A global monitor for keyboard
/// events needs Accessibility permission — an enormous ask for a drag affordance — and it would
/// report modifiers pressed while another app is frontmost, which is not a flip in *this* drag at
/// all. The events a drag actually needs are the ones being dispatched to this application while it
/// holds the session.
struct LocalModifierFlipSource: ModifierFlipSource {

    @MainActor
    func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch {
        LocalModifierFlipWatch(onFlip)
    }
}

/// The monitor token's owner, and the one place it is removed.
///
/// `addLocalMonitorForEvents` hands back an opaque token that `removeMonitor` **must** be given —
/// AppKit retains the handler until it is, so a token dropped on the floor is a block that keeps
/// firing for the rest of the process. Holding it in an object whose only method retires it is what
/// makes the removal unmissable: `DragSession.end()` stops the watch, and every way a drag can
/// finish goes through `end()`.
@MainActor
private final class LocalModifierFlipWatch: ModifierFlipWatch {

    private var token: Any?

    init(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) {
        token = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { event in
            // Local monitors run on the main thread, before the event reaches its window.
            MainActor.assumeIsolated { onFlip(event.modifierFlags) }
            // **Returned unchanged, always.** ⌥ and ⌘ mean things to the rest of the app — the
            // click grammar, the menu bar's key equivalents — and a monitor that swallowed them
            // would be reading the drag's modifiers by taking them away from everything else.
            return event
        }
    }

    func stop() {
        guard let token else { return }
        NSEvent.removeMonitor(token)
        self.token = nil
    }
}
