import AppKit
import CoreGraphics
import Foundation
import Testing
@testable import Kanban

/// **The edge-autoscroll driver's clock** — the half of `DragAutoScroller` that is not AppKit
/// (DRAG-REORDER.md § Edge autoscroll; the geometry it feeds is `DragAutoScrollMathTests`).
///
/// The split is where the testable line falls. `step(at:)` reads the physical mouse, the anchor's
/// window and a live `NSClipView`, none of which a unit test can pose; what it *decides* is
/// `elapsed × velocity`, and both factors are pure. So `DragAutoScrollClock` is exercised directly —
/// composed with `DragAutoScrollMath` exactly as the step composes them, which is what makes the
/// frame-rate-independence claim a measurement rather than an assertion — and the loop around it is
/// exercised through a scripted tick source, because a `CADisplayLink` needs a screen and a run loop
/// and there is neither in a test bundle.

// MARK: - Composing the clock with the geometry, as the step does

/// A tick sequence of `count` frames at `hz`, starting at a plausible `CACurrentMediaTime()` rather
/// than at zero — the driver never sees a timeline that begins at the origin.
private func frames(hz: Int, count: Int, from start: CFTimeInterval = 41_297.5) -> [CFTimeInterval] {
    (0...count).map { start + CFTimeInterval($0) / CFTimeInterval(hz) }
}

/// Total scroll travel over `ticks`, integrated the way `DragAutoScroller.step(at:)` integrates: the
/// clock's elapsed for this frame, the geometry's velocity for this pointer, `nextOffset` for the
/// new origin.
private func travel(over ticks: [CFTimeInterval],
                    pointer: CGFloat,
                    length: CGFloat = 400) -> CGFloat {
    let velocity = DragAutoScrollMath.velocity(position: pointer, length: length)
    var clock = DragAutoScrollClock()
    var offset: CGFloat = 0
    for timestamp in ticks {
        offset = DragAutoScrollMath.nextOffset(
            current: offset,
            velocity: velocity,
            elapsed: CGFloat(clock.elapsed(at: timestamp)),
            // Far beyond anything a second of scrolling can reach, in either direction, so the range
            // clamp — which `DragAutoScrollMathTests` owns — never confounds these numbers.
            minOffset: -100_000,
            maxOffset: 100_000
        )
    }
    return offset
}

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

@Suite("DragAutoScrollClock")
struct DragAutoScrollClockTests {

    @Test("A session's first tick establishes the base and scrolls nothing")
    func firstTickIsTheBase() {
        var clock = DragAutoScrollClock()
        #expect(clock.elapsed(at: 41_297.5) == 0)
        // And the second measures from it rather than from anything the driver did before.
        #expect(isClose(CGFloat(clock.elapsed(at: 41_297.5 + 1.0 / 120)), CGFloat(1.0 / 120)))
    }

    @Test("Resetting makes the next tick a first tick again")
    func resetRestoresTheBase() {
        var clock = DragAutoScrollClock()
        _ = clock.elapsed(at: 100)
        _ = clock.elapsed(at: 100.5)
        clock.reset()
        #expect(clock.elapsed(at: 900) == 0, "a new drag must not integrate the gap since the last one")
    }

    @Test("A second at 120Hz and a second at 60Hz travel the same distance")
    func frameRateIndependence() {
        // Deep in the trailing band, so the velocity is the 800pt/s ceiling and the expected figure
        // is arithmetic anyone can check: one second of it.
        let atTheEdge: CGFloat = 400
        let fast = travel(over: frames(hz: 120, count: 120), pointer: atTheEdge)
        let slow = travel(over: frames(hz: 60, count: 60), pointer: atTheEdge)

        #expect(isClose(fast, DragAutoScrollMath.maxSpeed))
        #expect(isClose(slow, DragAutoScrollMath.maxSpeed))
        #expect(isClose(fast, slow, 0.000_001), "\(fast) vs \(slow)")
    }

    @Test("A ramp speed is frame-rate independent too, not just the ceiling")
    func frameRateIndependenceOnTheRamp() {
        // Halfway into the leading band: the mean of floor and ceiling, scrolling the other way.
        let midBand = DragAutoScrollMath.band / 2
        let expected = -(DragAutoScrollMath.minSpeed + DragAutoScrollMath.maxSpeed) / 2
        // Same second, three different cadences — including one no display runs at, because the
        // point is that the integration does not know or care.
        let rates = [120, 60, 47]
        let travelled = rates.map { travel(over: frames(hz: $0, count: $0), pointer: midBand) }
        for (rate, distance) in zip(rates, travelled) {
            #expect(isClose(distance, expected, 0.001), "\(rate)Hz travelled \(distance)")
        }
    }

    @Test("An irregular cadence integrates its real span, not its frame count")
    func jitterIntegratesTheSpan() {
        // 120Hz with two frames dropped and one late — the same second of wall clock either way.
        let start: CFTimeInterval = 41_297.5
        var ticks: [CFTimeInterval] = [start]
        var t = start
        for step in [0.008, 0.0083, 0.025, 0.0083, 0.0083, 0.0418, 0.9] {
            t += step
            ticks.append(t)
        }
        // The 0.9 gap is clamped, so the honest expectation is the sum of the clamped steps.
        let expected = DragAutoScrollMath.maxSpeed
            * CGFloat(0.008 + 0.0083 + 0.025 + 0.0083 + 0.0083 + 0.0418 + DragAutoScrollClock.maxStep)
        #expect(isClose(travel(over: ticks, pointer: 400), expected, 0.001))
    }

    @Test("A resumed link steps once, not by however long it was paused")
    func pauseResumeHitsTheClamp() {
        var clock = DragAutoScrollClock()
        _ = clock.elapsed(at: 41_297.5)
        _ = clock.elapsed(at: 41_297.5 + 1.0 / 120)
        // The view was occluded for two seconds; the first frame back must not teleport the lane.
        #expect(clock.elapsed(at: 41_299.5) == DragAutoScrollClock.maxStep)
        // 40 points rather than 1600.
        #expect(isClose(travel(over: [0, 2.0], pointer: 400),
                        DragAutoScrollMath.maxSpeed * CGFloat(DragAutoScrollClock.maxStep)))
    }

    @Test("A merely late frame is honoured in full")
    func aLateFrameIsNotClamped() {
        var clock = DragAutoScrollClock()
        _ = clock.elapsed(at: 100)
        // Two 60Hz frames' worth: late, well inside the clamp, and worth exactly what it says.
        #expect(isClose(CGFloat(clock.elapsed(at: 100 + 1.0 / 30)), CGFloat(1.0 / 30)))
    }

    @Test("A timestamp that goes backwards scrolls nothing rather than backwards")
    func backwardsTimeIsInert() {
        var clock = DragAutoScrollClock()
        _ = clock.elapsed(at: 500)
        #expect(clock.elapsed(at: 499) == 0)
        // And the base moved with it, so the sequence recovers rather than stalling.
        #expect(isClose(CGFloat(clock.elapsed(at: 499 + 1.0 / 120)), CGFloat(1.0 / 120)))
    }
}

// MARK: - The loop and the link's lifetime

/// A stand-in for `DisplayLinkTickSource` whose frames the test writes, and which records the one
/// thing the real source does on termination: it invalidates the link.
@MainActor
private final class ScriptedTicks: DragAutoScrollTickSource {

    private var continuation: AsyncStream<CFTimeInterval>.Continuation?
    private var startWaiters: [CheckedContinuation<Void, Never>] = []
    private var stopWaiters: [CheckedContinuation<Void, Never>] = []

    private(set) var started = false

    /// Set from the stream's `onTermination`, through the same main-actor hop the real ticker uses to
    /// reach `CADisplayLink.invalidate()`.
    private(set) var invalidated = false

    func ticks(anchoredTo view: NSView?) -> AsyncStream<CFTimeInterval> {
        let (stream, continuation) = AsyncStream<CFTimeInterval>.makeStream(bufferingPolicy: .unbounded)
        self.continuation = continuation
        continuation.onTermination = { _ in
            Task { @MainActor in self.markInvalidated() }
        }
        started = true
        for waiter in startWaiters { waiter.resume() }
        startWaiters = []
        return stream
    }

    func yield(_ timestamp: CFTimeInterval) {
        continuation?.yield(timestamp)
    }

    func finish() {
        continuation?.finish()
    }

    /// Resumes once the driver has asked for its stream — the loop is running and suspended on it.
    func waitUntilStarted() async {
        guard !started else { return }
        await withCheckedContinuation { startWaiters.append($0) }
    }

    func waitUntilInvalidated() async {
        guard !invalidated else { return }
        await withCheckedContinuation { stopWaiters.append($0) }
    }

    private func markInvalidated() {
        invalidated = true
        for waiter in stopWaiters { waiter.resume() }
        stopWaiters = []
    }
}

@Suite("DragAutoScrollDriver")
@MainActor
struct DragAutoScrollDriverTests {

    @Test("The loop is driven by the frames, and ends with them")
    func theLoopFollowsTheStream() async {
        let source = ScriptedTicks()
        let scroller = DragAutoScroller(tickSource: source)

        let running = Task { await scroller.run() }
        await source.waitUntilStarted()
        for frame in frames(hz: 120, count: 4) { source.yield(frame) }
        source.finish()
        // Returning at all is the assertion: nothing in the driver sleeps or polls, so a run that
        // ends when its frames do is a run the frames were driving. A hang here is the failure.
        await running.value
        await source.waitUntilInvalidated()
        #expect(source.invalidated, "finishing terminates the stream, which retires the link")
    }

    @Test("Cancelling the drag terminates the stream, which is what invalidates the link")
    func cancellationRetiresTheLink() async {
        let source = ScriptedTicks()
        let scroller = DragAutoScroller(tickSource: source)

        // A source that never finishes: only cancellation can end this, which is the drag session's
        // contract — the `.task(id:)` is cancelled the moment the session clears.
        let running = Task { await scroller.run() }
        await source.waitUntilStarted()
        source.yield(41_297.5)
        source.yield(41_297.5 + 1.0 / 120)

        running.cancel()
        await running.value
        await source.waitUntilInvalidated()

        #expect(source.invalidated, "the link must not outlive the drag")
    }

    @Test("A driver with no anchor returns instead of spinning")
    func noAnchorNoFrames() async {
        // The shipping source with nothing to scroll: a lane whose representable never bound a view
        // has no display to link to, and must not leave a `.task` running for the drag's duration.
        let scroller = DragAutoScroller()
        await scroller.run()
    }
}
