import AppKit
import QuartzCore
import SwiftUI

/// Drives edge-autoscroll for one lane's scroll view across one drag session — the *driver* half of
/// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`.
///
/// Three constraints shape it, and each is a rule from that section:
///
/// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly
///   because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is
///   exactly the gesture that must keep scrolling. A frame clock plus `NSEvent.mouseLocation` needs
///   no events at all.
/// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space
///   while the *content* moves under it, so without this the shadow would freeze at whatever slot the
///   last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it
///   goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses —
///   one shared retarget, so the two can never disagree.
/// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it
///   is cancelled the moment the session ends — and `DragSession`'s watchdog guarantees that flag
///   clears no matter how the drag finished.
///
/// ## The clock is the display's
///
/// Ticks come from a `CADisplayLink` on whichever display the lane is drawn on
/// (`DisplayLinkTickSource`), not from a sleeping task. The retired version was
/// `Task.sleep(16ms)` in a loop, which has two defects a scroll that must look continuous can feel:
/// `Task.sleep` guarantees only a **lower** bound — a busy cooperative pool pushes the wake-up
/// arbitrarily late — and a fixed ~60Hz cadence against a 120Hz panel beats against the compositor
/// rather than landing on it. Every scroll step drags a retarget behind it, so both defects arrive
/// at the shadow as well as at the content.
@MainActor
final class DragAutoScroller {

    /// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
    /// drive, its window converts the physical cursor, and it is the view the display link is asked
    /// for. Weak — the view belongs to the hierarchy.
    fileprivate weak var anchor: NSView?

    /// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
    fileprivate var didScroll: (() -> Void)?

    /// The board's ruler, for the engagement rect's three reaches — they are distances to the lane's
    /// own furniture, which moves with the zoom level (`DragAutoScrollMath.reachAbove`;
    /// 03-board-ui.md ▸ Layout — zoom).
    ///
    /// Set by the lane at the head of each drag rather than observed, because that is exactly when it
    /// can change: zoom is inert while a session is in flight (`ZoomCommands.isEnabled`), so one read
    /// per drag is one read per level.
    ///
    /// Optional, and resolved at use rather than defaulted in `init`, because the initialiser is
    /// `nonisolated` (the tick-source seam is constructed off the main actor in tests) and the system
    /// body size is a main-actor read. Un-set means "nobody has told me", which resolves to the
    /// system's ruler — an un-zoomed board's answer.
    var bodyPointSize: CGFloat?

    /// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists
    /// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle
    /// (`DragAutoScrollDriverTests`).
    private let tickSource: any DragAutoScrollTickSource

    private var clock = DragAutoScrollClock()

    nonisolated init(tickSource: any DragAutoScrollTickSource = DisplayLinkTickSource()) {
        self.tickSource = tickSource
    }

    /// Ticks once per frame of the anchor's display until the owning task is cancelled.
    ///
    /// The stream *is* the lifetime: cancelling this task ends the iteration, which terminates the
    /// stream, which invalidates the link (`DisplayLinkTickSource`). Nothing else has to remember to.
    func run() async {
        clock.reset()
        for await timestamp in tickSource.ticks(anchoredTo: anchor) {
            guard !Task.isCancelled else { break }
            step(at: timestamp)
        }
        clock.reset()
    }

    private func step(at timestamp: CFTimeInterval) {
        let elapsed = clock.elapsed(at: timestamp)
        guard elapsed > 0,
              let anchor,
              let window = anchor.window,
              let scrollView = anchor.enclosingScrollView
        else { return }

        let clip = scrollView.contentView
        let visible = clip.bounds
        guard visible.width > 0, visible.height > 0 else { return }

        // Physical cursor → window → the clip view's (scrolled) coordinates, then relative to the
        // visible area's top-left corner, which is the space `DragAutoScrollMath` is written in.
        let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        let inClip = clip.convert(inWindow, from: nil)
        let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
        let ruler = bodyPointSize ?? BoardMetrics.bodyPointSize
        guard DragAutoScrollMath.engagementRect(viewport: visible.size, bodyPointSize: ruler)
            .contains(pointer) else { return }

        let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
        guard velocity.dx != 0 || velocity.dy != 0 else { return }

        let document = scrollView.documentView?.frame ?? .zero
        var proposed = visible
        proposed.origin.x = DragAutoScrollMath.nextOffset(
            current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed),
            minOffset: document.minX, maxOffset: document.maxX - visible.width)
        proposed.origin.y = DragAutoScrollMath.nextOffset(
            current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed),
            minOffset: document.minY, maxOffset: document.maxY - visible.height)
        // The clip view has the final say (content insets, magnification).
        let target = clip.constrainBoundsRect(proposed).origin
        guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return }

        clip.scroll(to: target)
        scrollView.reflectScrolledClipView(clip)
        didScroll?()
    }
}

// MARK: - The frame clock

/// One drag session's frame-time bookkeeping: display timestamps in, one tick's elapsed seconds out
/// (`DragAutoScrollClockTests`).
///
/// Trivial arithmetic with two rules that are not, both inherited from the sleeping loop this
/// replaced:
///
/// - **The first tick of a session scrolls nothing.** It has no predecessor to measure from, and the
///   alternative — measuring from `run()`'s own start — would integrate the scheduling delay before
///   the first frame into the first step.
/// - **A tick can never integrate more than `maxStep`.** A display link *pauses* — its view occluded,
///   its window ordered out, the display asleep — and resumes with a delta of however long that
///   lasted. Unclamped, the resuming frame would teleport the lane by that gap × up to 800pt/s.
struct DragAutoScrollClock {

    /// The largest span a single tick may integrate over, in seconds — three 60Hz frames' worth, so a
    /// merely late frame is still honoured in full and a resume after a pause is not.
    static let maxStep: CFTimeInterval = 0.05

    private var last: CFTimeInterval?

    /// Seconds since the previous tick: zero for the first of a session, clamped to `maxStep`, and
    /// never negative (a timestamp that goes backwards is not a reason to scroll the other way).
    mutating func elapsed(at timestamp: CFTimeInterval) -> CFTimeInterval {
        let elapsed = min(max(timestamp - (last ?? timestamp), 0), Self.maxStep)
        last = timestamp
        return elapsed
    }

    /// Forgets the base, so the next tick is a session's first again.
    mutating func reset() {
        last = nil
    }
}

// MARK: - The tick source

/// Where `DragAutoScroller` gets its frames — the seam under the driver's loop.
///
/// `Sendable` so the scroller's `nonisolated init` can carry one (it is stored on the main actor and
/// only ever called there, which the `@MainActor` requirement states).
protocol DragAutoScrollTickSource: Sendable {

    /// A fresh stream of frame timestamps for one drag session, on the display `view` is drawn on.
    ///
    /// Terminating the stream — finishing it, cancelling the consuming task, or simply dropping it —
    /// is what stops the source. A `nil` view has nothing to scroll, and answers with a stream that
    /// is already finished.
    @MainActor
    func ticks(anchoredTo view: NSView?) -> AsyncStream<CFTimeInterval>
}

/// The shipping tick source: `CADisplayLink`, obtained through `NSView.displayLink(target:selector:)`.
///
/// That factory (macOS 14+) is Apple's named replacement for the deprecated `CVDisplayLink`, and it
/// is asked for *per view* precisely because the view is what knows which display it is on: the link
/// follows the window between a 60Hz panel and a 120Hz one, and suspends itself while the view is
/// off-screen, with nothing here to write for either case.
struct DisplayLinkTickSource: DragAutoScrollTickSource {

    @MainActor
    func ticks(anchoredTo view: NSView?) -> AsyncStream<CFTimeInterval> {
        guard let view else { return AsyncStream { $0.finish() } }
        return DisplayLinkTicker.ticks(anchoredTo: view)
    }
}

/// The target-action → `AsyncStream` bridge, and the one place the link's lifetime is managed.
///
/// **`CADisplayLink` retains its target**, and this object holds the link: that is a cycle, and
/// `invalidate()` is the only thing that breaks it. So the link is created and invalidated in one
/// place — here — with the stream's own `onTermination` as the trigger, because every way a drag can
/// end (cancelled task, `break` out of the loop, the stream simply dropped) terminates the stream.
@MainActor
private final class DisplayLinkTicker: NSObject {

    private var link: CADisplayLink?
    private let continuation: AsyncStream<CFTimeInterval>.Continuation

    private init(continuation: AsyncStream<CFTimeInterval>.Continuation) {
        self.continuation = continuation
        super.init()
    }

    static func ticks(anchoredTo view: NSView) -> AsyncStream<CFTimeInterval> {
        // One frame of backlog and no more: a consumer that fell behind wants the newest timestamp,
        // not a queue of stale ones to replay. Dropping intermediate frames costs nothing — the
        // clock integrates over whatever gap it is handed.
        let (stream, continuation) = AsyncStream<CFTimeInterval>.makeStream(
            bufferingPolicy: .bufferingNewest(1)
        )
        let ticker = DisplayLinkTicker(continuation: continuation)
        let link = view.displayLink(target: ticker, selector: #selector(fire(_:)))
        // `.common`, not `.default`: a drag runs AppKit's event-tracking run loop mode, which is
        // exactly when this has to tick.
        link.add(to: .main, forMode: .common)
        ticker.link = link
        continuation.onTermination = { _ in
            // Cancellation arrives on whatever thread cancelled; the link is main-actor state.
            Task { @MainActor in ticker.invalidate() }
        }
        return stream
    }

    /// `targetTimestamp`, never `timestamp`: the latter is the frame the callback is *behind*, and
    /// integrating against it produces the interpolation stutter the API's own guidance warns about.
    /// The target is the time the frame this callback is preparing will be shown, which is the time
    /// the content should be at.
    @objc private func fire(_ link: CADisplayLink) {
        continuation.yield(link.targetTimestamp)
    }

    private func invalidate() {
        link?.invalidate()
        link = nil
    }
}

/// Binds a `DragAutoScroller` to the scroll view it should drive.
///
/// **Must be placed inside the scroll view's content** (as its `.background`), so
/// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view
/// and would find nothing. The same view is the display link's anchor, so this is also what puts the
/// driver's clock on the right screen.
struct DragAutoScrollAnchor: NSViewRepresentable {

    let scroller: DragAutoScroller

    /// Refreshed on every view update, so the callback always closes over the current context rather
    /// than the one the session started with.
    let didScroll: () -> Void

    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        bind(view)
        return view
    }

    func updateNSView(_ view: NSView, context: Context) {
        bind(view)
    }

    private func bind(_ view: NSView) {
        scroller.anchor = view
        scroller.didScroll = didScroll
    }
}
