import AppKit

struct ManualRefreshViewportRestoreRequest {
    let generation: Int
    let menuInteractionGeneration: Int
    let switcherSelection: ProviderSwitcherSelection?
}

struct MenuViewportGeometry: Equatable {
    let documentID: ObjectIdentifier
    let clipID: ObjectIdentifier
    let documentSize: CGSize
    let documentIsFlipped: Bool
    let clipSize: CGSize
    let clipOrigin: CGPoint
}

enum MenuViewportGeometryTransition: Equatable {
    case unchanged
    case layout
    case movement
}

private struct MenuViewportOriginRange {
    private(set) var minimumX: CGFloat
    private(set) var maximumX: CGFloat
    private(set) var minimumY: CGFloat
    private(set) var maximumY: CGFloat

    init(baseline: CGPoint, current: CGPoint) {
        self.minimumX = min(baseline.x, current.x)
        self.maximumX = max(baseline.x, current.x)
        self.minimumY = min(baseline.y, current.y)
        self.maximumY = max(baseline.y, current.y)
    }

    mutating func include(_ origin: CGPoint) {
        self.minimumX = min(self.minimumX, origin.x)
        self.maximumX = max(self.maximumX, origin.x)
        self.minimumY = min(self.minimumY, origin.y)
        self.maximumY = max(self.maximumY, origin.y)
    }

    func exceeds(_ tolerance: CGFloat) -> Bool {
        self.maximumX - self.minimumX > tolerance || self.maximumY - self.minimumY > tolerance
    }
}

@MainActor
final class ManualRefreshViewportMovementTracker: NSObject {
    private weak var scrollView: NSScrollView?
    private weak var clipView: NSClipView?
    private weak var documentView: NSView?
    private let originalPostsBoundsChangedNotifications: Bool
    private let originalClipPostsFrameChangedNotifications: Bool
    private let originalDocumentPostsFrameChangedNotifications: Bool
    private var baselineGeometry: MenuViewportGeometry?
    private var pendingOriginRange: MenuViewportOriginRange?
    private var afterSettleOperations: [@MainActor () -> Void] = []
    private var settleScheduled = false
    private var permitsPostLayoutTopCorrection = false
    private var isActive = true
    private(set) var observedMovement = false

    init(scrollView: NSScrollView) {
        let clipView = scrollView.contentView
        let documentView = scrollView.documentView
        self.scrollView = scrollView
        self.clipView = clipView
        self.documentView = documentView
        self.originalPostsBoundsChangedNotifications = clipView.postsBoundsChangedNotifications
        self.originalClipPostsFrameChangedNotifications = clipView.postsFrameChangedNotifications
        self.originalDocumentPostsFrameChangedNotifications = documentView?.postsFrameChangedNotifications ?? false
        self.baselineGeometry = nil
        self.pendingOriginRange = nil
        super.init()
        clipView.postsBoundsChangedNotifications = true
        clipView.postsFrameChangedNotifications = true
        documentView?.postsFrameChangedNotifications = true
        self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(self.viewportGeometryDidChange(_:)),
            name: NSView.boundsDidChangeNotification,
            object: clipView)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(self.viewportGeometryDidChange(_:)),
            name: NSView.frameDidChangeNotification,
            object: clipView)
        if let documentView {
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(self.viewportGeometryDidChange(_:)),
                name: NSView.frameDidChangeNotification,
                object: documentView)
        }
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    func stop() {
        guard self.isActive else { return }
        self.isActive = false
        self.clipView?.postsBoundsChangedNotifications = self.originalPostsBoundsChangedNotifications
        self.clipView?.postsFrameChangedNotifications = self.originalClipPostsFrameChangedNotifications
        self.documentView?.postsFrameChangedNotifications = self.originalDocumentPostsFrameChangedNotifications
        self.settleScheduled = false
        self.permitsPostLayoutTopCorrection = false
        self.pendingOriginRange = nil
        self.afterSettleOperations.removeAll(keepingCapacity: false)
        self.documentView = nil
        self.clipView = nil
        self.scrollView = nil
    }

    func isTracking(_ scrollView: NSScrollView) -> Bool {
        self.scrollView === scrollView &&
            self.clipView === scrollView.contentView &&
            self.documentView === scrollView.documentView
    }

    /// Make settled refresh geometry the baseline for the short completion-to-delivery window.
    /// Callers must enter through `afterPendingGeometrySettles` so stale AppKit geometry is never sampled.
    func rebaseAfterRefreshLayout() {
        guard !self.observedMovement, let scrollView = self.scrollView else { return }
        self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView)
        self.pendingOriginRange = nil
    }

    func afterPendingGeometrySettles(_ operation: @escaping @MainActor () -> Void) {
        guard self.isActive else { return }
        guard self.settleScheduled else {
            operation()
            return
        }
        self.afterSettleOperations.append(operation)
    }

    func settlePendingGeometryChanges() {
        self.settleScheduled = false
        if self.isActive,
           !self.observedMovement,
           let scrollView = self.scrollView,
           let current = StatusItemController.menuViewportGeometry(in: scrollView)
        {
            if let baselineGeometry = self.baselineGeometry {
                let pendingMovement = self.pendingOriginRange?.exceeds(1) == true
                switch StatusItemController.menuViewportGeometryTransition(from: baselineGeometry, to: current) {
                case .unchanged:
                    if pendingMovement {
                        if self.consumePostLayoutTopCorrectionIfNeeded(current) {
                            self.baselineGeometry = current
                        } else {
                            self.observedMovement = true
                        }
                    }
                // Otherwise keep the original baseline so fractional scroll deltas accumulate.
                case .layout:
                    self.baselineGeometry = current
                    // AppKit can publish the new document frame one run-loop pass before its
                    // automatic reset to the menu's top. Only that no-op restore target is safe
                    // to absorb; an arbitrary next origin is newer user movement.
                    self.permitsPostLayoutTopCorrection = !pendingMovement
                case .movement:
                    if self.consumePostLayoutTopCorrectionIfNeeded(current) {
                        self.baselineGeometry = current
                    } else {
                        self.observedMovement = true
                    }
                }
            } else {
                self.baselineGeometry = current
            }
            self.pendingOriginRange = nil
        }
        let operations = self.afterSettleOperations
        self.afterSettleOperations.removeAll(keepingCapacity: false)
        for operation in operations where self.isActive {
            operation()
        }
    }

    private func consumePostLayoutTopCorrectionIfNeeded(_ geometry: MenuViewportGeometry) -> Bool {
        guard self.permitsPostLayoutTopCorrection else { return false }
        self.permitsPostLayoutTopCorrection = false
        return StatusItemController.menuViewportGeometryIsAtTop(geometry)
    }

    @objc private func viewportGeometryDidChange(_: Notification) {
        guard self.isActive, !self.observedMovement else { return }
        if let origin = self.scrollView?.contentView.bounds.origin {
            if self.pendingOriginRange == nil {
                self.pendingOriginRange = MenuViewportOriginRange(
                    baseline: self.baselineGeometry?.clipOrigin ?? origin,
                    current: origin)
            } else {
                self.pendingOriginRange?.include(origin)
            }
        }
        guard !self.settleScheduled else { return }
        self.settleScheduled = true
        ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self] in
            self?.settlePendingGeometryChanges()
        }
    }
}

private struct ManualRefreshViewportMovementTracking {
    let generation: Int
    let tracker: ManualRefreshViewportMovementTracker
}

@MainActor
final class ManualRefreshViewportRestoreState {
    var deferredUntilRebuild: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:]
    private var movementTrackers: [ObjectIdentifier: ManualRefreshViewportMovementTracking] = [:]

    func startMovementTracking(
        for key: ObjectIdentifier,
        generation: Int,
        scrollView: NSScrollView)
    {
        self.stopMovementTracking(for: key)
        self.movementTrackers[key] = ManualRefreshViewportMovementTracking(
            generation: generation,
            tracker: ManualRefreshViewportMovementTracker(scrollView: scrollView))
    }

    func prepareForCompletedRefreshLayout(
        for key: ObjectIdentifier,
        generation: Int,
        scrollView: NSScrollView,
        completion: @escaping @MainActor () -> Void)
    {
        self.prepareMovementTracking(
            for: key,
            generation: generation,
            scrollView: scrollView,
            rebaseAfterLayout: true,
            completion: completion)
    }

    func prepareForDelivery(
        for key: ObjectIdentifier,
        generation: Int,
        scrollView: NSScrollView,
        completion: @escaping @MainActor () -> Void)
    {
        self.prepareMovementTracking(
            for: key,
            generation: generation,
            scrollView: scrollView,
            rebaseAfterLayout: false,
            completion: completion)
    }

    func afterMovementSettles(
        for key: ObjectIdentifier,
        generation: Int,
        operation: @escaping @MainActor () -> Void)
    {
        guard let tracking = self.movementTrackers[key], tracking.generation == generation else {
            operation()
            return
        }
        let tracker = tracking.tracker
        tracker.afterPendingGeometrySettles { [weak self, weak tracker] in
            guard let self,
                  let tracker,
                  let current = self.movementTrackers[key],
                  current.generation == generation,
                  current.tracker === tracker
            else { return }
            operation()
        }
    }

    func observedMovement(for key: ObjectIdentifier, generation: Int) -> Bool {
        guard let tracking = self.movementTrackers[key], tracking.generation == generation else { return false }
        return tracking.tracker.observedMovement
    }

    func stopMovementTracking(for key: ObjectIdentifier, generation: Int? = nil) {
        guard let tracking = self.movementTrackers[key],
              generation == nil || tracking.generation == generation
        else { return }
        tracking.tracker.stop()
        self.movementTrackers.removeValue(forKey: key)
    }

    func stopAllMovementTracking() {
        for tracking in self.movementTrackers.values {
            tracking.tracker.stop()
        }
        self.movementTrackers.removeAll(keepingCapacity: false)
    }

    private func prepareMovementTracking(
        for key: ObjectIdentifier,
        generation: Int,
        scrollView: NSScrollView,
        rebaseAfterLayout: Bool,
        completion: @escaping @MainActor () -> Void)
    {
        guard let tracking = self.movementTrackers[key] else {
            self.startMovementTracking(for: key, generation: generation, scrollView: scrollView)
            completion()
            return
        }
        guard tracking.generation == generation else {
            // A newer overlapping provider refresh owns this menu's tracker. Let the caller's
            // context check discard the stale completion without erasing newer movement.
            completion()
            return
        }
        let tracker = tracking.tracker
        tracker.afterPendingGeometrySettles { [weak self, weak tracker] in
            guard let self,
                  let tracker,
                  let current = self.movementTrackers[key],
                  current.generation == generation,
                  current.tracker === tracker
            else { return }
            if !tracker.observedMovement {
                if tracker.isTracking(scrollView) {
                    if rebaseAfterLayout {
                        tracker.rebaseAfterRefreshLayout()
                    }
                } else {
                    self.startMovementTracking(for: key, generation: generation, scrollView: scrollView)
                }
            }
            completion()
        }
    }

    #if DEBUG
    var testOperation: (@MainActor () async -> Void)?
    var testObserver: (@MainActor (NSMenu) -> Void)?
    var testScheduler: ((@escaping @MainActor () -> Void) -> Void)?
    #endif
}

extension StatusItemController {
    /// A user-initiated manual refresh reconciles the tracked menu in place, and the row
    /// geometry and AppKit scroll state it changes can leave the private menu viewport anchored
    /// mid-list with no way back to the top short of closing and reopening the menu. Arm a token
    /// before refreshing so a close and reopen cannot transfer the restore to a new tracking
    /// session. Background refreshes never enter this path and therefore never move the viewport.
    func armManualRefreshViewportRestoreRequests(
        originatingMenuID: ObjectIdentifier?,
        originatingMenuInteractionGeneration: Int?)
        -> [ObjectIdentifier: ManualRefreshViewportRestoreRequest]
    {
        let candidates: [(ObjectIdentifier, NSMenu)]
        if let originatingMenuID {
            guard let menu = self.openMenus[originatingMenuID] else { return [:] }
            candidates = [(originatingMenuID, menu)]
        } else {
            candidates = Array(self.openMenus)
        }

        var requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:]
        for (key, menu) in candidates where menu.supermenu == nil && !self.isHostedSubviewMenu(menu) {
            guard let menuInteractionGeneration = self.menuSession.menuInteractionGeneration(for: key) else { continue }
            if key == originatingMenuID,
               let originatingMenuInteractionGeneration,
               menuInteractionGeneration != originatingMenuInteractionGeneration
            {
                continue
            }
            self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key)
            let generation = self.menuSession.armViewportRestore(key)
            if let scrollView = Self.attachedMenuScrollView(in: menu) {
                self.manualRefreshViewportRestoreState.startMovementTracking(
                    for: key,
                    generation: generation,
                    scrollView: scrollView)
            }
            requests[key] = ManualRefreshViewportRestoreRequest(
                generation: generation,
                menuInteractionGeneration: menuInteractionGeneration,
                switcherSelection: self.viewportRestoreSwitcherSelection(for: menu))
        }
        return requests
    }

    /// A completed manual refresh updates live card content without rebuilding the tracked
    /// parent menu. Restore on AppKit's tracking run loop after that live layout settles. The
    /// exact token prevents an older completion from consuming a newer refresh or menu session.
    func scheduleCompletedManualRefreshViewportRestore(
        _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest])
    {
        for (key, request) in requests {
            guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key),
                  let menu = self.openMenus[key]
            else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                continue
            }
            let completion: @MainActor () -> Void = { [weak self, weak menu] in
                guard let self else { return }
                guard let menu else {
                    self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                    return
                }
                self.continueSchedulingCompletedManualRefreshViewportRestore(
                    request,
                    for: key,
                    menu: menu)
            }
            if let scrollView = Self.attachedMenuScrollView(in: menu) {
                self.manualRefreshViewportRestoreState.prepareForCompletedRefreshLayout(
                    for: key,
                    generation: request.generation,
                    scrollView: scrollView,
                    completion: completion)
            } else {
                completion()
            }
        }
    }

    private func continueSchedulingCompletedManualRefreshViewportRestore(
        _ request: ManualRefreshViewportRestoreRequest,
        for key: ObjectIdentifier,
        menu: NSMenu)
    {
        guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key),
              !self.hasPreparedForAppShutdown,
              self.openMenus[key] === menu,
              ObjectIdentifier(menu) == key,
              menu.supermenu == nil,
              !self.isHostedSubviewMenu(menu),
              request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu),
              self.menuNeedsRefresh(menu),
              !self.manualRefreshViewportRestoreState.observedMovement(
                  for: key,
                  generation: request.generation),
              !self.hasOpenNonHostedChildMenu()
        else {
            self.cancelManualRefreshViewportRestoreRequest(request, for: key)
            return
        }
        if self.hasOpenHostedSubviewMenu() ||
            self.parentMenuRebuildPendingAfterHostedSubviewClose ||
            self.openMenuRebuildRequests.tokens[key] != nil
        {
            self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request
            return
        }
        guard !self.hasMenuItemHighlightedForViewportRestore(in: menu) else {
            self.cancelManualRefreshViewportRestoreRequest(request, for: key)
            return
        }
        self.scheduleManualRefreshViewportRestore(request, for: menu)
    }

    func scheduleDeferredManualRefreshViewportRestoreAfterRebuild(for menu: NSMenu) {
        let key = ObjectIdentifier(menu)
        guard let request = self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key)
        else { return }
        let completion: @MainActor () -> Void = { [weak self, weak menu] in
            guard let self else { return }
            guard let menu else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                return
            }
            self.continueSchedulingDeferredManualRefreshViewportRestore(
                request,
                for: key,
                menu: menu)
        }
        if let scrollView = Self.attachedMenuScrollView(in: menu) {
            self.manualRefreshViewportRestoreState.prepareForDelivery(
                for: key,
                generation: request.generation,
                scrollView: scrollView,
                completion: completion)
        } else {
            completion()
        }
    }

    private func continueSchedulingDeferredManualRefreshViewportRestore(
        _ request: ManualRefreshViewportRestoreRequest,
        for key: ObjectIdentifier,
        menu: NSMenu)
    {
        guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key),
              !self.hasPreparedForAppShutdown,
              self.openMenus[key] === menu,
              !self.hasOpenNonHostedChildMenu(),
              !self.hasOpenHostedSubviewMenu(),
              !self.hasMenuItemHighlightedForViewportRestore(in: menu),
              !self.manualRefreshViewportRestoreState.observedMovement(
                  for: key,
                  generation: request.generation),
              request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu)
        else {
            self.cancelManualRefreshViewportRestoreRequest(request, for: key)
            return
        }
        self.scheduleManualRefreshViewportRestore(request, for: menu)
    }

    private func scheduleManualRefreshViewportRestore(
        _ request: ManualRefreshViewportRestoreRequest,
        for menu: NSMenu)
    {
        let key = ObjectIdentifier(menu)
        let delivery: @MainActor () -> Void = { [weak self, weak menu] in
            guard let self else { return }
            guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key) else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                return
            }
            guard !self.hasPreparedForAppShutdown,
                  let menu,
                  self.openMenus[key] === menu,
                  request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu)
            else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                return
            }
            guard !self.hasOpenNonHostedChildMenu() else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                return
            }
            let menuIsDirty = self.menuNeedsRefresh(menu)
            let parentRebuildPending = self.openMenuRebuildRequests.tokens[key] != nil ||
                (self.parentMenuRebuildPendingAfterHostedSubviewClose && menuIsDirty)
            if self.hasOpenHostedSubviewMenu() {
                guard menuIsDirty || parentRebuildPending else {
                    self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                    return
                }
                self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request
                return
            }
            if parentRebuildPending {
                self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request
                return
            }
            guard !self.hasMenuItemHighlightedForViewportRestore(in: menu),
                  !self.manualRefreshViewportRestoreState.observedMovement(
                      for: key,
                      generation: request.generation)
            else {
                self.cancelManualRefreshViewportRestoreRequest(request, for: key)
                return
            }
            guard self.menuSession.consumeViewportRestore(key, generation: request.generation) else { return }
            self.manualRefreshViewportRestoreState.stopMovementTracking(
                for: key,
                generation: request.generation)
            self.restoreMenuViewportToTop(menu)
        }
        let operation: @MainActor () -> Void = { [weak self] in
            self?.manualRefreshViewportRestoreState.afterMovementSettles(
                for: key,
                generation: request.generation,
                operation: delivery)
        }
        #if DEBUG
        if let scheduler = self._test_menuViewportRestoreScheduler {
            scheduler(operation)
        } else {
            ProviderSwitcherTrackingRunLoopScheduler.schedule(operation)
        }
        #else
        ProviderSwitcherTrackingRunLoopScheduler.schedule(operation)
        #endif
    }

    func cancelManualRefreshViewportRestore(for key: ObjectIdentifier) {
        self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key)
        self.manualRefreshViewportRestoreState.stopMovementTracking(for: key)
        self.menuSession.cancelViewportRestore(key)
    }

    private func cancelManualRefreshViewportRestoreRequest(
        _ request: ManualRefreshViewportRestoreRequest,
        for key: ObjectIdentifier)
    {
        if self.manualRefreshViewportRestoreState.deferredUntilRebuild[key]?.generation == request.generation {
            self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key)
        }
        self.manualRefreshViewportRestoreState.stopMovementTracking(
            for: key,
            generation: request.generation)
        self.menuSession.consumeViewportRestore(key, generation: request.generation)
    }

    func cancelManualRefreshViewportRestoreRequests(
        _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest])
    {
        for (key, request) in requests {
            self.cancelManualRefreshViewportRestoreRequest(request, for: key)
        }
    }

    private func viewportRestoreSwitcherSelection(for menu: NSMenu) -> ProviderSwitcherSelection? {
        guard self.shouldMergeIcons, menu === self.mergedMenu else { return nil }
        if self.isMergedOverviewSelected(in: menu) {
            return .overview
        }
        return .provider((self.resolvedMenuProvider() ?? .codex).instanceID)
    }

    private func isCurrentManualRefreshViewportRestoreContext(
        _ request: ManualRefreshViewportRestoreRequest,
        for key: ObjectIdentifier)
        -> Bool
    {
        self.menuSession.isCurrentViewportRestore(request.generation, for: key) &&
            self.menuSession.isCurrentMenuInteraction(request.menuInteractionGeneration, for: key)
    }

    private func hasMenuItemHighlightedForViewportRestore(in menu: NSMenu) -> Bool {
        let key = ObjectIdentifier(menu)
        guard let item = self.highlightedMenuItems[key], item.menu === menu else { return false }
        return item.isEnabled
    }

    func advanceMenuInteraction(for menu: NSMenu?) {
        guard let menu else { return }
        let key = ObjectIdentifier(menu)
        guard self.openMenus[key] === menu,
              let generation = self.menuSession.advanceMenuInteraction(for: key)
        else { return }
        (menu as? StatusItemMenu)?.menuInteractionGeneration = generation
    }

    func restoreMenuViewportToTop(_ menu: NSMenu) {
        #if DEBUG
        if let observer = self._test_menuViewportRestoreObserver {
            observer(menu)
            return
        }
        #endif
        guard let scrollView = Self.attachedMenuScrollView(in: menu),
              let documentView = scrollView.documentView
        else { return }
        let clipView = scrollView.contentView
        guard let target = Self.menuViewportTopOffset(
            documentIsFlipped: documentView.isFlipped,
            documentHeight: documentView.frame.height,
            clipHeight: clipView.bounds.height,
            currentOffset: clipView.documentVisibleRect.origin.y)
        else { return }
        self.performMenuMutationWithoutAnimation {
            clipView.scroll(to: NSPoint(x: clipView.documentVisibleRect.origin.x, y: target))
            scrollView.reflectScrolledClipView(clipView)
        }
    }

    /// The view-based menu (`NSMenuScrollView` → `NSClipView` → table representation)
    /// recycles row views once they scroll offscreen, so the shared scroll view must be
    /// resolved through whichever item view is currently attached to the menu window.
    static func attachedMenuScrollView(in menu: NSMenu) -> NSScrollView? {
        for item in menu.items {
            if let scrollView = item.view?.enclosingScrollView {
                return scrollView
            }
        }
        return nil
    }

    static func menuViewportGeometry(in scrollView: NSScrollView) -> MenuViewportGeometry? {
        guard let documentView = scrollView.documentView else { return nil }
        let clipView = scrollView.contentView
        return MenuViewportGeometry(
            documentID: ObjectIdentifier(documentView),
            clipID: ObjectIdentifier(clipView),
            documentSize: documentView.frame.size,
            documentIsFlipped: documentView.isFlipped,
            clipSize: clipView.bounds.size,
            clipOrigin: clipView.bounds.origin)
    }

    /// Bounds notifications can arrive before AppKit exposes updated row geometry. Compare only
    /// coalesced, settled samples: any geometry change is layout; stable geometry exposes scrolling.
    static func menuViewportGeometryTransition(
        from previous: MenuViewportGeometry,
        to current: MenuViewportGeometry,
        movementTolerance: CGFloat = 1)
        -> MenuViewportGeometryTransition
    {
        // AppKit can publish an origin reset before exposing a row-size change. A mixed batch is
        // therefore irreducibly ambiguous: treat it as layout, then catch repeating edge-scroll
        // ticks against the new stable geometry on the next batch.
        guard previous.documentID == current.documentID,
              previous.clipID == current.clipID,
              previous.documentSize == current.documentSize,
              previous.documentIsFlipped == current.documentIsFlipped,
              previous.clipSize == current.clipSize
        else { return .layout }
        let moved = abs(current.clipOrigin.x - previous.clipOrigin.x) > movementTolerance ||
            abs(current.clipOrigin.y - previous.clipOrigin.y) > movementTolerance
        return moved ? .movement : .unchanged
    }

    static func menuViewportGeometryIsAtTop(
        _ geometry: MenuViewportGeometry,
        tolerance: CGFloat = 1)
        -> Bool
    {
        let maximumOffset = max(0, geometry.documentSize.height - geometry.clipSize.height)
        let topOffset = geometry.documentIsFlipped ? 0 : maximumOffset
        return abs(geometry.clipOrigin.y - topOffset) <= tolerance
    }

    /// Returns the offset that shows the top of the menu content, or nil when the menu is
    /// not scrollable or the viewport is already there.
    static func menuViewportTopOffset(
        documentIsFlipped: Bool,
        documentHeight: CGFloat,
        clipHeight: CGFloat,
        currentOffset: CGFloat) -> CGFloat?
    {
        guard clipHeight > 0, documentHeight - clipHeight > 0.5 else { return nil }
        let top: CGFloat = documentIsFlipped ? 0 : documentHeight - clipHeight
        guard abs(currentOffset - top) > 0.5 else { return nil }
        return top
    }
}
