// frontend/src/composables/useVirtualScroll.js

import { ref, computed, reactive, watch, watchEffect, onUnmounted } from 'vue'

/**
 * Default minimum item height used for items that haven't been measured yet.
 * Matches the MIN_ITEM_SIZE constant used elsewhere in the codebase.
 */
const DEFAULT_MIN_ITEM_HEIGHT = 24

/**
 * Default buffer in pixels for preloading items before they enter the viewport.
 */
const DEFAULT_BUFFER = 500

/**
 * Default buffer in pixels before unloading items after they leave the viewport.
 * This is larger than the load buffer to prevent thrashing (constant load/unload cycles).
 */
const DEFAULT_UNLOAD_BUFFER = 1000

/**
 * Composable for virtual scrolling with variable-height items.
 *
 * Handles:
 * - Height cache for measured items
 * - Cumulative position calculation (computed)
 * - Binary search to find visible indices
 * - Scroll event handling (RAF-throttled)
 * - Render range calculation with asymmetric buffers
 * - ScrollTop correction when item heights change above viewport
 *
 * @param {Object} options - Configuration options
 * @param {import('vue').Ref<Array>} options.items - Reactive array of items to virtualize
 * @param {Function} options.itemKey - Function to extract unique key from item: (item) => key
 * @param {number} [options.minItemHeight=24] - Minimum/estimated height for unmeasured items
 * @param {number} [options.buffer=500] - Buffer in pixels for loading items
 * @param {number} [options.unloadBuffer=1000] - Buffer in pixels before unloading items
 * @param {import('vue').Ref<HTMLElement|null>} options.containerRef - Ref to the scroll container element
 * @returns {Object} Virtual scroll state and methods
 */
export function useVirtualScroll(options) {
    const {
        items,
        itemKey,
        minItemHeight = DEFAULT_MIN_ITEM_HEIGHT,
        buffer = DEFAULT_BUFFER,
        unloadBuffer = DEFAULT_UNLOAD_BUFFER,
        containerRef,
    } = options

    // ═══════════════════════════════════════════════════════════════════════════
    // Internal State
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Cache of measured heights: Map<itemKey, height>
     * Using reactive() to make the Map reactive for Vue's dependency tracking.
     */
    const heightCache = reactive(new Map())

    /**
     * Current scroll position of the container.
     */
    const scrollTop = ref(0)

    /**
     * Current viewport height of the container.
     */
    const viewportHeight = ref(0)

    /**
     * RAF handle for scroll throttling.
     */
    let rafId = null

    /**
     * Flag to track if we're in programmatic scroll mode (to avoid scroll correction).
     */
    let isProgrammaticScroll = false

    /**
     * Flag to track if we should stick to the bottom during height changes.
     * When true, batchUpdateItemHeights will always scroll to bottom after updates.
     * Used during initial scroll-to-bottom operations where we want to stay at bottom
     * until all items have finished resizing.
     */
    let stickToBottom = false

    /**
     * Suspended state for KeepAlive support.
     * When the scroller's container is detached from the DOM (e.g., by Vue's KeepAlive),
     * the browser resets scrollTop to 0. We save the scroll anchor (visible item + offset)
     * before this happens and freeze all reactive recalculations to prevent renderRange/spacer
     * corruption.
     *
     * The anchor-based approach is more robust than saving raw scrollTop because:
     * - It survives viewport size changes while suspended (e.g., window resize)
     * - It doesn't depend on exact pixel positions that may shift if items are re-measured
     *
     * - `suspended`: reactive ref — true when the scroller is in a detached/hidden state.
     *   MUST be a ref (not a plain let) because the renderRange watchEffect reads it:
     *   if it were a plain variable, Vue wouldn't track it as a dependency, and
     *   setting suspended=false in resume() would NOT re-trigger the watchEffect,
     *   leaving renderRange frozen and items unrendered.
     * - `savedAnchor`: { index, key, offset } — the first visible item and pixel offset into it
     */
    const suspended = ref(false)
    let savedAnchor = null
    let pendingResume = false
    // When the container is hidden by a parent (e.g., wa-tab-panel display:none),
    // the scroller auto-suspends to prevent scrollTop corruption from browser reset.
    // This flag distinguishes auto-suspension (tab switch) from manual suspension
    // (KeepAlive deactivation), so we know to auto-resume when the container
    // becomes visible again.
    let autoSuspended = false

    // ═══════════════════════════════════════════════════════════════════════════
    // Computed: Positions
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Computed array of item positions with cumulative top offset.
     * Each entry contains: { index, key, top, height }
     *
     * Performance note: This recomputes when items change OR when heightCache changes.
     * For large lists, consider memoization strategies if this becomes a bottleneck.
     */
    const positions = computed(() => {
        let top = 0
        return items.value.map((item, index) => {
            const key = itemKey(item)
            const height = heightCache.get(key) ?? minItemHeight
            const pos = { index, key, top, height }
            top += height
            return pos
        })
    })

    /**
     * Total height of all items (for scroll container sizing).
     */
    const totalHeight = computed(() => {
        const posArray = positions.value
        if (posArray.length === 0) return 0
        const last = posArray[posArray.length - 1]
        return last.top + last.height
    })

    // ═══════════════════════════════════════════════════════════════════════════
    // Binary Search
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Binary search to find the index of the first item whose bottom edge
     * is at or past the target position.
     *
     * Returns the index in the positions array, or -1 if targetTop is beyond all items.
     *
     * @param {number} targetTop - The scroll position to find
     * @returns {number} Index of the item at or just before this position
     */
    function findIndexAtPosition(targetTop) {
        const posArray = positions.value
        if (posArray.length === 0) return -1
        if (targetTop <= 0) return 0
        if (targetTop >= totalHeight.value) return posArray.length - 1

        let low = 0
        let high = posArray.length - 1

        while (low < high) {
            const mid = Math.floor((low + high) / 2)
            const pos = posArray[mid]
            const bottom = pos.top + pos.height

            if (bottom <= targetTop) {
                // Target is past this item's bottom, search in upper half
                low = mid + 1
            } else {
                // Target is within or before this item
                high = mid
            }
        }

        return low
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // Render Range with Hysteresis
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * The range of items to render with asymmetric buffers (hysteresis).
     * This is a ref updated by watchEffect rather than a computed, because
     * the hysteresis logic requires tracking the previous range as state.
     *
     * Using watchEffect is the proper Vue pattern for this scenario:
     * - It automatically tracks all reactive dependencies (scrollTop, viewportHeight, positions)
     * - Side effects (updating previousRange) are explicit and expected
     * - The ref remains reactive for consumers
     */
    const renderRange = ref({ start: 0, end: 0 })

    /**
     * Previous render range, used to implement hysteresis.
     * Items inside this range use unloadBuffer threshold to leave.
     * Items outside this range use buffer threshold to enter.
     */
    let previousRange = { start: 0, end: 0 }

    /**
     * watchEffect that calculates the render range with hysteresis.
     *
     * Implements asymmetric buffers to prevent thrashing:
     * - To ENTER the render range: item must be within `buffer` (500px) of viewport
     * - To LEAVE the render range: item must be outside `unloadBuffer` (1000px) of viewport
     *
     * This means an item at 600px from viewport:
     * - Won't be loaded initially (outside 500px buffer)
     * - Won't be unloaded if already loaded (inside 1000px unloadBuffer)
     */
    watchEffect(() => {
        const posArray = positions.value
        if (posArray.length === 0) {
            previousRange = { start: 0, end: 0 }
            renderRange.value = { start: 0, end: 0 }
            return
        }

        // When suspended, keep the current renderRange frozen.
        // `suspended` is a ref so Vue tracks it as a dependency here:
        // when resume() sets it to false, this watchEffect will re-run.
        if (suspended.value) return

        // Calculate zones with both buffers
        const loadTop = Math.max(0, scrollTop.value - buffer)
        const loadBottom = scrollTop.value + viewportHeight.value + buffer
        const unloadTop = Math.max(0, scrollTop.value - unloadBuffer)
        const unloadBottom = scrollTop.value + viewportHeight.value + unloadBuffer

        // Find indices for load zone (items that SHOULD be loaded)
        const loadStart = findIndexAtPosition(loadTop)
        const loadEnd = findIndexAtPosition(loadBottom)

        // Find indices for unload zone (items that CAN stay loaded)
        const unloadStart = findIndexAtPosition(unloadTop)
        const unloadEnd = findIndexAtPosition(unloadBottom)

        // Apply hysteresis:
        // - For start: use min of (loadStart, previousStart) but never below unloadStart
        // - For end: use max of (loadEnd, previousEnd) but never above unloadEnd
        //
        // This ensures:
        // - Items enter the range when they cross the load buffer threshold
        // - Items leave the range only when they cross the unload buffer threshold

        let newStart, newEnd

        // For start index (top of render range):
        // - Keep items that are still within unload buffer (don't unload too early)
        // - But always load items that enter the load buffer
        if (previousRange.start < loadStart) {
            // Previous range started higher (smaller index)
            // Keep items that are still within unload buffer
            newStart = Math.max(unloadStart, previousRange.start)
        } else {
            // We're expanding upward or staying same - use load buffer
            newStart = loadStart
        }

        // For end index (bottom of render range):
        // - Keep items that are still within unload buffer (don't unload too early)
        // - But always load items that enter the load buffer
        if (previousRange.end > loadEnd + 1) {
            // Previous range extended further (larger index)
            // Keep items that are still within unload buffer
            newEnd = Math.min(unloadEnd + 1, previousRange.end)
        } else {
            // We're expanding downward or staying same - use load buffer
            newEnd = loadEnd + 1
        }

        // Ensure valid bounds
        newStart = Math.max(0, newStart)
        newEnd = Math.min(posArray.length, newEnd)

        // Store for next computation and update the reactive ref
        previousRange = { start: newStart, end: newEnd }
        renderRange.value = { start: newStart, end: newEnd }
    })

    /**
     * The range of items actually visible in the viewport (without buffer).
     * Useful for the @update event to tell the parent what's truly visible.
     */
    const visibleRange = computed(() => {
        const posArray = positions.value
        if (posArray.length === 0) {
            return { start: 0, end: 0 }
        }

        const visibleTop = scrollTop.value
        const visibleBottom = scrollTop.value + viewportHeight.value

        const start = findIndexAtPosition(visibleTop)
        const end = findIndexAtPosition(visibleBottom)

        return {
            start: Math.max(0, start),
            end: Math.min(posArray.length, end + 1),
        }
    })

    // ═══════════════════════════════════════════════════════════════════════════
    // Computed: Spacers
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Height of the spacer before rendered items.
     * Sum of all item heights before renderRange.start.
     */
    const spacerBeforeHeight = computed(() => {
        const posArray = positions.value
        const { start } = renderRange.value

        if (start <= 0 || start >= posArray.length || posArray.length === 0) return 0
        // The top of the first rendered item IS the total height of items before it
        return posArray[start].top
    })

    /**
     * Height of the spacer after rendered items.
     * Sum of all item heights after renderRange.end.
     */
    const spacerAfterHeight = computed(() => {
        const posArray = positions.value
        const { end } = renderRange.value

        if (end <= 0 || end >= posArray.length || posArray.length === 0) return 0
        // Total height minus the bottom position of the last rendered item
        const lastRenderedPos = posArray[end - 1]
        const lastRenderedBottom = lastRenderedPos.top + lastRenderedPos.height
        return totalHeight.value - lastRenderedBottom
    })

    // ═══════════════════════════════════════════════════════════════════════════
    // Height Update with Scroll Correction
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Update the height of an item in the cache (single item version).
     * For better performance with multiple items, use batchUpdateItemHeights.
     *
     * @param {*} key - The item key
     * @param {number} newHeight - The new measured height
     * @returns {{ key: any, height: number, oldHeight: number | undefined } | null} Change info for event emission
     */
    function updateItemHeight(key, newHeight) {
        // Delegate to batch function for single item
        const results = batchUpdateItemHeights(new Map([[key, newHeight]]))
        return results.length > 0 ? results[0] : null
    }

    /**
     * Update multiple item heights at once with anchor-based scroll preservation.
     * This is the core function for handling batched ResizeObserver updates.
     *
     * ANCHOR-BASED SCROLLING:
     * Instead of correcting scrollTop after changes (which causes jumps),
     * we anchor on a visible item and maintain its visual position.
     *
     * Algorithm:
     * 1. Capture the "anchor" - the first item visible at the top of viewport
     * 2. Calculate offset = how far scrollTop is into that anchor item
     * 3. Apply all height changes to the cache
     * 4. Restore: newScrollTop = newAnchorTop + offset
     *
     * @param {Map<any, number>} updates - Map of itemKey → newHeight
     * @returns {Array<{key: any, height: number, oldHeight: number | undefined} | null>} Array of changes
     */
    function batchUpdateItemHeights(updates) {
        const container = containerRef.value
        const results = []

        // When suspended (container detached by KeepAlive), skip all updates.
        // The height cache retains its valid values, and any 0-height measurements
        // from detached items would be filtered out anyway.
        if (suspended.value) {
            return results
        }

        // Filter out zero heights and unchanged values
        const actualUpdates = new Map()
        for (const [key, newHeight] of updates) {
            // Reject 0 height values - these occur when items are measured while hidden
            if (newHeight === 0) {
                continue
            }
            const oldHeight = heightCache.get(key)
            if (oldHeight !== newHeight) {
                actualUpdates.set(key, { newHeight, oldHeight })
            }
        }

        // If no actual changes, return empty
        if (actualUpdates.size === 0) {
            return []
        }

        // If no container or programmatic scroll, just apply without anchor logic
        if (!container || isProgrammaticScroll) {
            for (const [key, { newHeight, oldHeight }] of actualUpdates) {
                heightCache.set(key, newHeight)
                results.push({ key, height: newHeight, oldHeight })
            }
            return results
        }

        // If stickToBottom is enabled, apply updates and scroll to bottom
        if (stickToBottom) {
            for (const [key, { newHeight, oldHeight }] of actualUpdates) {
                heightCache.set(key, newHeight)
                results.push({ key, height: newHeight, oldHeight })
            }
            // Scroll to bottom after applying updates
            const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
            if (Number.isFinite(maxScrollTop) && maxScrollTop >= 0) {
                container.scrollTop = maxScrollTop
                scrollTop.value = maxScrollTop
            }
            return results
        }

        // ══════════════════════════════════════════════════════════════════
        // ANCHOR-BASED SCROLLING
        // ══════════════════════════════════════════════════════════════════

        const currentScrollTop = container.scrollTop
        const posArray = positions.value

        // Edge case: empty list or no positions
        if (posArray.length === 0) {
            for (const [key, { newHeight, oldHeight }] of actualUpdates) {
                heightCache.set(key, newHeight)
                results.push({ key, height: newHeight, oldHeight })
            }
            return results
        }

        // 1. CAPTURE THE ANCHOR before any modifications
        // Find the first item whose bottom is past scrollTop (i.e., visible at top)
        const anchorIndex = findIndexAtPosition(currentScrollTop)
        const anchorItem = posArray[anchorIndex]

        // Safety check
        if (!anchorItem) {
            for (const [key, { newHeight, oldHeight }] of actualUpdates) {
                heightCache.set(key, newHeight)
                results.push({ key, height: newHeight, oldHeight })
            }
            return results
        }

        const anchorKey = anchorItem.key
        const anchorOriginalTop = anchorItem.top
        // Offset = how far into the anchor item the viewport starts
        const offsetInAnchor = currentScrollTop - anchorOriginalTop

        // Check if we were at the bottom (special case)
        const wasAtBottom = isAtBottom(5)

        // 2. APPLY all height changes to the cache
        for (const [key, { newHeight, oldHeight }] of actualUpdates) {
            heightCache.set(key, newHeight)
            results.push({ key, height: newHeight, oldHeight })
        }

        // 3. RESTORE scroll position relative to anchor
        // The positions computed will auto-recompute due to reactivity
        const newPosArray = positions.value

        // Special case: if we were at bottom, stay at bottom
        if (wasAtBottom) {
            const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
            if (Number.isFinite(maxScrollTop) && maxScrollTop >= 0) {
                container.scrollTop = maxScrollTop
                scrollTop.value = maxScrollTop
            }
            return results
        }

        // Find the anchor in the new positions
        const newAnchorItem = newPosArray.find(p => p.key === anchorKey)

        if (newAnchorItem) {
            const newScrollTop = newAnchorItem.top + offsetInAnchor

            // Safety checks
            if (Number.isFinite(newScrollTop) && newScrollTop >= 0) {
                // Clamp to valid range
                const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
                const clampedScrollTop = Math.min(newScrollTop, maxScrollTop)

                // Only update if there's an actual difference (avoid micro-corrections)
                if (Math.abs(clampedScrollTop - currentScrollTop) > 0.5) {
                    container.scrollTop = clampedScrollTop
                    scrollTop.value = clampedScrollTop
                }
            }
        }

        return results
    }

    /**
     * Remove an item's height from the cache (when item is removed from the list).
     *
     * @param {*} key - The item key to remove
     */
    function removeItemHeight(key) {
        heightCache.delete(key)
    }

    /**
     * Clear all heights from the cache.
     * Useful when the items array is completely replaced.
     */
    function clearHeightCache() {
        heightCache.clear()
    }

    /**
     * Get the cached height for an item, or undefined if not cached.
     *
     * @param {*} key - The item key
     * @returns {number | undefined}
     */
    function getItemHeight(key) {
        return heightCache.get(key)
    }

    /**
     * Invalidate (remove) all items in the height cache that have zero height.
     * This is used when the scroller becomes visible after being hidden,
     * as items measured while hidden (display: none) report 0 height.
     * Removing these entries allows them to be re-measured with correct values.
     */
    function invalidateZeroHeights() {
        for (const [key, height] of heightCache.entries()) {
            if (height === 0) {
                heightCache.delete(key)
            }
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // Scroll Event Handler
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Handle scroll events with RAF throttling.
     * Updates scrollTop ref which triggers recomputation of render range.
     *
     * @param {Event} event - The scroll event
     */
    function handleScroll(event) {
        // Ignore scroll events while suspended (container is detached).
        // The browser may fire a scroll event when resetting scrollTop to 0 during detach.
        if (suspended.value) return

        if (rafId !== null) {
            cancelAnimationFrame(rafId)
        }

        rafId = requestAnimationFrame(() => {
            rafId = null
            if (suspended.value) return
            const target = event?.target || containerRef.value
            if (target) {
                // When the container is hidden (e.g., parent wa-tab-panel set to
                // display:none), the browser resets scrollTop to 0 and fires a
                // scroll event. Updating scrollTop.value here would corrupt the
                // saved scroll position. Instead, auto-suspend to freeze state.
                //
                // The ResizeObserver on the container does NOT fire when a *parent*
                // element gets display:none, so updateViewportHeight(0) may never
                // be called. This check in handleScroll acts as a fallback
                // detection: if the container has no height, it's hidden.
                if (target.clientHeight === 0 && viewportHeight.value > 0) {
                    autoSuspended = true
                    suspend()
                    return
                }
                scrollTop.value = target.scrollTop
            }
        })
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // Navigation Methods
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Scroll to a specific item index.
     *
     * @param {number} index - The item index to scroll to
     * @param {Object} [options] - Scroll options
     * @param {'start' | 'center' | 'end'} [options.align='start'] - Where to position the item
     * @param {'auto' | 'smooth'} [options.behavior='auto'] - Scroll behavior
     */
    function scrollToIndex(index, options = {}) {
        const { align = 'start', behavior = 'auto' } = options
        const container = containerRef.value
        if (!container) return

        const posArray = positions.value
        if (index < 0 || index >= posArray.length) return

        const pos = posArray[index]
        let targetScrollTop

        switch (align) {
            case 'center':
                // Center the item in the viewport
                targetScrollTop = pos.top - (viewportHeight.value / 2) + (pos.height / 2)
                break
            case 'end':
                // Position the item at the bottom of the viewport
                targetScrollTop = pos.top + pos.height - viewportHeight.value
                break
            case 'start':
            default:
                // Position the item at the top of the viewport
                targetScrollTop = pos.top
                break
        }

        // Clamp to valid scroll range
        const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
        targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))

        // Mark as programmatic to avoid scroll correction during this scroll
        isProgrammaticScroll = true

        if (behavior === 'smooth') {
            container.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
            // Reset programmatic flag after smooth scroll completes (estimate)
            setTimeout(() => {
                isProgrammaticScroll = false
            }, 500)
        } else {
            container.scrollTop = targetScrollTop
            scrollTop.value = targetScrollTop
            isProgrammaticScroll = false
        }
    }

    /**
     * Scroll to the top of the list.
     *
     * @param {Object} [options] - Scroll options
     * @param {'auto' | 'smooth'} [options.behavior='auto'] - Scroll behavior
     */
    function scrollToTop(options = {}) {
        const { behavior = 'auto' } = options
        const container = containerRef.value
        if (!container) return

        isProgrammaticScroll = true

        if (behavior === 'smooth') {
            container.scrollTo({ top: 0, behavior: 'smooth' })
            setTimeout(() => {
                isProgrammaticScroll = false
            }, 500)
        } else {
            container.scrollTop = 0
            scrollTop.value = 0
            isProgrammaticScroll = false
        }
    }

    /**
     * Scroll to the bottom of the list.
     *
     * @param {Object} [options] - Scroll options
     * @param {'auto' | 'smooth'} [options.behavior='auto'] - Scroll behavior
     */
    function scrollToBottom(options = {}) {
        const { behavior = 'auto' } = options
        const container = containerRef.value
        if (!container) return

        const targetScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)

        isProgrammaticScroll = true

        if (behavior === 'smooth') {
            container.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
            setTimeout(() => {
                isProgrammaticScroll = false
            }, 500)
        } else {
            container.scrollTop = targetScrollTop
            scrollTop.value = targetScrollTop
            isProgrammaticScroll = false
        }
    }

    /**
     * Get the current scroll state of the container.
     *
     * @returns {{ scrollTop: number, scrollHeight: number, clientHeight: number }}
     */
    function getScrollState() {
        const container = containerRef.value
        if (!container) {
            return { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }
        }
        return {
            scrollTop: container.scrollTop,
            scrollHeight: container.scrollHeight,
            clientHeight: container.clientHeight,
        }
    }

    /**
     * Check if the scroller is currently at or near the bottom.
     *
     * @param {number} [threshold=5] - Pixel threshold for "at bottom" detection
     * @returns {boolean}
     */
    function isAtBottom(threshold = 5) {
        const container = containerRef.value
        if (!container) return true
        // When the container is hidden (display:none), all dimensions are 0.
        // Return false to avoid false positives that would trigger scroll-to-bottom.
        if (container.clientHeight === 0) return false
        const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
        return distanceFromBottom <= threshold
    }

    /**
     * Enable "stick to bottom" mode.
     * While enabled, any height changes will automatically scroll to bottom.
     * Use this during initial scroll-to-bottom operations.
     */
    function enableStickToBottom() {
        stickToBottom = true
    }

    /**
     * Disable "stick to bottom" mode.
     * Call this when the scroll position has stabilized.
     */
    function disableStickToBottom() {
        stickToBottom = false
    }

    /**
     * Check if the scroller is currently at or near the top.
     *
     * @param {number} [threshold=5] - Pixel threshold for "at top" detection
     * @returns {boolean}
     */
    function isAtTop(threshold = 5) {
        return scrollTop.value <= threshold
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // Viewport Height Update
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * Update the viewport height. Called by the component's ResizeObserver.
     *
     * Handles three scenarios:
     * 1. Normal resize: update viewportHeight to trigger renderRange recomputation.
     * 2. Container hidden (height=0): auto-suspend the scroller to preserve scroll
     *    state before the browser's scroll event corrupts scrollTop.
     * 3. Container visible again (height>0 while suspended): auto-resume or complete
     *    a deferred KeepAlive resume.
     *
     * @param {number} height - The new viewport height
     */
    function updateViewportHeight(height) {
        // While suspended, ignore all height changes — UNLESS we need to
        // complete a deferred resume (container was hidden when resume() was called)
        // or auto-resume (container was hidden by tab switch and is now visible again).
        if (suspended.value) {
            if (height > 0) {
                const container = containerRef.value
                if (!container) return

                if (pendingResume || autoSuspended) {
                    // Container is visible again — perform the deferred/auto resume.
                    performResume(container)
                }
            }
            return
        }

        // Auto-suspend: container just became hidden (e.g., wa-tab-panel display:none).
        // Save the scroll anchor NOW while scrollTop.value is still valid (the
        // ResizeObserver fires synchronously on layout change, before the browser's
        // scroll event resets scrollTop to 0 via handleScroll/RAF).
        // Only auto-suspend if the container WAS visible (viewportHeight > 0).
        // This avoids spurious suspend/resume cycles during initial mount of
        // hidden tab panels (Files, Git, Terminal) that start with height=0.
        if (height === 0 && viewportHeight.value > 0) {
            autoSuspended = true
            suspend()
            return
        }

        viewportHeight.value = height
    }

    /**
     * Suspend the scroller: freeze renderRange and save the scroll anchor.
     *
     * Called in two situations:
     * 1. KeepAlive deactivation (via onDeactivated) — before the browser detaches
     *    the DOM and resets scrollTop to 0.
     * 2. Auto-suspension (via updateViewportHeight) — when the container becomes
     *    hidden (e.g., wa-tab-panel switching to display:none). The ResizeObserver
     *    fires synchronously on layout change, BEFORE the browser's scroll event
     *    resets scrollTop, so we can still capture the correct scroll position.
     *
     * If already suspended (e.g., auto-suspended from tab switch), a subsequent
     * KeepAlive suspend clears the autoSuspended flag so that a manual resume()
     * is required instead of an automatic resume from updateViewportHeight().
     */
    function suspend() {
        if (suspended.value) {
            // Already suspended (e.g., auto-suspended from tab switch).
            // Clear autoSuspended so that a KeepAlive resume() is required
            // instead of an automatic resume from updateViewportHeight().
            autoSuspended = false
            return
        }

        const posArray = positions.value
        if (posArray.length > 0) {
            const currentScrollTop = scrollTop.value
            const anchorIndex = findIndexAtPosition(currentScrollTop)
            const anchorItem = posArray[anchorIndex]
            if (anchorItem) {
                savedAnchor = {
                    index: anchorIndex,
                    key: anchorItem.key,
                    offset: currentScrollTop - anchorItem.top,
                }
            }
        }

        suspended.value = true
        // Reset stickToBottom which may have been left true if a
        // scrollToBottomUntilStable was interrupted by deactivation.
        stickToBottom = false
    }

    /**
     * Resume the scroller (called after KeepAlive reattaches the DOM).
     *
     * Restores the scroll position from the saved anchor: finds the anchor
     * item in the (possibly recomputed) positions array and scrolls to
     * anchorTop + offset.
     *
     * If the container is not yet visible (e.g., inside a hidden wa-tab-panel),
     * the resume is deferred: the scroller stays suspended and savedAnchor is
     * preserved. The actual resume will happen when updateViewportHeight()
     * receives a positive height (triggered by the ResizeObserver when the
     * tab panel becomes visible).
     */
    function resume() {
        if (!suspended.value) return

        const container = containerRef.value
        if (!container) return

        // If the container is hidden (e.g., inactive wa-tab-panel with display:none),
        // defer the resume until the container becomes visible. Setting scrollTop
        // on a hidden container is silently ignored by the browser, so we must wait.
        if (container.clientHeight === 0) {
            pendingResume = true
            return
        }

        // Container is visible — perform the actual resume
        performResume(container)
    }

    /**
     * Perform the actual resume: unsuspend, update viewport, restore scroll.
     * Called either directly from resume() when the container is visible,
     * or deferred from updateViewportHeight() when the container becomes visible.
     *
     * @param {HTMLElement} container - The scroller container element
     */
    function performResume(container) {
        pendingResume = false
        autoSuspended = false
        suspended.value = false

        // Update viewportHeight from the now-visible container
        viewportHeight.value = container.clientHeight

        if (savedAnchor) {
            const posArray = positions.value

            // Try to find the anchor by key first (more reliable if items shifted)
            let anchorItem = posArray.find(p => p.key === savedAnchor.key)

            // Fallback to index if key not found (item may have been removed)
            if (!anchorItem && savedAnchor.index < posArray.length) {
                anchorItem = posArray[savedAnchor.index]
            }

            if (anchorItem) {
                const targetScrollTop = anchorItem.top + savedAnchor.offset
                const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
                const clampedScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))

                container.scrollTop = clampedScrollTop
                scrollTop.value = clampedScrollTop
            }

            savedAnchor = null
        }
    }

    /**
     * Get the current scroll anchor (the first visible item and pixel offset into it).
     * Returns null if there are no items.
     *
     * This is used by parent components (like SessionItemsList) to save scroll state
     * before the VirtualScroller might be destroyed and recreated (e.g., by v-if/v-else-if
     * conditions inside a KeepAlive boundary).
     *
     * @returns {{ index: number, key: any, offset: number } | null}
     */
    function getScrollAnchor() {
        const posArray = positions.value
        if (posArray.length === 0) return null

        const currentScrollTop = scrollTop.value
        const anchorIndex = findIndexAtPosition(currentScrollTop)
        const anchorItem = posArray[anchorIndex]
        if (!anchorItem) return null

        return {
            index: anchorIndex,
            key: anchorItem.key,
            offset: currentScrollTop - anchorItem.top,
        }
    }

    /**
     * Scroll to a previously saved anchor position.
     * The anchor is resolved by key first, then by index as fallback.
     *
     * This is used by parent components to restore scroll state after the
     * VirtualScroller has been recreated.
     *
     * @param {{ index: number, key: any, offset: number }} anchor - The anchor to restore
     */
    function scrollToAnchor(anchor) {
        if (!anchor) return

        const container = containerRef.value
        if (!container) return

        const posArray = positions.value

        // Try to find the anchor by key first (more reliable if items shifted)
        let anchorItem = posArray.find(p => p.key === anchor.key)

        // Fallback to index if key not found (item may have been removed)
        if (!anchorItem && anchor.index < posArray.length) {
            anchorItem = posArray[anchor.index]
        }

        if (anchorItem) {
            const targetScrollTop = anchorItem.top + anchor.offset
            const maxScrollTop = Math.max(0, totalHeight.value - viewportHeight.value)
            const clampedScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))

            container.scrollTop = clampedScrollTop
            scrollTop.value = clampedScrollTop
        }
    }

    /**
     * Sync the scroll position from the container (useful after mount or resize).
     */
    function syncScrollPosition() {
        const container = containerRef.value
        if (container) {
            scrollTop.value = container.scrollTop
            viewportHeight.value = container.clientHeight
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // Cleanup
    // ═══════════════════════════════════════════════════════════════════════════

    onUnmounted(() => {
        if (rafId !== null) {
            cancelAnimationFrame(rafId)
            rafId = null
        }
    })

    // ═══════════════════════════════════════════════════════════════════════════
    // Watch for items array replacement
    // ═══════════════════════════════════════════════════════════════════════════

    /**
     * When the items array reference changes completely, we need to clean up
     * height cache entries for items that no longer exist.
     * However, we preserve heights for items that still exist (same key).
     */
    watch(
        () => items.value,
        (newItems) => {
            // Build a set of current item keys
            const currentKeys = new Set(newItems.map(item => itemKey(item)))

            // Remove cached heights for items that no longer exist
            for (const key of heightCache.keys()) {
                if (!currentKeys.has(key)) {
                    heightCache.delete(key)
                }
            }
        },
        { flush: 'post' }
    )

    // ═══════════════════════════════════════════════════════════════════════════
    // Public API
    // ═══════════════════════════════════════════════════════════════════════════

    return {
        // State (readonly refs/computed)
        positions,
        totalHeight,
        renderRange,
        visibleRange,
        spacerBeforeHeight,
        spacerAfterHeight,
        scrollTop,
        viewportHeight,
        suspended,

        // Height management
        updateItemHeight,
        batchUpdateItemHeights,
        removeItemHeight,
        clearHeightCache,
        getItemHeight,
        invalidateZeroHeights,

        // Event handler
        handleScroll,

        // Navigation
        scrollToIndex,
        scrollToTop,
        scrollToBottom,
        getScrollState,
        isAtBottom,
        isAtTop,

        // Stick to bottom mode (for initial scroll operations)
        enableStickToBottom,
        disableStickToBottom,

        // Viewport management
        updateViewportHeight,
        syncScrollPosition,

        // KeepAlive support
        suspend,
        resume,
        getScrollAnchor,
        scrollToAnchor,
    }
}
