import { createLazyMeasurementsView } from './lazy-measurements'
import { approxEqual, debounce, memo, notUndefined } from './utils'

// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes
// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer
// scroll-position adjustments triggered by mid-scroll resizes until the
// scroll settles. SSR-safe (returns false when navigator is unavailable).
let _isIOSResult: boolean | undefined
const isIOSWebKit = (): boolean => {
  if (_isIOSResult !== undefined) return _isIOSResult
  if (typeof navigator === 'undefined') return (_isIOSResult = false)
  if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true)
  // iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop.
  const mtp = (navigator as Navigator & { maxTouchPoints?: number })
    .maxTouchPoints
  return (_isIOSResult =
    navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0)
}

// Test hook: reset the iOS detection cache. Not exported.
export const _resetIOSDetectionForTests = () => {
  _isIOSResult = undefined
}

export { approxEqual, debounce, memo, notUndefined } from './utils'
export type { NoInfer, PartialKeys } from './utils'

//

type ScrollDirection = 'forward' | 'backward'

type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'

type ScrollBehavior = 'auto' | 'smooth' | 'instant'

type ScrollAnchor = 'start' | 'end'

type FollowOnAppend = boolean | ScrollBehavior

export interface ScrollToOptions {
  align?: ScrollAlignment
  behavior?: ScrollBehavior
}

type ScrollToOffsetOptions = ScrollToOptions

type ScrollToIndexOptions = ScrollToOptions

type ScrollToEndOptions = Pick<ScrollToOptions, 'behavior'>

export interface Range {
  startIndex: number
  endIndex: number
  overscan: number
  count: number
}

type Key = number | string | bigint

export interface VirtualItem {
  key: Key
  index: number
  start: number
  end: number
  size: number
  lane: number
}

export interface Rect {
  width: number
  height: number
}

//

const getRect = (element: HTMLElement): Rect => {
  const { offsetWidth, offsetHeight } = element
  return { width: offsetWidth, height: offsetHeight }
}

export const defaultKeyExtractor = (index: number) => index

export const defaultRangeExtractor = (range: Range) => {
  const start = Math.max(range.startIndex - range.overscan, 0)
  const end = Math.min(range.endIndex + range.overscan, range.count - 1)
  const len = end - start + 1

  const arr = new Array<number>(len)
  for (let i = 0; i < len; i++) {
    arr[i] = start + i
  }
  return arr
}

export const observeElementRect = <T extends Element>(
  instance: Virtualizer<T, any>,
  cb: (rect: Rect) => void,
) => {
  const element = instance.scrollElement
  if (!element) {
    return
  }
  const targetWindow = instance.targetWindow
  if (!targetWindow) {
    return
  }

  const handler = (rect: Rect) => {
    const { width, height } = rect
    cb({ width: Math.round(width), height: Math.round(height) })
  }

  handler(getRect(element as unknown as HTMLElement))

  if (!targetWindow.ResizeObserver) {
    return () => {}
  }

  const observer = new targetWindow.ResizeObserver((entries) => {
    const run = () => {
      const entry = entries[0]
      if (entry?.borderBoxSize) {
        const box = entry.borderBoxSize[0]
        if (box) {
          handler({ width: box.inlineSize, height: box.blockSize })
          return
        }
      }
      handler(getRect(element as unknown as HTMLElement))
    }

    instance.options.useAnimationFrameWithResizeObserver
      ? requestAnimationFrame(run)
      : run()
  })

  observer.observe(element, { box: 'border-box' })

  return () => {
    observer.unobserve(element)
  }
}

const addEventListenerOptions = {
  passive: true,
}

export const observeWindowRect = (
  instance: Virtualizer<Window, any>,
  cb: (rect: Rect) => void,
) => {
  const element = instance.scrollElement
  if (!element) {
    return
  }

  const handler = () => {
    cb({ width: element.innerWidth, height: element.innerHeight })
  }
  handler()

  element.addEventListener('resize', handler, addEventListenerOptions)

  return () => {
    element.removeEventListener('resize', handler)
  }
}

const supportsScrollend =
  typeof window == 'undefined' ? true : 'onscrollend' in window

type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void

// Shared core: both element and window variants attach scroll/scrollend
// listeners with the same lifecycle; they only differ in how to read the
// current offset from the scroll target.
const observeOffset = <T extends Element | Window>(
  instance: Virtualizer<T, any>,
  cb: ObserveOffsetCallBack,
  readOffset: (target: T) => number,
) => {
  const element = instance.scrollElement
  if (!element) {
    return
  }
  const targetWindow = instance.targetWindow
  if (!targetWindow) {
    return
  }

  const registerScrollendEvent =
    instance.options.useScrollendEvent && supportsScrollend

  let offset = 0
  const fallback = registerScrollendEvent
    ? null
    : debounce(
        targetWindow,
        () => cb(offset, false),
        instance.options.isScrollingResetDelay,
      )

  const createHandler = (isScrolling: boolean) => () => {
    offset = readOffset(element)
    fallback?.()
    cb(offset, isScrolling)
  }
  const handler = createHandler(true)
  const endHandler = createHandler(false)

  element.addEventListener('scroll', handler, addEventListenerOptions)
  if (registerScrollendEvent) {
    element.addEventListener('scrollend', endHandler, addEventListenerOptions)
  }
  return () => {
    element.removeEventListener('scroll', handler)
    if (registerScrollendEvent) {
      element.removeEventListener('scrollend', endHandler)
    }
  }
}

export const observeElementOffset = <T extends Element>(
  instance: Virtualizer<T, any>,
  cb: ObserveOffsetCallBack,
) =>
  observeOffset(instance, cb, (el) => {
    const { horizontal, isRtl } = instance.options
    return horizontal ? el.scrollLeft * ((isRtl && -1) || 1) : el.scrollTop
  })

export const observeWindowOffset = (
  instance: Virtualizer<Window, any>,
  cb: ObserveOffsetCallBack,
) =>
  observeOffset(instance, cb, (win) =>
    instance.options.horizontal ? win.scrollX : win.scrollY,
  )

export const measureElement = <TItemElement extends Element>(
  element: TItemElement,
  entry: ResizeObserverEntry | undefined,
  instance: Virtualizer<any, TItemElement>,
) => {
  // When useCachedMeasurements is enabled, return the cached size
  // (or estimateSize as fallback) instead of measuring the DOM.
  if (instance.options.useCachedMeasurements) {
    const index = instance.indexFromElement(element)
    const key = instance.options.getItemKey(index)
    return (
      instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index)
    )
  }

  if (entry?.borderBoxSize) {
    const box = entry.borderBoxSize[0]
    if (box) {
      const size = Math.round(
        box[instance.options.horizontal ? 'inlineSize' : 'blockSize'],
      )
      return size
    }
  }

  // When called without a ResizeObserverEntry (sync measurement path),
  // return the previously measured size if available. This avoids a
  // synchronous layout read (offsetWidth/offsetHeight) on re-renders.
  // The ResizeObserver is already observing the element and will deliver
  // the accurate size asynchronously if it changed.
  // Users who need synchronous DOM reads can provide a custom measureElement.
  if (!entry) {
    const index = instance.indexFromElement(element)
    const key = instance.options.getItemKey(index)
    const cachedSize = instance.itemSizeCache.get(key)
    if (cachedSize !== undefined) {
      return cachedSize
    }
  }

  return (element as unknown as HTMLElement)[
    instance.options.horizontal ? 'offsetWidth' : 'offsetHeight'
  ]
}

const scrollWithAdjustments = (
  offset: number,
  {
    adjustments = 0,
    behavior,
  }: { adjustments?: number; behavior?: ScrollBehavior },
  instance: Virtualizer<any, any>,
) => {
  instance.scrollElement?.scrollTo?.({
    [instance.options.horizontal ? 'left' : 'top']: offset + adjustments,
    behavior,
  })
}

export const windowScroll: <T extends Window>(
  offset: number,
  options: { adjustments?: number; behavior?: ScrollBehavior },
  instance: Virtualizer<T, any>,
) => void = scrollWithAdjustments

export const elementScroll: <T extends Element>(
  offset: number,
  options: { adjustments?: number; behavior?: ScrollBehavior },
  instance: Virtualizer<T, any>,
) => void = scrollWithAdjustments

type LaneAssignmentMode = 'estimate' | 'measured'

export interface VirtualizerOptions<
  TScrollElement extends Element | Window,
  TItemElement extends Element,
> {
  // Required from the user
  count: number
  getScrollElement: () => TScrollElement | null
  estimateSize: (index: number) => number

  // Required from the framework adapter (but can be overridden)
  scrollToFn: (
    offset: number,
    options: { adjustments?: number; behavior?: ScrollBehavior },
    instance: Virtualizer<TScrollElement, TItemElement>,
  ) => void
  observeElementRect: (
    instance: Virtualizer<TScrollElement, TItemElement>,
    cb: (rect: Rect) => void,
  ) => void | (() => void)
  observeElementOffset: (
    instance: Virtualizer<TScrollElement, TItemElement>,
    cb: ObserveOffsetCallBack,
  ) => void | (() => void)
  // Optional
  debug?: boolean
  initialRect?: Rect
  onChange?: (
    instance: Virtualizer<TScrollElement, TItemElement>,
    sync: boolean,
  ) => void
  measureElement?: (
    element: TItemElement,
    entry: ResizeObserverEntry | undefined,
    instance: Virtualizer<TScrollElement, TItemElement>,
  ) => number
  overscan?: number
  horizontal?: boolean
  paddingStart?: number
  paddingEnd?: number
  scrollPaddingStart?: number
  scrollPaddingEnd?: number
  initialOffset?: number | (() => number)
  getItemKey?: (index: number) => Key
  rangeExtractor?: (range: Range) => Array<number>
  scrollMargin?: number
  gap?: number
  indexAttribute?: string
  initialMeasurementsCache?: Array<VirtualItem>
  lanes?: number
  anchorTo?: ScrollAnchor
  followOnAppend?: FollowOnAppend
  scrollEndThreshold?: number
  isScrollingResetDelay?: number
  useScrollendEvent?: boolean
  enabled?: boolean
  isRtl?: boolean
  useAnimationFrameWithResizeObserver?: boolean
  laneAssignmentMode?: LaneAssignmentMode
  useCachedMeasurements?: boolean
}

type ScrollState = {
  // what we want
  index: number | null
  align: ScrollAlignment
  behavior: ScrollBehavior

  // lifecycle
  startedAt: number

  // target tracking
  lastTargetOffset: number

  // settling
  stableFrames: number
}

type PendingScrollAnchor = [
  key: Key | null,
  offset: number,
  followOnAppend: ScrollBehavior | null,
  anchorDelta: number,
]

export class Virtualizer<
  TScrollElement extends Element | Window,
  TItemElement extends Element,
> {
  private unsubs: Array<void | (() => void)> = []
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
  scrollElement: TScrollElement | null = null
  targetWindow: (Window & typeof globalThis) | null = null
  isScrolling = false
  private scrollState: ScrollState | null = null
  measurementsCache: Array<VirtualItem> = []
  // Flat backing store for the lanes===1 fast path: [start_0, size_0, start_1, size_1, ...].
  // null until the first single-lane build; reused (and grown) across rebuilds.
  private _flatMeasurements: Float64Array | null = null
  itemSizeCache = new Map<Key, number>()
  private itemSizeCacheVersion = 0
  private laneAssignments = new Map<number, number>() // index → lane cache
  // Earliest index dirtied since last getMeasurements() rebuild, or null.
  private pendingMin: number | null = null
  private prevLanes: number | undefined = undefined
  private lanesChangedFlag = false
  private lanesSettling = false
  private pendingScrollAnchor: PendingScrollAnchor | null = null
  scrollRect: Rect | null = null
  scrollOffset: number | null = null
  scrollDirection: ScrollDirection | null = null
  private scrollAdjustments = 0
  // Sum of size-change deltas above-viewport that were skipped during
  // iOS momentum scroll (writing scrollTop mid-momentum cancels it).
  // Flushed in a single scrollTo when iOS is fully settled.
  private _iosDeferredAdjustment = 0
  // Touch state. iOS WebKit cancels momentum when scrollTop is written, so
  // we defer adjustments not only during `isScrolling` but also through the
  // touchstart→touchend window (active drag) and a short tail after
  // touchend (early-momentum window — iOS only fires touch events once at
  // the start of momentum, so we use a timer rather than another event).
  private _iosTouching = false
  private _iosJustTouchEnded = false
  private _iosTouchEndTimerId: number | null = null
  // Subpixel reconciliation. Safari (and Chrome/Firefox under certain DPRs)
  // round scrollTop/scrollLeft writes to integer pixels. If we wrote 12345.5
  // but the browser reports back 12346, the next reconcileScroll sees a
  // "target changed" and re-fires scrollTo — a feedback loop that the
  // approxEqual(<1.01) tolerance otherwise absorbs as a workaround.
  // By remembering the intended value of our most-recent self-driven
  // scrollTo, we can match the browser's rounded read back to the intended
  // value when the diff is < 1.5 px, distinguishing it from a real user
  // scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases.
  private _intendedScrollOffset: number | null = null
  shouldAdjustScrollPositionOnItemSizeChange:
    | undefined
    | ((
        item: VirtualItem,
        delta: number,
        instance: Virtualizer<TScrollElement, TItemElement>,
      ) => boolean)
  elementsCache = new Map<Key, TItemElement>()
  private now = () => this.targetWindow?.performance?.now?.() ?? Date.now()
  private observer = (() => {
    let _ro: ResizeObserver | null = null

    const get = () => {
      if (_ro) {
        return _ro
      }

      if (!this.targetWindow || !this.targetWindow.ResizeObserver) {
        return null
      }

      return (_ro = new this.targetWindow.ResizeObserver((entries) => {
        entries.forEach((entry) => {
          const run = () => {
            const node = entry.target as TItemElement
            const index = this.indexFromElement(node)

            if (!node.isConnected) {
              this.observer.unobserve(node)
              // Find the cache entry pointing to this exact node and remove
              // it. We can't call getItemKey(index) here because items may
              // have been removed since this node was rendered — the index
              // could be stale and out-of-bounds in the user's data array
              // (regression test in e2e/.../stale-index.spec.ts, fix #1148).
              // The === comparison naturally handles the React-replaced-
              // a-node-for-the-same-key case: that entry now points to a
              // different node, so this loop won't match.
              for (const [cacheKey, cachedNode] of this.elementsCache) {
                if (cachedNode === node) {
                  this.elementsCache.delete(cacheKey)
                  break
                }
              }
              return
            }

            if (this.shouldMeasureDuringScroll(index)) {
              this.resizeItem(
                index,
                this.options.measureElement(node, entry, this),
              )
            }
          }
          this.options.useAnimationFrameWithResizeObserver
            ? requestAnimationFrame(run)
            : run()
        })
      }))
    }

    return {
      disconnect: () => {
        get()?.disconnect()
        _ro = null
      },
      observe: (target: Element) =>
        get()?.observe(target, { box: 'border-box' }),
      unobserve: (target: Element) => get()?.unobserve(target),
    }
  })()
  range: { startIndex: number; endIndex: number } | null = null

  constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
    this.setOptions(opts)
  }

  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
    // Skip `{...defaults, ...opts}` because explicit `undefined` values in
    // opts would override defaults with `undefined`.
    const merged = {
      debug: false,
      initialOffset: 0,
      overscan: 1,
      paddingStart: 0,
      paddingEnd: 0,
      scrollPaddingStart: 0,
      scrollPaddingEnd: 0,
      horizontal: false,
      getItemKey: defaultKeyExtractor,
      rangeExtractor: defaultRangeExtractor,
      onChange: () => {},
      measureElement,
      initialRect: { width: 0, height: 0 },
      scrollMargin: 0,
      gap: 0,
      indexAttribute: 'data-index',
      initialMeasurementsCache: [],
      lanes: 1,
      anchorTo: 'start',
      followOnAppend: false,
      scrollEndThreshold: 1,
      isScrollingResetDelay: 150,
      enabled: true,
      isRtl: false,
      useScrollendEvent: false,
      useAnimationFrameWithResizeObserver: false,
      laneAssignmentMode: 'estimate',
      useCachedMeasurements: false,
    } as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>>

    for (const key in opts) {
      const v = (opts as any)[key]
      if (v !== undefined) (merged as any)[key] = v
    }

    const prevOptions = this.options as
      | Required<VirtualizerOptions<TScrollElement, TItemElement>>
      | undefined
    let anchor: [Key, number] | null = null
    let followOnAppend: ScrollBehavior | null = null
    let edgeKeysChanged = false

    if (
      prevOptions !== undefined &&
      prevOptions.enabled &&
      merged.enabled &&
      merged.anchorTo === 'end' &&
      this.scrollElement !== null
    ) {
      const prevCount = prevOptions.count
      const nextCount = merged.count
      const measurements = this.getMeasurements()
      const prevFirstKey =
        prevCount > 0
          ? (measurements[0]?.key ?? prevOptions.getItemKey(0))
          : null
      const prevLastKey =
        prevCount > 0
          ? (measurements[prevCount - 1]?.key ??
            prevOptions.getItemKey(prevCount - 1))
          : null
      const didCountChange = nextCount !== prevCount
      const didEdgeKeysChange =
        didCountChange ||
        (prevCount > 0 &&
          nextCount > 0 &&
          (merged.getItemKey(0) !== prevFirstKey ||
            merged.getItemKey(nextCount - 1) !== prevLastKey))

      if (didEdgeKeysChange) {
        edgeKeysChanged = true
        const item =
          prevCount > 0
            ? (this.getVirtualItemForOffset(this.getScrollOffset()) ??
              measurements[0])
            : null

        if (item) {
          anchor = [item.key, this.getScrollOffset() - item.start]
        }

        const behavior =
          merged.followOnAppend === true
            ? 'auto'
            : merged.followOnAppend || null

        if (
          behavior &&
          nextCount > prevCount &&
          this.isAtEnd(prevOptions.scrollEndThreshold) &&
          (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)
        ) {
          followOnAppend = behavior
        }
      }
    }

    this.options = merged

    // When edge keys changed (prepend, trim, reorder, etc.) the key→index
    // mapping has shifted. Force a full measurement rebuild so the anchor
    // resolution below reads positions from the new layout, not the stale
    // memoised cache. Without this, a stable `getItemKey` reference +
    // unchanged `count` would let getMeasurements() return the old layout.
    if (edgeKeysChanged) {
      this.pendingMin = 0
      this.itemSizeCacheVersion++
    }

    // Eagerly adjust scrollOffset so the virtualizer computes the correct
    // visible range during the current render pass — before _willUpdate
    // syncs the DOM scroll position in a layout effect. Without this,
    // the virtualizer would render the wrong items for one frame (the
    // estimate-based positions are stale) and then correct in the next
    // frame, producing a visible "jump" on prepend with dynamic sizes.
    let anchorResolved = false
    let anchorDelta = 0
    if (anchor && this.scrollOffset !== null) {
      const [anchorKey, anchorOffset] = anchor
      const newMeasurements = this.getMeasurements()
      const { count, getItemKey } = this.options
      let idx = 0
      while (idx < count && getItemKey(idx) !== anchorKey) {
        idx++
      }
      if (idx < count) {
        const anchorItem = newMeasurements[idx]
        if (anchorItem) {
          // Clamp to the reachable range's lower bound — anchorOffset may
          // have been derived from a transiently negative scrollOffset
          // (rubber-band), and a negative tracked offset never self-heals
          // when the element cannot scroll (#1229).
          const newOffset = Math.max(0, anchorItem.start + anchorOffset)
          if (newOffset !== this.scrollOffset) {
            anchorDelta = newOffset - this.scrollOffset
            this.scrollOffset = newOffset
            anchorResolved = true
          }
        }
      }
    }

    if (anchorResolved || followOnAppend) {
      this.pendingScrollAnchor = [
        anchorResolved ? anchor![0] : null,
        anchorResolved ? anchor![1] : 0,
        followOnAppend,
        anchorDelta,
      ]
    }
  }

  private notify = (sync: boolean) => {
    this.options.onChange?.(this, sync)
  }

  private applyScrollAdjustment(delta: number, behavior?: ScrollBehavior) {
    if (delta === 0) return

    if (process.env.NODE_ENV !== 'production' && this.options.debug) {
      console.info('correction', delta)
    }

    if (
      isIOSWebKit() &&
      (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
    ) {
      this._iosDeferredAdjustment += delta
    } else {
      this._scrollToOffset(this.getScrollOffset(), {
        adjustments: (this.scrollAdjustments += delta),
        behavior,
      })
      // Eagerly carry the intended target in `scrollOffset` so callers that
      // read it before the next scroll event — notably the next `resizeItem`
      // tick's `getVirtualDistanceFromEnd()` / `wasAtEnd` check — see the
      // post-adjustment position even when the DOM `scrollTop` write was
      // clamped because the consumer hasn't grown the sizer yet (`notify()`
      // runs after this in `resizeItem`). Same idea as the eager
      // `scrollOffset` adjustment for prepend in `setOptions` (#1176). The
      // adjustment is now baked into `scrollOffset`, so zero
      // `scrollAdjustments` to keep their sum invariant.
      if (this.scrollOffset !== null) {
        this.scrollOffset += this.scrollAdjustments
        // Clamp only the lower bound: a negative offset is unreachable, and
        // on an unscrollable element (content fits the viewport) no scroll
        // event ever fires to correct it, permanently skewing
        // getDistanceFromEnd() and wedging _flushIosDeferredIfReady (#1229).
        // Upper-bound overflow stays untouched — it is transiently
        // legitimate mid-prepend while the consumer's sizer catches up.
        if (this.scrollOffset < 0) this.scrollOffset = 0
        this.scrollAdjustments = 0
      }
    }
  }

  private maybeNotify = memo(
    () => {
      this.calculateRange()

      return [
        this.isScrolling,
        this.range ? this.range.startIndex : null,
        this.range ? this.range.endIndex : null,
      ]
    },
    (isScrolling) => {
      this.notify(isScrolling)
    },
    {
      key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
      debug: () => this.options.debug,
      initialDeps: [
        this.isScrolling,
        this.range ? this.range.startIndex : null,
        this.range ? this.range.endIndex : null,
      ] as [boolean, number | null, number | null],
    },
  )

  private cleanup = () => {
    this.unsubs.filter(Boolean).forEach((d) => d!())
    this.unsubs = []
    this.observer.disconnect()
    if (this.rafId != null && this.targetWindow) {
      this.targetWindow.cancelAnimationFrame(this.rafId)
      this.rafId = null
    }
    this.scrollState = null
    // The iOS gesture/deferral state is scoped to the current scroll
    // element: the touch listeners that maintain it were just removed, and
    // an in-flight touch keeps targeting the old element (implicit touch
    // capture), so the new element never reports it. Carrying the state
    // over would replay a stale deferred delta on the new element's first
    // flush, and a cleanup that lands mid-touch or inside the post-touchend
    // grace window would strand _iosTouching / _iosJustTouchEnded as true
    // (the listener unsub clears the grace timer, and with it the only
    // pending reset of the flag), deferring every adjustment on the new
    // element until its next touch cycle.
    this._iosDeferredAdjustment = 0
    this._iosTouching = false
    this._iosJustTouchEnded = false
    this.scrollElement = null
    this.targetWindow = null
  }

  _didMount = () => {
    return () => {
      this.cleanup()
    }
  }

  _willUpdate = () => {
    const scrollElement = this.options.enabled
      ? this.options.getScrollElement()
      : null

    if (this.scrollElement !== scrollElement) {
      this.cleanup()

      if (!scrollElement) {
        this.maybeNotify()
        return
      }

      this.scrollElement = scrollElement

      if (this.scrollElement && 'ownerDocument' in this.scrollElement) {
        this.targetWindow = this.scrollElement.ownerDocument.defaultView
      } else {
        this.targetWindow = this.scrollElement?.window ?? null
      }

      this.elementsCache.forEach((cached) => {
        this.observer.observe(cached)
      })

      this.unsubs.push(
        this.options.observeElementRect(this, (rect) => {
          this.scrollRect = rect
          this.maybeNotify()
        }),
      )

      this.unsubs.push(
        this.options.observeElementOffset(this, (offset, isScrolling) => {
          // A scroll event that reports movement but lands on the offset we
          // already hold — and isn't a self-write read-back — is a spurious
          // no-op re-emit that Safari/Firefox fire after a re-render's layout
          // (Chrome doesn't). Treating it as scrolling re-arms `isScrolling`,
          // which forces a render that triggers another such event: an
          // infinite re-render loop. Ignore it. (Self-writes are handled by
          // the `_intendedScrollOffset` reconciliation just below.)
          if (
            isScrolling &&
            this._intendedScrollOffset === null &&
            offset === this.scrollOffset
          ) {
            return
          }

          // If this scroll event looks like the browser's read-back of a
          // value we just wrote, prefer our intended (sub-pixel-accurate)
          // value over the browser's rounded one. The 1.5 px tolerance is
          // tight enough to avoid mistaking a real user scroll for a
          // self-write — by the time the user has moved 1.5 px, the
          // intended value will already have been consumed by a prior
          // scroll event and cleared.
          if (
            this._intendedScrollOffset !== null &&
            Math.abs(offset - this._intendedScrollOffset) < 1.5
          ) {
            offset = this._intendedScrollOffset
          }
          this._intendedScrollOffset = null

          this.scrollAdjustments = 0
          // If the offset hasn't moved, this is the echo of our own
          // adjustment write — `applyScrollAdjustment` already folded it
          // into `scrollOffset`. There's no direction to infer, so leave
          // it alone; a real gesture always moves the offset.
          const prevOffset = this.getScrollOffset()
          this.scrollDirection = isScrolling
            ? prevOffset === offset
              ? this.scrollDirection
              : prevOffset < offset
                ? 'forward'
                : 'backward'
            : null
          this.scrollOffset = offset
          this.isScrolling = isScrolling

          // Flush deferred iOS adjustments if we're now fully settled.
          // "Fully settled" means: not actively scrolling, no finger on
          // screen, and the post-touchend grace window has expired.
          this._flushIosDeferredIfReady()

          if (this.scrollState) {
            this.scheduleScrollReconcile()
          }
          this.maybeNotify()
        }),
      )

      // Touch event listeners (iOS-aware deferral). We attach unconditionally
      // — the listeners are passive and cheap; on non-touch devices they
      // simply never fire. The gating by isIOSWebKit() lives in resizeItem
      // and _flushIosDeferredIfReady so we only burn the path on iOS.
      if ('addEventListener' in this.scrollElement) {
        const scrollEl = this.scrollElement as unknown as EventTarget
        const onTouchStart = () => {
          this._iosTouching = true
          this._iosJustTouchEnded = false
          if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
            this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
            this._iosTouchEndTimerId = null
          }
        }
        const onTouchEnd = () => {
          this._iosTouching = false
          if (!isIOSWebKit() || this.targetWindow == null) {
            // Non-iOS: nothing more to track. Just clear the touching flag.
            return
          }
          this._iosJustTouchEnded = true
          // After ~150 ms with no scroll/touch events, momentum is done.
          this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => {
            this._iosJustTouchEnded = false
            this._iosTouchEndTimerId = null
            // After the grace window, attempt to flush. The scroll event
            // for momentum decay may have already fired before our timer.
            this._flushIosDeferredIfReady()
          }, 150)
        }
        scrollEl.addEventListener(
          'touchstart',
          onTouchStart,
          addEventListenerOptions,
        )
        scrollEl.addEventListener(
          'touchend',
          onTouchEnd,
          addEventListenerOptions,
        )
        this.unsubs.push(() => {
          scrollEl.removeEventListener('touchstart', onTouchStart)
          scrollEl.removeEventListener('touchend', onTouchEnd)
          if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
            this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
            this._iosTouchEndTimerId = null
          }
        })
      }

      this._scrollToOffset(this.getScrollOffset(), {
        adjustments: undefined,
        behavior: undefined,
      })
    }

    const anchor = this.pendingScrollAnchor
    this.pendingScrollAnchor = null

    if (anchor && this.scrollElement && this.options.enabled) {
      const [key, _offset, followOnAppend, anchorDelta] = anchor

      if (key !== null && !followOnAppend) {
        // scrollOffset was eagerly adjusted in setOptions so the
        // virtualizer already computed the correct range during render.
        // Now sync the browser's actual scroll position to match.
        // Skip when followOnAppend is set — scrollToEnd will handle it.
        //
        // On iOS WebKit, writing scrollTop during touch/momentum cancels
        // the in-flight scroll. Defer the DOM sync the same way
        // applyScrollAdjustment does — accumulate the delta and let
        // _flushIosDeferredIfReady handle it once the scroll settles.
        if (
          isIOSWebKit() &&
          (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
        ) {
          if (anchorDelta !== 0) {
            this._iosDeferredAdjustment += anchorDelta
          }
        } else {
          this._scrollToOffset(this.getScrollOffset(), {
            adjustments: undefined,
            behavior: undefined,
          })
        }
      }

      if (followOnAppend) {
        this.scrollToEnd({ behavior: followOnAppend })
      }
    }
  }

  // Apply any accumulated iOS-deferred scroll adjustment, but only when we're
  // truly settled — not actively scrolling, not under an active touch, and
  // past the post-touchend grace window. Called from the scroll callback
  // and the touchend grace-timer.
  private _flushIosDeferredIfReady = () => {
    if (this._iosDeferredAdjustment === 0) return
    if (this.isScrolling) return
    if (this._iosTouching) return
    if (this._iosJustTouchEnded) return
    // Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go
    // negative or beyond scrollHeight - clientHeight. Writing scrollTop
    // while in that zone snaps the page back to the clamped value at the
    // end of the bounce, often discarding the user's intent. Skip the
    // flush; the next in-bounds scroll event will retry.
    const cur = this.getScrollOffset()
    const max = this.getMaxScrollOffset()
    if (cur < 0 || cur > max) return
    // At the end clamp the browser already absorbed a shrink above the
    // viewport (it clamped scrollTop onto the new bottom), so replaying our
    // deferred negative delta would lift the view off the bottom — drop it.
    // Positive deltas still replay: growth above doesn't clamp. (#1233)
    if (this._iosDeferredAdjustment < 0 && cur >= max - 1) {
      this._iosDeferredAdjustment = 0
      return
    }
    const delta = this._iosDeferredAdjustment
    this._iosDeferredAdjustment = 0
    // Roll the deferred delta into the running accumulator so any resize
    // landing between now and the resulting scroll event computes from the
    // post-flush offset rather than the stale one.
    this._scrollToOffset(cur, {
      adjustments: (this.scrollAdjustments += delta),
      behavior: undefined,
    })
  }

  private rafId: number | null = null
  private scheduleScrollReconcile() {
    if (!this.targetWindow) {
      this.scrollState = null
      return
    }
    if (this.rafId != null) return
    this.rafId = this.targetWindow.requestAnimationFrame(() => {
      this.rafId = null
      this.reconcileScroll()
    })
  }
  private reconcileScroll() {
    if (!this.scrollState) return

    const el = this.scrollElement
    if (!el) return

    // Safety valve: bail out if reconciliation has been running too long
    const MAX_RECONCILE_MS = 5000
    if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
      this.scrollState = null
      return
    }

    const offsetInfo =
      this.scrollState.index != null
        ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align)
        : undefined
    const targetOffset = offsetInfo
      ? offsetInfo[0]
      : this.scrollState.lastTargetOffset

    // Require one stable frame where target matches scroll offset.
    // approxEqual() already tolerates minor fluctuations, so one frame is sufficient
    // to confirm scroll has reached its target without premature cleanup.
    const STABLE_FRAMES = 1

    const targetChanged = targetOffset !== this.scrollState.lastTargetOffset

    if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
      this.scrollState.stableFrames++
      if (this.scrollState.stableFrames >= STABLE_FRAMES) {
        // Final-pass exact landing. The reconcile-stable check uses a 1.01px
        // tolerance (approxEqual) so we don't fight subpixel browser rounding
        // during the converging phase. Once we're definitively settled,
        // commit the exact target so consumers calling scrollToIndex(N)
        // end up at the EXACT computed position of item N — matching
        // virtuoso's 0px landing accuracy rather than our prior 0.5-1px.
        if (this.getScrollOffset() !== targetOffset) {
          this._scrollToOffset(targetOffset, {
            adjustments: undefined,
            behavior: 'auto',
          })
        }
        this.scrollState = null
        return
      }
    } else {
      this.scrollState.stableFrames = 0

      if (targetChanged) {
        // When the target moves during smooth scroll (because items came into
        // view and got measured, shifting positions), the original logic was
        // to immediately snap to 'auto' — visibly jarring on long
        // scroll-to-index calls. Now: keep smooth while we're still far
        // (more than a viewport) from the new target. Only fall back to
        // 'auto' for the final approach, so the user sees one continuous
        // motion that smoothly adjusts its endpoint as measurements arrive.
        const viewport = this.getSize() || 600
        const distance = Math.abs(targetOffset - this.getScrollOffset())
        const keepSmooth =
          this.scrollState.behavior === 'smooth' && distance > viewport

        this.scrollState.lastTargetOffset = targetOffset
        if (!keepSmooth) {
          this.scrollState.behavior = 'auto'
        }

        this._scrollToOffset(targetOffset, {
          adjustments: undefined,
          behavior: keepSmooth ? 'smooth' : 'auto',
        })
      }
    }

    // Always reschedule while scrollState is active to guarantee
    // the safety valve timeout runs even if no scroll events fire
    // (e.g. no-op scrollToFn, detached element)
    this.scheduleScrollReconcile()
  }

  private getSize = () => {
    if (!this.options.enabled) {
      this.scrollRect = null
      return 0
    }

    this.scrollRect = this.scrollRect ?? this.options.initialRect

    return this.scrollRect[this.options.horizontal ? 'width' : 'height']
  }

  private getScrollOffset = () => {
    if (!this.options.enabled) {
      this.scrollOffset = null
      return 0
    }

    this.scrollOffset =
      this.scrollOffset ??
      (typeof this.options.initialOffset === 'function'
        ? this.options.initialOffset()
        : this.options.initialOffset)

    return this.scrollOffset
  }

  private getMeasurementOptions = memo(
    () => [
      this.options.count,
      this.options.paddingStart,
      this.options.scrollMargin,
      this.options.getItemKey,
      this.options.enabled,
      this.options.lanes,
      this.options.laneAssignmentMode,
      this.options.gap,
    ],
    (
      count,
      paddingStart,
      scrollMargin,
      getItemKey,
      enabled,
      lanes,
      laneAssignmentMode,
      gap,
    ) => {
      const lanesChanged =
        this.prevLanes !== undefined && this.prevLanes !== lanes

      if (lanesChanged) {
        // Set flag for getMeasurements to handle
        this.lanesChangedFlag = true
      }

      this.prevLanes = lanes
      this.pendingMin = null

      return {
        count,
        paddingStart,
        scrollMargin,
        getItemKey,
        enabled,
        lanes,
        laneAssignmentMode,
        gap,
      }
    },
    {
      key: false,
    },
  )

  private getMeasurements = memo(
    () => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
    (
      {
        count,
        paddingStart,
        scrollMargin,
        getItemKey,
        enabled,
        lanes,
        laneAssignmentMode,
        gap,
      },
      _itemSizeCacheVersion,
    ) => {
      const itemSizeCache = this.itemSizeCache
      if (!enabled) {
        this.measurementsCache = []
        this.itemSizeCache.clear()
        this.laneAssignments.clear()
        return []
      }

      // Clean up stale lane cache entries when count decreases
      if (this.laneAssignments.size > count) {
        for (const index of this.laneAssignments.keys()) {
          if (index >= count) {
            this.laneAssignments.delete(index)
          }
        }
      }

      // ✅ Force complete recalculation when lanes change
      if (this.lanesChangedFlag) {
        this.lanesChangedFlag = false // Reset immediately
        this.lanesSettling = true // Start settling period
        this.measurementsCache = []
        this.itemSizeCache.clear()
        this.laneAssignments.clear() // Clear lane cache for new lane count
        // Force min = 0 on the rebuild
        this.pendingMin = null
      }

      // Don't restore from initialMeasurementsCache during lane changes
      // as it contains stale lane assignments from the previous lane count
      if (this.measurementsCache.length === 0 && !this.lanesSettling) {
        this.measurementsCache = this.options.initialMeasurementsCache
        this.measurementsCache.forEach((item) => {
          this.itemSizeCache.set(item.key, item.size)
        })
      }

      // During lanes settling, ignore pendingMin to prevent repositioning
      const min = this.lanesSettling ? 0 : (this.pendingMin ?? 0)
      this.pendingMin = null

      // ✅ End settling period when cache is fully built
      if (this.lanesSettling && this.measurementsCache.length === count) {
        this.lanesSettling = false
      }

      // ─── Fast path: single-lane lazy materialization ────────────────────
      // For lanes === 1 (the default and most common case), skip the
      // per-item VirtualItem object allocation. We write start/size pairs
      // into a Float64Array and return a Proxy that builds VirtualItem
      // objects on demand (only the indices a consumer actually reads).
      //
      // At n=100k this drops cold-mount cost from ~2.5ms (eager object
      // allocation) to roughly the cost of a single typed-array fill.
      if (lanes === 1) {
        // Reuse flat backing if large enough; else grow (preserving data
        // before `min` to mirror the slice-and-rebuild contract).
        const need = count * 2
        let flat = this._flatMeasurements
        if (!flat || flat.length < need) {
          const next = new Float64Array(need)
          if (flat && min > 0) next.set(flat.subarray(0, min * 2))
          flat = next
          this._flatMeasurements = flat
        }

        let runningStart: number
        if (min === 0) {
          runningStart = paddingStart + scrollMargin
        } else {
          // Continue from where we left off
          const prevIdx = min - 1
          runningStart = flat[prevIdx * 2]! + flat[prevIdx * 2 + 1]! + gap
        }

        for (let i = min; i < count; i++) {
          const key = getItemKey(i)
          const measuredSize = itemSizeCache.get(key)
          const size =
            typeof measuredSize === 'number'
              ? measuredSize
              : this.options.estimateSize(i)
          flat[i * 2] = runningStart
          flat[i * 2 + 1] = size
          runningStart += size + gap
        }

        const view = createLazyMeasurementsView(count, flat, getItemKey)
        this.measurementsCache = view
        return view
      }

      const measurements = this.measurementsCache.slice(0, min)

      // ✅ Performance: Track last item index per lane for O(1) lookup
      const laneLastIndex: Array<number | undefined> = new Array(lanes).fill(
        undefined,
      )
      // Running end position of each lane's last item, so the shortest lane
      // can be found with an O(lanes) argmin instead of the old backward walk
      // through `measurements` (getFurthestMeasurement). `filledLanes` tracks
      // how many lanes have at least one item, mirroring the previous
      // "all lanes seen → shortest lane, else i % lanes" branch.
      const laneEnds = new Float64Array(lanes)
      let filledLanes = 0

      // Initialize from existing measurements (before min)
      for (let m = 0; m < min; m++) {
        const item = measurements[m]
        if (item) {
          if (laneLastIndex[item.lane] === undefined) filledLanes++
          laneLastIndex[item.lane] = m
          laneEnds[item.lane] = item.end
        }
      }

      for (let i = min; i < count; i++) {
        const key = getItemKey(i)

        // Check for cached lane assignment
        const cachedLane = this.laneAssignments.get(i)
        let lane: number
        let start: number

        const shouldCacheLane =
          laneAssignmentMode === 'estimate' || itemSizeCache.has(key)

        if (cachedLane !== undefined && this.options.lanes > 1) {
          // Use cached lane - O(1) lookup for previous item in same lane
          lane = cachedLane
          const prevIndex = laneLastIndex[lane]
          const prevInLane =
            prevIndex !== undefined ? measurements[prevIndex] : undefined
          start = prevInLane
            ? prevInLane.end + gap
            : paddingStart + scrollMargin
        } else if (filledLanes === lanes) {
          // No cache, every lane seeded: place in the shortest lane.
          // Read the running per-lane ends (O(lanes) argmin) instead of the
          // old backward scan. Tie-break on the lane's last-item index to
          // preserve the previous sort-by-(end, index) placement exactly.
          let bestLane = 0
          let bestEnd = laneEnds[0]!
          let bestIdx = laneLastIndex[0]!
          for (let l = 1; l < lanes; l++) {
            const e = laneEnds[l]!
            if (e < bestEnd || (e === bestEnd && laneLastIndex[l]! < bestIdx)) {
              bestLane = l
              bestEnd = e
              bestIdx = laneLastIndex[l]!
            }
          }
          lane = bestLane
          start = bestEnd + gap

          if (shouldCacheLane) {
            this.laneAssignments.set(i, lane)
          }
        } else {
          // No cache and not every lane seeded yet — seed lanes in order,
          // matching the previous `i % lanes` fallback for the first row.
          lane = i % this.options.lanes
          start = paddingStart + scrollMargin

          if (shouldCacheLane) {
            this.laneAssignments.set(i, lane)
          }
        }

        const measuredSize = itemSizeCache.get(key)
        const size =
          typeof measuredSize === 'number'
            ? measuredSize
            : this.options.estimateSize(i)

        const end = start + size

        measurements[i] = {
          index: i,
          start,
          size,
          end,
          key,
          lane,
        }

        // ✅ Performance: Update lane's last item index + running end
        if (laneLastIndex[lane] === undefined) filledLanes++
        laneLastIndex[lane] = i
        laneEnds[lane] = end
      }

      this.measurementsCache = measurements

      return measurements
    },
    {
      key: process.env.NODE_ENV !== 'production' && 'getMeasurements',
      debug: () => this.options.debug,
    },
  )

  calculateRange = memo(
    () => [
      this.getMeasurements(),
      this.getSize(),
      this.getScrollOffset(),
      this.options.lanes,
    ],
    (measurements, outerSize, scrollOffset, lanes) => {
      if (measurements.length === 0 || outerSize === 0) {
        this.range = null
        return null
      }
      this.range = calculateRangeImpl(
        measurements,
        outerSize,
        scrollOffset,
        lanes,
        // Pass the typed array so binary search + forward-walk can read
        // start/end directly from Float64Array, skipping the Proxy traps.
        lanes === 1 && this._flatMeasurements != null
          ? this._flatMeasurements
          : null,
      )
      return this.range
    },
    {
      key: process.env.NODE_ENV !== 'production' && 'calculateRange',
      debug: () => this.options.debug,
    },
  )

  getVirtualIndexes = memo(
    () => {
      let startIndex: number | null = null
      let endIndex: number | null = null
      const range = this.calculateRange()
      if (range) {
        startIndex = range.startIndex
        endIndex = range.endIndex
      }
      this.maybeNotify.updateDeps([this.isScrolling, startIndex, endIndex])
      return [
        this.options.rangeExtractor,
        this.options.overscan,
        this.options.count,
        startIndex,
        endIndex,
      ]
    },
    (rangeExtractor, overscan, count, startIndex, endIndex) => {
      return startIndex === null || endIndex === null
        ? []
        : rangeExtractor({
            startIndex,
            endIndex,
            overscan,
            count,
          })
    },
    {
      key: process.env.NODE_ENV !== 'production' && 'getVirtualIndexes',
      debug: () => this.options.debug,
    },
  )

  indexFromElement = (node: TItemElement) => {
    const attributeName = this.options.indexAttribute
    const indexStr = node.getAttribute(attributeName)

    if (!indexStr) {
      console.warn(
        `Missing attribute name '${attributeName}={index}' on measured element.`,
      )
      return -1
    }

    return parseInt(indexStr, 10)
  }

  /**
   * Determines if an item at the given index should be measured during smooth scroll.
   * During smooth scroll, only items within a buffer range around the target are measured
   * to prevent items far from the target from pushing it away.
   */
  private shouldMeasureDuringScroll = (index: number): boolean => {
    // No scroll state or not smooth scroll - always allow measurements
    if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
      return true
    }

    const scrollIndex =
      this.scrollState.index ??
      this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index

    if (scrollIndex !== undefined && this.range) {
      // Allow measurements within a buffer range around the scroll target
      const bufferSize = Math.max(
        this.options.overscan,
        Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
      )
      const minIndex = Math.max(0, scrollIndex - bufferSize)
      const maxIndex = Math.min(
        this.options.count - 1,
        scrollIndex + bufferSize,
      )
      return index >= minIndex && index <= maxIndex
    }

    return true
  }

  measureElement = (node: TItemElement | null) => {
    if (!node) {
      this.elementsCache.forEach((cached, key) => {
        if (!cached.isConnected) {
          this.observer.unobserve(cached)
          this.elementsCache.delete(key)
        }
      })
      return
    }

    const index = this.indexFromElement(node)
    const key = this.options.getItemKey(index)
    const prevNode = this.elementsCache.get(key)

    if (prevNode !== node) {
      if (prevNode) {
        this.observer.unobserve(prevNode)
      }
      this.observer.observe(node)
      this.elementsCache.set(key, node)
    }

    // Sync-measure when idle (initial render) or during programmatic scrolling
    // (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame.
    // During normal user scrolling, skip sync measurement — the RO callback handles it async.
    if (
      (!this.isScrolling || this.scrollState) &&
      this.shouldMeasureDuringScroll(index)
    ) {
      this.resizeItem(index, this.options.measureElement(node, undefined, this))
    }
  }

  resizeItem = (index: number, size: number) => {
    if (index < 0 || index >= this.options.count) return

    // Fast field reads. For lanes===1 we read raw start/size from the flat
    // typed array, avoiding a Proxy.get + VirtualItem allocation per call.
    // For lanes>1 we fall back to the cached VirtualItem array.
    let cachedSize: number
    let itemStart: number
    let key: Key
    const flat = this._flatMeasurements
    if (this.options.lanes === 1 && flat !== null) {
      key = this.options.getItemKey(index)
      itemStart = flat[index * 2]!
      cachedSize = flat[index * 2 + 1]!
    } else {
      const item = this.measurementsCache[index]
      if (!item) return
      key = item.key
      itemStart = item.start
      cachedSize = item.size
    }

    const itemSize = this.itemSizeCache.get(key) ?? cachedSize
    const delta = size - itemSize

    if (delta !== 0) {
      const wasAtEnd =
        this.options.anchorTo === 'end' &&
        this.scrollState?.behavior !== 'smooth' &&
        this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold
      const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0
      // Default anchoring predicate (used unless the consumer supplies a
      // custom shouldAdjustScrollPositionOnItemSizeChange).
      const scrollOffsetWithAdj =
        this.getScrollOffset() + this.scrollAdjustments
      const isFirstMeasure = !this.itemSizeCache.has(key)
      const defaultShouldAdjust = isFirstMeasure
        ? // First measurement: compensate any item whose top sits above the
          // fold — the estimate→actual delta must be corrected regardless of
          // scroll direction, since the whole estimated block was above it.
          itemStart < scrollOffsetWithAdj
        : // Re-measurement: only compensate an item that is ENTIRELY above the
          // fold. An item that merely *spans* the fold (top above, bottom
          // below — e.g. a streaming chat message growing at its bottom)
          // changes size *below* the anchor point, so shifting scrollTop by the
          // delta would drag the viewport downward on every growth (#1218).
          // Also skip during backward scroll to avoid the "items jump while
          // scrolling up" cascade.
          itemStart + itemSize <= scrollOffsetWithAdj &&
          this.scrollDirection !== 'backward'
      const shouldAdjustScroll =
        this.scrollState?.behavior !== 'smooth' &&
        (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
          ? this.shouldAdjustScrollPositionOnItemSizeChange(
              // The callback expects a VirtualItem; build one lazily only
              // when the consumer actually supplied a custom predicate.
              this.measurementsCache[index] ?? {
                index,
                key,
                start: itemStart,
                size: cachedSize,
                end: itemStart + cachedSize,
                lane: 0,
              },
              delta,
              this,
            )
          : defaultShouldAdjust)

      if (this.pendingMin === null || index < this.pendingMin) {
        this.pendingMin = index
      }
      this.itemSizeCache.set(key, size)
      this.itemSizeCacheVersion++

      if (wasAtEnd) {
        this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize)
      } else if (shouldAdjustScroll) {
        this.applyScrollAdjustment(delta)
      }

      this.notify(false)
    }
  }

  getVirtualItems = memo(
    () => [this.getVirtualIndexes(), this.getMeasurements()],
    (indexes, measurements) => {
      const virtualItems: Array<VirtualItem> = []

      for (let k = 0, len = indexes.length; k < len; k++) {
        const i = indexes[k]!
        const measurement = measurements[i]!

        virtualItems.push(measurement)
      }

      return virtualItems
    },
    {
      key: process.env.NODE_ENV !== 'production' && 'getVirtualItems',
      debug: () => this.options.debug,
    },
  )

  getVirtualItemForOffset = (offset: number) => {
    const measurements = this.getMeasurements()
    if (measurements.length === 0) {
      return undefined
    }
    // Same fast-path as calculateRange: read start values directly from the
    // typed array during binary search to skip the Proxy.get materialization
    // per probe.
    const flat = this._flatMeasurements
    const useFlat = this.options.lanes === 1 && flat != null
    const idx = findNearestBinarySearch(
      0,
      measurements.length - 1,
      useFlat
        ? (i: number) => flat[i * 2]!
        : (i: number) => notUndefined(measurements[i]).start,
      offset,
    )
    return notUndefined(measurements[idx])
  }

  private getMaxScrollOffset = () => {
    if (!this.scrollElement) return 0

    if ('scrollHeight' in this.scrollElement) {
      // Element
      return this.options.horizontal
        ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth
        : this.scrollElement.scrollHeight - this.scrollElement.clientHeight
    } else {
      // Window
      const doc = this.scrollElement.document.documentElement
      return this.options.horizontal
        ? doc.scrollWidth - this.scrollElement.innerWidth
        : doc.scrollHeight - this.scrollElement.innerHeight
    }
  }

  private getVirtualDistanceFromEnd = () => {
    return Math.max(
      this.getTotalSize() - this.getSize() - this.getScrollOffset(),
      0,
    )
  }

  getDistanceFromEnd = () => {
    return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0)
  }

  isAtEnd = (threshold = this.options.scrollEndThreshold) => {
    return this.getDistanceFromEnd() <= threshold
  }

  getOffsetForAlignment = (
    toOffset: number,
    align: ScrollAlignment,
    itemSize = 0,
  ) => {
    if (!this.scrollElement) return 0

    const size = this.getSize()
    const scrollOffset = this.getScrollOffset()

    if (align === 'auto') {
      align = toOffset >= scrollOffset + size ? 'end' : 'start'
    }

    if (align === 'center') {
      // When aligning to a particular item (e.g. with scrollToIndex),
      // adjust offset by the size of the item to center on the item
      toOffset += (itemSize - size) / 2
    } else if (align === 'end') {
      toOffset -= size
    }

    const maxOffset = this.getMaxScrollOffset()

    return Math.max(Math.min(maxOffset, toOffset), 0)
  }

  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
    index = Math.max(0, Math.min(index, this.options.count - 1))

    const size = this.getSize()
    const scrollOffset = this.getScrollOffset()

    const item = this.measurementsCache[index]
    if (!item) return

    if (align === 'auto') {
      if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
        align = 'end'
      } else if (item.start <= scrollOffset + this.options.scrollPaddingStart) {
        align = 'start'
      } else {
        return [scrollOffset, align] as const
      }
    }

    // For the last item with 'end' alignment, use browser's actual max scroll
    // to account for borders/padding that aren't in our measurements
    if (align === 'end' && index === this.options.count - 1) {
      return [this.getMaxScrollOffset(), align] as const
    }

    const toOffset =
      align === 'end'
        ? item.end + this.options.scrollPaddingEnd
        : item.start - this.options.scrollPaddingStart

    return [
      this.getOffsetForAlignment(toOffset, align, item.size),
      align,
    ] as const
  }

  scrollToOffset = (
    toOffset: number,
    { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
  ) => {
    // An absolute scroll command derives its target from current
    // measurements, so any iOS-deferred compensation still pending is stale by
    // definition — the command already accounts for the measurements the delta
    // was compensating for. Drop it so _flushIosDeferredIfReady doesn't replay
    // it onto the just-established position (relative commands like scrollBy
    // intentionally keep the deferral, since they build on the current offset).
    this._iosDeferredAdjustment = 0

    const offset = this.getOffsetForAlignment(toOffset, align)

    const now = this.now()
    this.scrollState = {
      index: null,
      align,
      behavior,
      startedAt: now,
      lastTargetOffset: offset,
      stableFrames: 0,
    }

    this._scrollToOffset(offset, { adjustments: undefined, behavior })

    this.scheduleScrollReconcile()
  }

  scrollToIndex = (
    index: number,
    {
      align: initialAlign = 'auto',
      behavior = 'auto',
    }: ScrollToIndexOptions = {},
  ) => {
    // See scrollToOffset: an absolute target invalidates any pending
    // iOS-deferred compensation.
    this._iosDeferredAdjustment = 0

    index = Math.max(0, Math.min(index, this.options.count - 1))

    const offsetInfo = this.getOffsetForIndex(index, initialAlign)
    if (!offsetInfo) {
      return
    }
    const [offset, align] = offsetInfo

    const now = this.now()
    this.scrollState = {
      index,
      align,
      behavior,
      startedAt: now,
      lastTargetOffset: offset,
      stableFrames: 0,
    }

    this._scrollToOffset(offset, { adjustments: undefined, behavior })

    this.scheduleScrollReconcile()
  }

  scrollBy = (
    delta: number,
    { behavior = 'auto' }: ScrollToOffsetOptions = {},
  ) => {
    const offset = this.getScrollOffset() + delta
    const now = this.now()

    this.scrollState = {
      index: null,
      align: 'start',
      behavior,
      startedAt: now,
      lastTargetOffset: offset,
      stableFrames: 0,
    }

    this._scrollToOffset(offset, { adjustments: undefined, behavior })

    this.scheduleScrollReconcile()
  }

  scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => {
    if (this.options.count > 0) {
      this.scrollToIndex(this.options.count - 1, {
        align: 'end',
        behavior,
      })
      return
    }

    this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), {
      behavior,
    })
  }

  getTotalSize = () => {
    const measurements = this.getMeasurements()

    let end: number
    // If there are no measurements, set the end to paddingStart
    // If there is only one lane, use the last measurement's end
    // Otherwise find the maximum end value among all measurements
    if (measurements.length === 0) {
      end = this.options.paddingStart
    } else if (this.options.lanes === 1) {
      // Fast path: read last item's end directly from the flat typed array
      // when available; avoids a Proxy.get + VirtualItem materialization
      // just to call getTotalSize (which React renders trigger every commit).
      const lastIdx = measurements.length - 1
      const flat = this._flatMeasurements
      if (flat != null) {
        end = flat[lastIdx * 2]! + flat[lastIdx * 2 + 1]!
      } else {
        end = measurements[lastIdx]?.end ?? 0
      }
    } else {
      const endByLane = Array<number | null>(this.options.lanes).fill(null)
      let endIndex = measurements.length - 1
      while (endIndex >= 0 && endByLane.some((val) => val === null)) {
        const item = measurements[endIndex]!
        if (endByLane[item.lane] === null) {
          endByLane[item.lane] = item.end
        }

        endIndex--
      }

      end = Math.max(...endByLane.filter((val): val is number => val !== null))
    }

    return Math.max(
      end - this.options.scrollMargin + this.options.paddingEnd,
      0,
    )
  }

  /**
   * Returns a snapshot of currently-measured items suitable for round-
   * tripping through state storage (sessionStorage, history, etc.) and
   * passing back as `initialMeasurementsCache` on remount. Pair with the
   * current `scrollOffset` to restore exact scroll position after navigation.
   *
   * Only items the consumer has actually rendered (and thus measured) appear
   * in the snapshot; unmeasured items will fall back to `estimateSize` on
   * restore. Returns an empty array if no items have been measured.
   */
  takeSnapshot = (): Array<VirtualItem> => {
    const snapshot: Array<VirtualItem> = []
    if (this.itemSizeCache.size === 0) return snapshot
    // Iterate measurementsCache only for indices whose key is in itemSizeCache
    // (i.e., have been measured). We build VirtualItem objects with the
    // current start/size/end so they can be persisted as plain data.
    const m = this.getMeasurements()
    for (const item of m) {
      if (item && this.itemSizeCache.has(item.key)) {
        // Force materialization (lazy path) and copy plain fields.
        snapshot.push({
          index: item.index,
          key: item.key,
          start: item.start,
          size: item.size,
          end: item.end,
          lane: item.lane,
        })
      }
    }
    return snapshot
  }

  private _scrollToOffset = (
    offset: number,
    {
      adjustments,
      behavior,
    }: {
      adjustments: number | undefined
      behavior: ScrollBehavior | undefined
    },
  ) => {
    // Record the intended logical scroll target so the next scroll event
    // can reconcile against subpixel rounding by the browser.
    this._intendedScrollOffset = offset + (adjustments ?? 0)
    this.options.scrollToFn(offset, { behavior, adjustments }, this)
  }

  measure = () => {
    // Reset pendingMin so the next getMeasurements rebuilds from index 0.
    // Without this, a prior resizeItem() that left pendingMin > 0 would
    // cause the rebuild to preserve stale items before that index.
    this.pendingMin = null
    this.itemSizeCache.clear()
    this.laneAssignments.clear() // Clear lane cache for full re-layout
    this.itemSizeCacheVersion++
    this.notify(false)
  }
}

const findNearestBinarySearch = (
  low: number,
  high: number,
  getCurrentValue: (i: number) => number,
  value: number,
) => {
  while (low <= high) {
    const middle = ((low + high) / 2) | 0
    const currentValue = getCurrentValue(middle)

    if (currentValue < value) {
      low = middle + 1
    } else if (currentValue > value) {
      high = middle - 1
    } else {
      return middle
    }
  }

  if (low > 0) {
    return low - 1
  } else {
    return 0
  }
}

// Monomorphic Float64Array variant — reads start values directly at stride
// 2 instead of through a getter closure. JITs the inner load to a typed-
// array bounds-check + load with no indirect call.
function findNearestBinarySearchFlat(
  flat: Float64Array,
  high: number,
  value: number,
) {
  let low = 0
  while (low <= high) {
    const middle = ((low + high) / 2) | 0
    const currentValue = flat[middle * 2]!

    if (currentValue < value) {
      low = middle + 1
    } else if (currentValue > value) {
      high = middle - 1
    } else {
      return middle
    }
  }
  return low > 0 ? low - 1 : 0
}

function calculateRangeImpl(
  measurements: Array<VirtualItem>,
  outerSize: number,
  scrollOffset: number,
  lanes: number,
  flat: Float64Array | null,
) {
  const lastIndex = measurements.length - 1

  // handle case when item count is less than or equal to lanes
  if (measurements.length <= lanes) {
    return { startIndex: 0, endIndex: lastIndex }
  }

  if (lanes === 1 && flat !== null) {
    // Hot single-lane path: typed-array reads, no closures, no Proxy traps.
    const startIndex = findNearestBinarySearchFlat(
      flat,
      lastIndex,
      scrollOffset,
    )
    let endIndex = startIndex
    const limit = scrollOffset + outerSize
    while (
      endIndex < lastIndex &&
      flat[endIndex * 2]! + flat[endIndex * 2 + 1]! < limit
    ) {
      endIndex++
    }
    return { startIndex, endIndex }
  }

  // Fallback (lanes > 1 or no flat array): closure-based reads.
  const getStart = (index: number) => measurements[index]!.start
  let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset)
  let endIndex = startIndex

  if (lanes === 1) {
    while (
      endIndex < lastIndex &&
      measurements[endIndex]!.end < scrollOffset + outerSize
    ) {
      endIndex++
    }
  } else if (lanes > 1) {
    // Expand forward until we include the visible items from all lanes
    // which are closer to the end of the virtualizer window
    const endPerLane = Array(lanes).fill(0)
    while (
      endIndex < lastIndex &&
      endPerLane.some((pos) => pos < scrollOffset + outerSize)
    ) {
      const item = measurements[endIndex]!
      endPerLane[item.lane] = item.end
      endIndex++
    }

    // Expand backward until we include all lanes' visible items
    // closer to the top
    const startPerLane = Array(lanes).fill(scrollOffset + outerSize)
    while (startIndex >= 0 && startPerLane.some((pos) => pos >= scrollOffset)) {
      const item = measurements[startIndex]!
      startPerLane[item.lane] = item.start
      startIndex--
    }

    // Align startIndex to the beginning of its lane
    startIndex = Math.max(0, startIndex - (startIndex % lanes))
    // Align endIndex to the end of its lane
    endIndex = Math.min(lastIndex, endIndex + (lanes - 1 - (endIndex % lanes)))
  }

  return { startIndex, endIndex }
}
