import CoreGraphics
import Testing
@testable import Kanban

/// `DragAutoScrollMath` — given a viewport and a pointer inside (or just outside) it, how fast, and
/// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are
/// what was proven. The live driver is the drag session's; this is the decision it makes once per
/// frame of the display the lane is on (`DragAutoScrollDriverTests`, DRAG-REORDER.md § Edge
/// autoscroll).

private let length: CGFloat = 400
private let band = DragAutoScrollMath.band
private let minSpeed = DragAutoScrollMath.minSpeed
private let maxSpeed = DragAutoScrollMath.maxSpeed

private func velocity(_ position: CGFloat, length viewport: CGFloat = length) -> CGFloat {
    DragAutoScrollMath.velocity(position: position, length: viewport)
}

private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
    abs(value - expected) <= tolerance
}

@Suite("DragAutoScrollMath")
struct DragAutoScrollMathTests {

    // MARK: The neutral middle

    @Test("The middle of the viewport never scrolls")
    func middleNeverScrolls() {
        for position in stride(from: band, through: length - band, by: 8) {
            #expect(velocity(position) == 0, "cursor \(position) is outside both bands")
        }
        // The band boundaries themselves are neutral — a band is the region strictly inside one.
        #expect(velocity(band) == 0)
        #expect(velocity(length - band) == 0)
    }

    // MARK: Direction

    @Test("The leading band scrolls toward the start and the trailing band toward the end")
    func direction() {
        #expect(velocity(band - 1) < 0)
        #expect(velocity(0) < 0)
        #expect(velocity(length - band + 1) > 0)
        #expect(velocity(length) > 0)
    }

    // MARK: The ramp

    @Test("Speed ramps with edge proximity, on both ends")
    func speedRampsWithProximity() {
        var previous = abs(velocity(band - 0.5))
        for position in stride(from: band - 8, through: 0, by: -8) {
            let speed = abs(velocity(position))
            #expect(speed > previous, "cursor \(position) should beat the shallower sample")
            previous = speed
        }
        previous = abs(velocity(length - band + 0.5))
        for position in stride(from: length - band + 8, through: length, by: 8) {
            let speed = abs(velocity(position))
            #expect(speed > previous, "cursor \(position) should beat the shallower sample")
            previous = speed
        }
    }

    @Test("The ramp spans the floor to the ceiling, linearly")
    func rampIsLinearBetweenFloorAndCeiling() {
        // Just inside the band: the floor, which exists so entering a band produces visible motion
        // rather than an imperceptible crawl. At the viewport edge: the ceiling. Halfway: the mean.
        #expect(isClose(abs(velocity(band - 0.0001)), minSpeed, 0.01))
        #expect(isClose(abs(velocity(0)), maxSpeed))
        #expect(isClose(abs(velocity(band / 2)), (minSpeed + maxSpeed) / 2))
        #expect(isClose(abs(velocity(length)), maxSpeed))
        #expect(isClose(abs(velocity(length - band / 2)), (minSpeed + maxSpeed) / 2))
    }

    @Test("Beyond the viewport edge the speed saturates rather than growing")
    func saturatesBeyondTheEdge() {
        // A pointer over the lane header (above the scroll area) or below its bottom padding drives
        // the fastest scroll, never faster.
        #expect(isClose(velocity(-40), -maxSpeed))
        #expect(isClose(velocity(-4000), -maxSpeed))
        #expect(isClose(velocity(length + 40), maxSpeed))
    }

    // MARK: Degenerate viewports

    @Test("A short viewport halves its bands instead of overlapping them")
    func shortViewport() {
        let short: CGFloat = 60
        #expect(velocity(30, length: short) == 0, "the exact centre still resolves to no scrolling")
        #expect(velocity(29, length: short) < 0)
        #expect(velocity(31, length: short) > 0)
        #expect(isClose(abs(velocity(0, length: short)), maxSpeed))
    }

    @Test("An empty or inverted viewport never scrolls")
    func emptyViewport() {
        #expect(velocity(0, length: 0) == 0)
        #expect(velocity(10, length: -5) == 0)
    }

    // MARK: Two axes

    @Test("The two axes are resolved independently")
    func axesAreIndependent() {
        let viewport = CGSize(width: 400, height: 400)
        let bottom = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 390), viewport: viewport)
        #expect(bottom.dx == 0)
        #expect(bottom.dy > 0)

        let corner = DragAutoScrollMath.velocity(pointer: CGPoint(x: 2, y: 2), viewport: viewport)
        #expect(corner.dx < 0)
        #expect(corner.dy < 0)

        let centre = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 200), viewport: viewport)
        #expect(centre.dx == 0)
        #expect(centre.dy == 0)
    }

    // MARK: Engagement reach

    /// The standard system body size — the ruler every figure below was tuned against.
    private static let standardBody: CGFloat = 13

    @Test("Engagement reaches over the header but barely sideways")
    func engagementReach() {
        let viewport = CGSize(width: 240, height: 400)
        let size = Self.standardBody
        let above = DragAutoScrollMath.reachAbove(bodyPointSize: size)
        let below = DragAutoScrollMath.reachBelow(bodyPointSize: size)
        let side = DragAutoScrollMath.reachSide(bodyPointSize: size)
        let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size)

        #expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always")

        // Above it (the lane header) and below it (the strip's padding).
        #expect(reach.contains(CGPoint(x: 120, y: -above + 1)))
        #expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1)))
        #expect(!reach.contains(CGPoint(x: 120, y: -above - 1)))
        #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1)))

        // Sideways: only a sliver, so the neighbouring lane never engages.
        #expect(reach.contains(CGPoint(x: -side + 1, y: 200)))
        #expect(!reach.contains(CGPoint(x: -side - 1, y: 200)))
        #expect(!reach.contains(CGPoint(x: viewport.width + side + 1, y: 200)))

        // The sideways reach must stay under half the distance between two lanes' scroll areas, or
        // two lanes would scroll at once.
        #expect(side < 28 / 2)
    }

    @Test("The standard body size draws the reaches it always drew")
    func reachesAtStandardBodySize() {
        let size = Self.standardBody
        #expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48)
        #expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24)
        #expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12)
    }

    /// The reaches are distances to the lane's own furniture — a header, a padding, a gap — and all
    /// three of those grow with the board's zoom (03-board-ui.md ▸ Layout — zoom). A reach fixed in
    /// points would stop covering the header it is specified against.
    @Test("Every reach grows with the board's ruler")
    func reachesFollowTheRuler() {
        let standard = Self.standardBody
        let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0)

        #expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed)
            > DragAutoScrollMath.reachAbove(bodyPointSize: standard))
        #expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed)
            > DragAutoScrollMath.reachBelow(bodyPointSize: standard))
        #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed)
            > DragAutoScrollMath.reachSide(bodyPointSize: standard))

        // And the sideways rule holds on the zoomed ruler too: half the distance between two lanes'
        // scroll areas is the gap plus a plate padding on each side.
        let gap = BoardMetrics.stripGap(bodyPointSize: zoomed)
        let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed)
        #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 2)
    }

    // MARK: Stepping the offset

    @Test("One tick advances the offset by velocity × elapsed")
    func nextOffsetAdvances() {
        #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: 600, elapsed: 0.5,
                                              minOffset: 0, maxOffset: 1000) == 400)
        #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: -600, elapsed: 0.1,
                                              minOffset: 0, maxOffset: 1000) == 40)
    }

    @Test("A tick clamps into the scrollable range")
    func nextOffsetClamps() {
        #expect(DragAutoScrollMath.nextOffset(current: 10, velocity: -800, elapsed: 1,
                                              minOffset: 0, maxOffset: 1000) == 0)
        #expect(DragAutoScrollMath.nextOffset(current: 990, velocity: 800, elapsed: 1,
                                              minOffset: 0, maxOffset: 1000) == 1000)
        // Content shorter than the viewport: nothing to scroll, pin to the top.
        #expect(DragAutoScrollMath.nextOffset(current: 0, velocity: 800, elapsed: 1,
                                              minOffset: 0, maxOffset: -120) == 0)
    }
}
