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 ticking task 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.
@MainActor
final class DragAutoScroller {

    /// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
    /// drive, and its window converts the physical cursor. 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)?

    private var lastTick: CFTimeInterval?

    nonisolated init() {}

    /// Ticks at display rate until the owning task is cancelled.
    func run() async {
        lastTick = nil
        while !Task.isCancelled {
            try? await Task.sleep(for: .milliseconds(16))
            guard !Task.isCancelled else { break }
            step()
        }
        lastTick = nil
    }

    private func step() {
        let now = CACurrentMediaTime()
        let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
        lastTick = now
        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)
        guard DragAutoScrollMath.engagementRect(viewport: visible.size).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?()
    }
}

/// 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.
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
    }
}
