import CoreGraphics

/// Edge-autoscroll geometry for a scroll view hosting drop targets, as pure arithmetic — no view,
/// no timer, no `NSScrollView` (`DragAutoScrollMathTests`). Ported from the pathfinder, whose
/// numbers are what was proven; the reasoning is reproduced because the behaviour is.
///
/// A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below
/// the fold — and nothing in `DropSlotMath` can reach them, since the proposal is a function of the
/// cursor over the *visible* resting layout. A card session hovering near either end of a lane's
/// scroll area therefore scrolls it, continuously, until the pointer leaves the band or the drag
/// ends (DRAG-REORDER.md § Edge autoscroll).
///
/// ## The geometry
///
/// Along each axis the visible area owns an **activation band** of `band` points at either end. A
/// pointer inside a band scrolls that way at a speed that ramps with how deep into the band it
/// sits: `minSpeed` at the band's inner edge, `maxSpeed` at (and beyond) the visible area's own
/// edge. Outside both bands the velocity is exactly zero, so a drag that merely crosses the middle
/// of a lane never scrolls it.
///
/// The `minSpeed` floor is deliberate: entering a band produces immediate, visible motion instead
/// of an imperceptible crawl that leaves the user wondering whether autoscroll exists at all. It is
/// the one discontinuity in the ramp, and it sits exactly on the band boundary, where the pointer
/// is moving anyway.
///
/// The pointer may also sit *outside* the visible area and still drive it — generously above and
/// below (the lane's header and the strip's padding are still "this lane"), but barely sideways, so
/// a drag over the neighbouring lane never scrolls this one. `engagementRect` is that reach; a
/// pointer outside it drives nothing.
///
/// Everything is axis-agnostic: the board strip has nothing to autoscroll today (every lane shares
/// the window width and the strip fills the window height — 03-board-ui.md § Layout), and the same
/// math would serve one unchanged if that ever changes.
///
/// The ticking driver — the physical-mouse read, the re-resolved proposal on every step, the
/// structurally terminated task — is the drag session's, not this file's.
enum DragAutoScrollMath {

    /// Thickness of the activation band at each end of the visible area.
    static let band: CGFloat = 56

    /// Speed at the band's inner edge — the floor described above, in points/second.
    static let minSpeed: CGFloat = 90

    /// Speed at (and beyond) the visible area's own edge, in points/second. Deliberately not
    /// faster: every scroll step re-resolves the drop proposal against the lane's resting grid, and
    /// the distance the content travels between two resolutions is this speed divided by the tick
    /// rate.
    static let maxSpeed: CGFloat = 800

    /// How far above the visible area the pointer may sit and still drive it — enough to cover the
    /// lane's header, which is where a drag naturally goes to scroll up.
    ///
    /// **Font-derived, unlike the three figures above it, and the split is the point.** `band`,
    /// `minSpeed` and `maxSpeed` describe the *cursor's* relationship to an edge — how near counts as
    /// near, how fast the content should answer — and none of that changes because the board is drawn
    /// larger; they stay fixed for `LaneResizeSession.reentry`'s reason. The three reaches describe
    /// **where the lane's furniture is**, and that furniture moves: this one is specified as "enough
    /// to cover the lane's header", and a header at 200% zoom is twice as tall as the 48 points that
    /// covered it at 13pt (03-board-ui.md ▸ Layout — zoom).
    ///
    /// 3.7 em: 48 points at the standard body size, which is what the reach has always been.
    static func reachAbove(bodyPointSize: CGFloat) -> CGFloat {
        BoardMetrics.em(3.7, bodyPointSize: bodyPointSize)
    }

    /// The same below, covering the lane's bottom padding — 1.85 em, the standard size's 24 points.
    static func reachBelow(bodyPointSize: CGFloat) -> CGFloat {
        BoardMetrics.em(1.85, bodyPointSize: bodyPointSize)
    }

    /// The sideways reach — kept under half the distance between two lanes' scroll areas so only
    /// one lane ever engages.
    ///
    /// **It is the inter-lane gap, named as itself** rather than as a coincidentally equal number:
    /// the distance between two lanes' scroll areas is the gap plus a plate padding on each side
    /// (0.9 + 2 × 0.45 em), so half of it is exactly `stripGap`. Writing it that way is what keeps
    /// the sentence above true at every zoom level instead of only at 13pt, where 12 happened to be
    /// the answer.
    static func reachSide(bodyPointSize: CGFloat) -> CGFloat {
        BoardMetrics.stripGap(bodyPointSize: bodyPointSize)
    }

    /// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer
    /// must be in to drive this scroller at all.
    static func engagementRect(viewport: CGSize, bodyPointSize: CGFloat) -> CGRect {
        let above = reachAbove(bodyPointSize: bodyPointSize)
        let below = reachBelow(bodyPointSize: bodyPointSize)
        let side = reachSide(bodyPointSize: bodyPointSize)
        return CGRect(x: -side,
                      y: -above,
                      width: viewport.width + side * 2,
                      height: viewport.height + above + below)
    }

    /// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
    /// visible extent runs `0...length`: negative scrolls toward the start (content moves
    /// down/right), positive toward the end.
    ///
    /// `band` is clamped to half the extent, so the two bands of a short viewport meet rather than
    /// overlap and its exact centre still resolves to "no scrolling".
    static func velocity(position: CGFloat,
                         length: CGFloat,
                         band: CGFloat = band,
                         minSpeed: CGFloat = minSpeed,
                         maxSpeed: CGFloat = maxSpeed) -> CGFloat {
        guard length > 0 else { return 0 }
        let band = min(band, length / 2)
        guard band > 0 else { return 0 }

        let depth: CGFloat
        let direction: CGFloat
        if position < band {
            depth = (band - position) / band
            direction = -1
        } else if position > length - band {
            depth = (position - (length - band)) / band
            direction = 1
        } else {
            return 0
        }
        return direction * (minSpeed + (maxSpeed - minSpeed) * min(max(depth, 0), 1))
    }

    /// Both axes at once for a pointer in the visible area's own coordinates.
    static func velocity(pointer: CGPoint,
                         viewport: CGSize,
                         band: CGFloat = band,
                         minSpeed: CGFloat = minSpeed,
                         maxSpeed: CGFloat = maxSpeed) -> CGVector {
        CGVector(
            dx: velocity(position: pointer.x, length: viewport.width,
                         band: band, minSpeed: minSpeed, maxSpeed: maxSpeed),
            dy: velocity(position: pointer.y, length: viewport.height,
                         band: band, minSpeed: minSpeed, maxSpeed: maxSpeed)
        )
    }

    /// One tick's scroll offset: `current` advanced by `velocity` for `elapsed` seconds, clamped
    /// into the scrollable range. An empty or inverted range (content shorter than the viewport)
    /// pins to `minOffset`.
    static func nextOffset(current: CGFloat,
                           velocity: CGFloat,
                           elapsed: CGFloat,
                           minOffset: CGFloat,
                           maxOffset: CGFloat) -> CGFloat {
        let upper = max(minOffset, maxOffset)
        return min(max(current + velocity * elapsed, minOffset), upper)
    }
}
