import CoreGraphics
import Foundation

// MARK: - What a click names

/// Which of the board's two selectable levels an item is — 04-interactions.md § Selection's
/// **cards XOR lanes** axis, named so the grammar can compare it rather than infer it per branch.
///
/// It is deliberately *not* stored anywhere: a selection is a set of ids and nothing else
/// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing
/// it would be a second answer to a question the snapshot can always answer, and one that a reload
/// could falsify.
///
/// The **one** place a kind is written down is the clipboard manifest, which has no snapshot to
/// re-derive it from — "the cards-XOR-lanes selection rule means the clipboard holds cards or lanes,
/// never both" (04-interactions.md ▸ Clipboard). Hence `String`-backed and `Codable`: those raw
/// spellings are pasteboard API, decoded after a relaunch, and they are the case names so there is
/// no second vocabulary to keep in step.
public enum SelectionKind: String, Codable, Sendable, Equatable {
    case card
    case lane
}

/// What a pointer click names: an item, its level, and the container the surface it was clicked on
/// belongs to.
///
/// The **container is the surface's, not the item's** — a card face is always `.board` and a trash
/// row is always `.trash`, because that is what the user clicked. A click on a surface whose item
/// crossed containers a moment ago simply selects nothing the next reload will keep, which is the
/// ordinary vanish rule and not a case for this type to model.
public struct SelectionTarget: Sendable, Equatable {
    public var id: ItemID
    public var kind: SelectionKind
    public var container: ItemContainer

    public init(id: ItemID, kind: SelectionKind, container: ItemContainer) {
        self.id = id
        self.kind = kind
        self.container = container
    }
}

/// The modifier a click carried — 04-interactions.md § Selection's three-way grammar ("click
/// selects; ⌘-click toggles; ⇧-click range-extends").
///
/// Three cases rather than an `OptionSet`, because the design gives ⌘ and ⇧ *no* combined meaning:
/// AppKit hands us both flags when both keys are down, and the grammar picks one. `ClickModifier`
/// is where that choice is made once (`ClickModifier.current`), so no call site re-decides it.
public enum ClickModifier: Sendable, Equatable {
    case plain
    case command
    case shift
}

// MARK: - SelectionGrammar

/// 04-interactions.md § Selection's pointer grammar, plus § The trash's extensions to it, as a pure
/// function of the click, the current selection, the anchor, and the snapshot
/// (`SelectionGrammarTests`).
///
/// **The container is the invariant, and it is enforced here or nowhere.** A selection never mixes
/// trash rows with board items (§ The trash's "single container rule replacing the old liveness
/// law"), and *on the board* it is also cards XOR lanes (§ Selection). **Inside the trash it is
/// kind-blind** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash
/// grammar): "within the trash cards and lane rows select together — clicks, ⇧-click ranges,
/// ⇧-arrow extension, and the rubber band all sweep every row". The kind axis therefore stops at the
/// container boundary rather than reaching through it, and the guard the trash used to need moved to
/// the exits — the mixed-payload drop refusal and ⌘C/⌘X validation (§ The trash), since "inside the
/// trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the
/// exits don't".
///
/// What has not changed is what a modifier does when it *would* cross an axis that still stands: it
/// degrades to a replace, which is the only answer that keeps the invariant true without silently
/// dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
/// trash card) share one answer instead of four near-copies of it.
public enum SelectionGrammar {

    /// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range
    /// from, and the head a subsequent arrow would step from.
    ///
    /// The anchor is carried *out* rather than mutated in place because it is not derivable from the
    /// selection — a ⇧-range replaces the whole set and deliberately leaves the anchor where it was,
    /// so "the last plain or ⌘ click" is a memory of a gesture and only the gesture can update it
    /// (`TransientBoardState.selectionAnchor`).
    ///
    /// **The head is always the clicked item**, in every branch below — the ⇧-branch included, where
    /// the anchor deliberately stays put. That asymmetry is the definition of the two:
    /// `TransientBoardState.selectionHead` is where the *next* step starts, and a ⇧-click moves it
    /// exactly as a plain one does.
    public struct Outcome: Sendable, Equatable {
        public var selection: ItemReferenceSet
        public var anchor: ItemID?
        public var head: ItemID?

        public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) {
            self.selection = selection
            self.anchor = anchor
            self.head = head
        }

        /// Nothing selected, nothing to range from, nowhere to step from — the toggle-off outcomes.
        static let cleared = Outcome(selection: .empty, anchor: nil, head: nil)
    }

    /// The grammar, one call.
    ///
    /// - Parameters:
    ///   - target: what was clicked, with the surface's container (see `SelectionTarget`).
    ///   - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
    ///   - selection: the board's current selection.
    ///   - anchor: the range origin — `TransientBoardState.selectionAnchor`.
    ///   - snapshot: the board as it is now; every order list and every kind is derived from it.
    ///   - togglesOnRepeat: **the lane's click-again-to-unselect**, and only the lane's
    ///     (04-interactions.md § Selection: "single click selects the lane (click again to
    ///     unselect)", and the header "toggles like empty space (settled)"). A card face passes
    ///     `false`: Finder does not deselect a file by clicking it twice, and neither do we.
    ///   - filter: the live search filter (04 § Search). Only the ⇧-branch reads it — a range walks
    ///     what is *on the board*, which under a search is the survivors — because the other two
    ///     name their target outright and a click on something the user can see needs no permission
    ///     from the predicate.
    public static func click(
        _ target: SelectionTarget,
        modifier: ClickModifier,
        selection: ItemReferenceSet,
        anchor: ItemID?,
        snapshot: BoardModel,
        togglesOnRepeat: Bool = false,
        filter: SearchFilter = .inactive
    ) -> Outcome {
        switch modifier {
        case .plain:
            return plain(target, selection: selection, togglesOnRepeat: togglesOnRepeat)
        case .command:
            return command(target, selection: selection, snapshot: snapshot)
        case .shift:
            return shift(target, selection: selection, anchor: anchor, snapshot: snapshot, filter: filter)
        }
    }

    // MARK: - The three branches

    /// **Plain**: the selection becomes exactly what was clicked, and the click becomes the anchor.
    ///
    /// The one exception is `togglesOnRepeat`, and it tests for *sole membership* rather than mere
    /// containment: a lane click that lands inside a multi-lane selection narrows it to that lane
    /// (replace), because "click again to unselect" is about the lane the user already had, not
    /// about wiping a selection they built with ⌘.
    private static func plain(
        _ target: SelectionTarget,
        selection: ItemReferenceSet,
        togglesOnRepeat: Bool
    ) -> Outcome {
        if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] {
            return .cleared
        }
        return Outcome(
            selection: ItemReferenceSet(ids: [target.id], container: target.container),
            anchor: target.id,
            head: target.id
        )
    }

    /// **⌘-click toggles** — but only *within* a set it can legally join. Crossing the container (a
    /// trash row clicked while board cards are selected) or, **on the board**, the kind (a card
    /// clicked while lanes are selected) is not a mixed selection and not a refusal: it is a
    /// **replace**, the same outcome a plain click would give, because the click unambiguously names
    /// a new set of one.
    ///
    /// **Inside the trash the kind clause simply does not apply** (04-interactions.md ▸ The trash,
    /// re-ruled 2026-07-31): a ⌘-click on a lane row extends a set of trash cards, because there
    /// "cards and lane rows select together". The container clause is untouched.
    ///
    /// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a
    /// selection whose members all name nothing the board renders counts as empty, so a ⌘-click
    /// after a foreign delete starts a fresh set rather than extending a ghost — which is why the
    /// call stands even in the trash, where its *answer* no longer gates anything.
    private static func command(
        _ target: SelectionTarget,
        selection: ItemReferenceSet,
        snapshot: BoardModel
    ) -> Outcome {
        guard selection.container == target.container,
              let current = kind(of: selection, in: snapshot),
              target.container == .trash || current == target.kind
        else {
            return plain(target, selection: selection, togglesOnRepeat: false)
        }

        var ids = selection.ids
        if ids.remove(target.id) == nil {
            ids.insert(target.id)
        } else if ids.isEmpty {
            // The last member toggled out: nothing is selected, so there is nothing to range from.
            return .cleared
        }
        return Outcome(
            selection: ItemReferenceSet(ids: ids, container: target.container),
            anchor: target.id,
            head: target.id
        )
    }

    /// **⇧-click range-extends from the anchor**, Finder-list style: the whole range replaces the
    /// selection, and the anchor stays where it is so successive ⇧-clicks sweep out from the same
    /// origin rather than walking it along.
    ///
    /// The anchor is valid **iff both it and the target sit in the same order list** — which folds
    /// the nil anchor, the vanished anchor, and every standing axis crossing into one test, since a
    /// list is one container, and on the board one kind of it (`order(of:in:)`; inside the trash the
    /// list is the whole column, so a range from a card to a lane row is an ordinary range).
    /// An invalid anchor makes the click a plain one, never a no-op:
    /// the keyboard's ⇧-arrow goes inert at a boundary because its next step is ambiguous, while a
    /// click names an unambiguous target and so always has something to do.
    private static func shift(
        _ target: SelectionTarget,
        selection: ItemReferenceSet,
        anchor: ItemID?,
        snapshot: BoardModel,
        filter: SearchFilter
    ) -> Outcome {
        guard let anchor,
              let span = range(
                  from: anchor,
                  to: target.id,
                  kind: target.kind,
                  in: target.container,
                  snapshot: snapshot,
                  filter: filter
              )
        else {
            return plain(target, selection: selection, togglesOnRepeat: false)
        }
        return Outcome(
            selection: ItemReferenceSet(ids: span, container: target.container),
            anchor: anchor,
            head: target.id
        )
    }

    /// The ids between two items in one order list, inclusive — **the span both extension gestures
    /// select**, ⇧-click and ⇧-arrow alike.
    ///
    /// It is a function rather than a branch inside `shift` because the keyboard needs the identical
    /// answer: "a ⇧-arrow extends" (04-interactions.md ▸ Grammar) means exactly the range a ⇧-click
    /// to the same item would produce, and two implementations of one span is two chances for the
    /// pointer and the keyboard to disagree about what a range is.
    ///
    /// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
    /// anchor's caller-side absence, and every standing axis crossing into one test. The callers
    /// differ on what they do with that: a click degrades to a
    /// plain click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is
    /// ambiguous).
    ///
    /// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
    /// hid is absent from the list, so a range aimed at it answers `nil` and each caller degrades
    /// exactly as it does for a card an agent deleted. The span between two *visible* endpoints
    /// likewise collects only survivors — 04 § Search's "ranges … read [the filter]".
    public static func range(
        from: ItemID,
        to: ItemID,
        kind: SelectionKind,
        in container: ItemContainer,
        snapshot: BoardModel,
        filter: SearchFilter = .inactive
    ) -> Set<ItemID>? {
        let list = order(of: kind, in: container, snapshot: snapshot, filter: filter)
        guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
        return Set(start <= end ? list[start...end] : list[end...start])
    }

    // MARK: - The order lists

    /// The list a ⇧-range walks for one (container, kind) pair — **the single place a "what's on the
    /// board, in what order" question is answered** for the pointer.
    ///
    /// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
    /// else** — the filter "is the single source of truth for what's on the board … ranges … all
    /// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
    /// walk is stated in terms of these lists, so one parameter narrows all of them together.
    ///
    /// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
    /// order, a lane-index lookup, the successor's container) read exactly as they did before the
    /// filter existed; the callers that *are* the board's input grammar pass the store's query.
    ///
    /// **The lane list takes no filter**, because a card query hides no *live* lane — see
    /// `SearchFilter`. **The trash's lane list is filtered like its cards**, and that asymmetry is
    /// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
    /// title only") — a trashed lane is a row in a column, not a container a query can empty.
    ///
    /// **The trash has one list, whatever the kind** (04-interactions.md ▸ The trash, re-ruled
    /// 2026-07-31 — superseding the kind-narrowed slices this returned while the trash's grammar was
    /// kind-homogeneous): "⇧-click ranges … sweep every row". So a trash range walks
    /// `BoardModel.trashEntries` — the column as drawn — and picks up rows of both kinds between its
    /// endpoints, which is the ruling implemented as an absence rather than as a clause. The `kind`
    /// argument is simply not consulted there; on the board it still names one of two lists, because
    /// the live board is still cards XOR lanes.
    public static func order(
        of kind: SelectionKind,
        in container: ItemContainer,
        snapshot: BoardModel,
        filter: SearchFilter = .inactive
    ) -> [ItemID] {
        switch container {
        case .trash:
            return trashRows(in: snapshot, filter: filter)
        case .board:
            switch kind {
            case .card: return boardCards(in: snapshot, filter: filter)
            case .lane: return lanes(in: snapshot)
            }
        }
    }

    /// The board's cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane
    /// selection flattens left-to-right, top-to-bottom)", the multi-drag order the ⌘N target rule
    /// and paste anchoring already share (04-interactions.md ▸ Drag and drop, ▸ The map).
    ///
    /// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten
    /// is one walk — `NewCardTarget.resolve`'s walk, in list form.
    ///
    /// **The filter narrows the walk in place**, which is what makes a search-time ⇧-range and
    /// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
    /// predicate to the same cards, one lane at a time, and this is that collection flattened.
    public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
        var ids: [ItemID] = []
        for lane in snapshot.lanes {
            for card in lane.cards where filter.matches(card) {
                ids.append(card.id)
            }
        }
        return ids
    }

    /// The board's lanes, left to right.
    ///
    /// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
    /// query empties is still a lane on the board — the width division is layout, and the badge
    /// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
    /// part of the board grammar a search does not narrow.
    public static func lanes(in snapshot: BoardModel) -> [ItemID] {
        snapshot.lanes.map(\.id)
    }

    /// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted
    /// by `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "the trash sorts by
    /// `modified` descending", newest-first falling out of the stamp rather than a minted rank).
    ///
    /// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
    /// filter exactly like any other card").
    ///
    /// **Not the ranging grammar's list any more** (kind-blind trash selection, 2026-07-31 — see
    /// `order(of:in:)`): this is the kind-narrowed slice, kept for the consumers that genuinely mean
    /// "the trash's *cards*" and for the search suite that pins the predicate.
    public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
        snapshot.trash.filter { filter.matches($0) }.map(\.id)
    }

    /// The trash's **lane rows**, top to bottom — the opaque units (03-board-ui.md § Trash, lanes
    /// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`).
    ///
    /// Its own list rather than a kind flag on `trashCards`, and for the same reason that one
    /// survives: some consumers mean the rows of one kind. The rows' positions among the cards are
    /// `trashRows`' business.
    public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
        snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
    }

    /// **Every row the trash column shows, both kinds, in the column's own order** — what
    /// *everything* in the trash walks (04-interactions.md ▸ The trash: "inside, plain arrows walk
    /// every row, card and lane row alike", and since 2026-07-31 the ranging grammar too: "clicks,
    /// ⇧-click ranges, ⇧-arrow extension, and the rubber band all sweep every row").
    ///
    /// Navigation and ranging read the same sequence, which is what the kind-blind ruling bought:
    /// there is no longer a per-kind list that could disagree with the column about "the row below
    /// this one". One merge for all of it — `BoardModel.trashEntries`.
    public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
        snapshot.trashEntries.filter { filter.matches($0) }.map(\.id)
    }

    // MARK: - The current selection's kind

    /// Which level the selection holds, or `nil` when it holds nothing its container renders.
    ///
    /// **On the board any member answers, because the set is homogeneous there** — but the walk is
    /// the snapshot's order rather than the set's iteration order, so the answer is deterministic
    /// even for a set that somehow was not. Members that name nothing are ignored, and a set of only
    /// such members reads as empty: a selection the next reload will drop must not decide what a
    /// click does now.
    ///
    /// **In the trash it answers for the topmost row, and a mixed set is legal** (04-interactions.md
    /// ▸ The trash, re-ruled 2026-07-31 — trash selection is kind-blind). Two callers still want it
    /// there and neither is asking about homogeneity: the ⌘-click branch uses it as a liveness test
    /// ("does this selection still name anything"), and the clipboard's capture wants the payload's
    /// kind — which is sound precisely because Cut and Copy are validated against
    /// `mixesKinds(_:in:)` first. Anything that needs to know whether the set is of one kind asks
    /// that, never this.
    public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
        guard !selection.isEmpty else { return nil }
        switch selection.container {
        case .trash:
            return snapshot.trashEntries.first { selection.ids.contains($0.id) }?.kind
        case .board:
            for lane in snapshot.lanes {
                if selection.ids.contains(lane.id) { return .lane }
                if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card }
            }
            return nil
        }
    }

    /// **Whether the selection names rows of both kinds** — the predicate the *exits* are validated
    /// against now that the trash's selection grammar is kind-blind (04-interactions.md ▸ The trash,
    /// ruled 2026-07-31: "The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X
    /// validation)").
    ///
    /// Only the trash can answer `true`: the live board's grammar is still cards XOR lanes, and its
    /// branch is a walk rather than a `false` so a caller cannot be misled by a set some future
    /// gesture built wrongly.
    ///
    /// Rows the container no longer holds are ignored, like everywhere else here — a selection whose
    /// lane row a foreign purge took is a single-kind selection now, and greying out ⌘C for a ghost
    /// would be a refusal the user cannot see the reason for.
    public static func mixesKinds(_ selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
        guard !selection.isEmpty else { return false }
        var seen: SelectionKind?
        switch selection.container {
        case .trash:
            for entry in snapshot.trashEntries where selection.ids.contains(entry.id) {
                guard let seen else {
                    seen = entry.kind
                    continue
                }
                if seen != entry.kind { return true }
            }
        case .board:
            for lane in snapshot.lanes {
                if selection.ids.contains(lane.id) {
                    if seen == .card { return true }
                    seen = .lane
                }
                if lane.cards.contains(where: { selection.ids.contains($0.id) }) {
                    if seen == .lane { return true }
                    seen = .card
                }
            }
        }
        return false
    }

    // MARK: - Successor on delete

    /// What ⌫ selects after deleting `ids` — 04-interactions.md ▸ The map's Finder-style
    /// successor sibling, as a pure function of the **pre-write** snapshot.
    ///
    /// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
    /// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container =
    /// > nothing selected) — repeated ⌫ walks down a lane.
    ///
    /// Three decisions the wording implies and this states:
    ///
    /// - **The container is the *last* deleted item's**, in flatten order — the same "last member"
    ///   the ⌘N target rule and paste anchoring already share. A selection spanning lanes therefore
    ///   lands in the rightmost/bottom-most one, which is where the user was working.
    /// - **The survivor search is forward first, then backward**: the first surviving sibling *after*
    ///   the last deleted position, else the last surviving sibling *before* the first deleted one.
    ///   Forward is what makes repeated ⌫ walk down a lane rather than bouncing.
    /// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller
    ///   clears.
    ///
    /// **Both stagings of Delete get one** (04, resettled 2026-07-28 — "one Delete vocabulary,
    /// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
    /// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
    /// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
    /// **The trash's own successor is kind-blind** — ruled, and the branch below says so.
    ///
    /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
    /// reload-survival rule: "the selection just shrinks"), which is why this is called by
    /// `BoardStore`'s delete paths and by nothing on the reload path.
    ///
    /// **The container is what the surface is *showing*.** Under a search the successor must be a
    /// card the user can see — picking a hidden neighbour would hand the selection straight back to
    /// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
    /// filter narrows the container, and repeated ⌫ walks down the *filtered* lane.
    public static func successor(
        afterDeleting ids: Set<ItemID>,
        in container: ItemContainer = .board,
        snapshot: BoardModel,
        filter: SearchFilter = .inactive
    ) -> ItemID? {
        guard !ids.isEmpty else { return nil }
        let selection = ItemReferenceSet(ids: ids, container: container)
        guard let kind = kind(of: selection, in: snapshot) else { return nil }

        let siblings: [ItemID]
        switch (container, kind) {
        case (.trash, _):
            // **The siblings are every row, both kinds** (04-interactions.md ▸ The map, ruled
            // 2026-07-31 — ratifying what stood here as an interim): "In the trash the successor walk
            // is kind-blind: the next row of either kind, in the same all-rows order plain arrows
            // walk — a successor is a fresh singleton selection, so the landing violates no grammar,
            // and repeated ⌘⌫ empties a mixed trash without dead-ends". The alternative — kind-scoped
            // siblings — clears the selection whenever the purged row was its kind's last, which is a
            // deselect wearing a successor's clothes.
            siblings = trashRows(in: snapshot, filter: filter)
        case (.board, .lane):
            siblings = lanes(in: snapshot)
        case (.board, .card):
            // The last selected card in flatten order names the lane; its lane's rendered cards are
            // the container the successor is drawn from.
            guard let last = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
                  let lane = snapshot.lanes.first(where: { lane in
                      lane.cards.contains { $0.id == last }
                  })
            else { return nil }
            siblings = lane.cards.filter { filter.matches($0) }.map(\.id)
        }

        let doomed = siblings.indices.filter { ids.contains(siblings[$0]) }
        guard let first = doomed.first, let last = doomed.last else { return nil }
        if let after = siblings[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
        return siblings[..<first].last { !ids.contains($0) }
    }
}

// MARK: - The rubber band

/// One item the marquee can sweep: its identity, its level, its container, and where it is drawn.
///
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
/// geometry is the layout's own answer, and re-deriving it would be a second one.
public struct MarqueeTarget: Sendable, Equatable {
    public var id: ItemID
    public var kind: SelectionKind
    public var container: ItemContainer
    public var frame: CGRect

    public init(id: ItemID, kind: SelectionKind, container: ItemContainer, frame: CGRect) {
        self.id = id
        self.kind = kind
        self.container = container
        self.frame = frame
    }
}

/// What a rubber band selects, as a pure function of the band, the drawn frames, and the container
/// the band started in (`SelectionGrammarTests`).
///
/// The two rules it exists to state, both 04-interactions.md's:
///
/// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why
///   `container` is a parameter rather than something derived from what the rect happens to touch: a
///   band begun on the board and dragged over the trash column selects board cards and nothing else.
/// - **On the board the band never selects lanes** (§ Selection gives it to cards: "click-drag
///   rubber-bands across lanes" — across them, not over them). A live lane is never registered as a
///   target at all, so that half is true twice over.
/// - **In the trash it sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 —
///   superseding the card-only band this enforced while the trash's grammar was kind-homogeneous):
///   "the rubber band [sweeps] every row (the band's full-height backdrop covers both kinds)". A
///   trashed lane row is registered as a target — the arrows navigate by the same frames
///   (`NavigationMath`) and the band's begin guard reads them — so dropping the kind filter on that
///   side is the whole of the change.
public enum MarqueeMath {

    /// The ids `rect` sweeps.
    public static func selection(
        rect: CGRect,
        targets: [MarqueeTarget],
        in container: ItemContainer
    ) -> Set<ItemID> {
        Set(
            targets.lazy
                .filter { target in
                    guard target.container == container, rect.intersects(target.frame) else {
                        return false
                    }
                    // Kind-blind in the trash, card-only on the board.
                    return container == .trash || target.kind == .card
                }
                .map(\.id)
        )
    }

    /// Which of two drawn rows is "higher" — top edge, then leading edge, then identity.
    ///
    /// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
    /// the same way twice.
    ///
    /// Used by `NavigationMath`, which breaks its score ties with it: two candidates that a metric
    /// cannot separate must still be separated the same way twice.
    static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
        if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
        if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
        return lhs.id.rawValue < rhs.id.rawValue
    }
}
