import CoreGraphics
import Observation

// MARK: - MarqueeSession

/// Window-local state for an in-flight rubber band — `LaneResizeSession`'s sibling, and as small
/// for its reason: it holds only what the *pointer* contributes, because everything the selection
/// needs beyond that is read fresh at gesture time (`MarqueeTargetRegistry`, `MarqueeMath`).
///
/// **The side is frozen at the origin** — "a rubber-band stays on the side of the boundary it
/// started on" (04-interactions.md ▸ The trash). A band begun on the board and dragged across the
/// trash column keeps selecting live cards; a band begun in the trash keeps selecting rows. That is
/// the whole reason the side is stored here rather than re-derived from what the rect touches.
///
/// **Nothing here animates.** 03-board-ui.md § Motion names the marquee twice: "the rubber-band
/// marquee tracks the cursor 1:1 (an eased band visibly lags the mouse)" and, in the
/// animation-free-by-construction list, "the marquee rectangle (1:1 cursor following — animating
/// input echo would be lag)". So the rect is drawn outside every animated transaction on the strip,
/// and the selection churn it drives is deliberately un-keyed too ("never on broad state like the
/// selection set").
@MainActor
@Observable
final class MarqueeSession {

    /// Where the band was begun, in `BoardView.stripSpace` coordinates; `nil` when idle — which is
    /// what "no marquee" means here rather than a separate flag.
    private(set) var origin: CGPoint?

    /// The pointer's current position, in the same space. Meaningless while `origin` is `nil`.
    private(set) var current: CGPoint = .zero

    /// The side of the live/trash boundary this band selects on, frozen at `begin`.
    private(set) var container: ItemContainer = .board

    /// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
    /// lane header's threshold because this gesture arms on *any* empty surface, and a click that
    /// clears the selection must not leave a one-pixel band behind it.
    static let minimumDistance: CGFloat = 5

    var isActive: Bool { origin != nil }

    /// The band, normalized so it is a rectangle regardless of which way the drag ran; `nil` when
    /// idle, which is also the overlay's "draw nothing".
    var rect: CGRect? {
        guard let origin else { return nil }
        return CGRect(
            x: min(origin.x, current.x),
            y: min(origin.y, current.y),
            width: abs(current.x - origin.x),
            height: abs(current.y - origin.y)
        )
    }

    func begin(at point: CGPoint, in container: ItemContainer) {
        origin = point
        current = point
        self.container = container
    }

    func update(to point: CGPoint) {
        guard isActive else { return }
        current = point
    }

    /// Ends the band. **The selection stays exactly as the last sample computed it** — a release is
    /// not a commit here, because nothing was written: the band was only ever a way of naming a set.
    /// Idempotent, like every other session's `end`.
    func end() {
        origin = nil
        current = .zero
    }
}

// MARK: - MarqueeTargetRegistry

/// Where each sweepable item is drawn, keyed by identity — the geometry half of the rubber band.
///
/// **The views register themselves** (`onGeometryChange` in `BoardView.stripSpace`, removal in
/// `onDisappear`) rather than the band re-deriving the masonry's arithmetic: the layout already
/// computed those frames, and a second computation is a second answer that could disagree with what
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
/// flight, and a foreign reload — the frames simply re-register.
///
/// **A live lane is never registered.** The band selects cards, and a lane on the strip has no entry
/// here at all — 04-interactions.md § Selection's "click-drag rubber-bands across lanes" made
/// structural rather than filtered.
///
/// **A trashed lane row is registered** (lanes rejoined the trash 2026-07-29), and not for the band:
/// this registry is also the arrows' geometry (`NavigationMath`, where "plain arrows walk every row,
/// card and lane row alike") and the begin guard's universe below. The band still never selects one
/// — `MarqueeMath` filters by kind, which is where that rule lives for both containers.
///
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
/// that geometrically rather than by gesture priority is what keeps the two from fighting.
///
/// **Deliberately not `@Observable`** — `LaneDropRegistry`'s rule, for the same reason and with the
/// same proof: nothing renders off it. Every reader asks at *event* time — the band's begin guard and
/// its sample loop from inside a drag gesture (`MarqueeControl.gesture(in:)`), the arrows from inside
/// a key handler (`BoardView.step`, `.extend`, over `NavigationMath`) — so no view body ever reads a
/// frame from here and none needs invalidating when one moves.
///
/// What observing it cost is the whole reason the rule is stated: every card face registers here
/// through `onGeometryChange`, and a drag's make-room reflow *animates positions*, so for the ~0.18 s
/// of every proposal change each sliding face re-fires its observer once per display frame. Observed,
/// each of those writes was an `@Observable` mutation with registrar bookkeeping — dozens of cards at
/// the display's refresh rate, invalidating the strip on top of the reflow's own render work (an A/B
/// on 2026-07-31 confirmed it: removing the card-face registration alone made dragging visibly
/// smoother). Unobserved, a registration is a dictionary store and nothing else.
///
/// The alternative — suppressing the writes while a drag is in flight — was rejected: a suppressed
/// write never replays if that card's geometry does not change again after the drag ends, which
/// leaves the band and the arrows navigating by stale rectangles. The frames must stay live; only
/// their observation had to go.
@MainActor
final class MarqueeTargetRegistry {

    private(set) var targets: [ItemID: MarqueeTarget] = [:]

    var all: [MarqueeTarget] { Array(targets.values) }

    func update(_ target: MarqueeTarget) {
        targets[target.id] = target
    }

    func remove(_ id: ItemID) {
        targets.removeValue(forKey: id)
    }

    /// Whether `point` lands on something already drawn — the band's begin guard.
    func contains(_ point: CGPoint) -> Bool {
        targets.values.contains { $0.frame.contains(point) }
    }
}
