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 60 times a
/// second (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

    @Test("Engagement reaches over the header but barely sideways")
    func engagementReach() {
        let viewport = CGSize(width: 240, height: 400)
        let reach = DragAutoScrollMath.engagementRect(viewport: viewport)

        #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: -DragAutoScrollMath.reachAbove + 1)))
        #expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1)))
        #expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1)))
        #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1)))

        // Sideways: only a sliver, so the neighbouring lane never engages.
        #expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200)))
        #expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200)))
        #expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 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(DragAutoScrollMath.reachSide < 28 / 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)
    }
}
