<!--
    Component: VirtualList

    High-performance virtualized list for large datasets with:
    - Virtual scrolling (only renders visible items + overscan)
    - Keyboard navigation (ArrowUp/Down, Home, End)
    - Fixed or dynamic item heights
    - ARIA listbox/option pattern with single tab stop
    - Native browser scroll
-->
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';

interface Props {
    /**
     * Array of items to render in the virtual list.
     *
     * @template T - The type of items in the list
     */
    items: T[];
    /**
     * Total number of items (including not-yet-loaded items for pagination).
     * If not provided, defaults to items.length.
     *
     * Use this when implementing pagination to ensure the scrollbar
     * reflects the total count of items, not just the loaded ones.
     *
     * @example
     * ```ts
     * // Pagination scenario: 1920 total fonts, but only 50 loaded
     * <VirtualList items={loadedFonts} total={1920}>
     * ```
     */
    total?: number;
    /**
     * Height for each item, either as a fixed number
     * or a function that returns height per index.
     * @default 80
     */
    itemHeight?: number | ((index: number) => number);
    /**
     * Optional overscan value for the virtual list.
     * @default 5
     */
    overscan?: number;
    /**
     * Optional CSS class string for styling the container
     * (follows shadcn convention for className prop)
     */
    class?: string;
    /**
     * An optional callback that will be called for each new set of loaded items
     * @param items - Loaded items
     */
    onVisibleItemsChange?: (items: T[]) => void;
    /**
     * An optional callback that will be called when user scrolls near the end of the list.
     * Useful for triggering auto-pagination.
     *
     * The callback receives the index of the last visible item. You can use this
     * to determine if you should load more data.
     *
     * @example
     * ```ts
     * onNearBottom={(lastVisibleIndex) => {
     *     const itemsRemaining = total - lastVisibleIndex;
     *     if (itemsRemaining < 5 && hasMore && !isFetching) {
     *         loadMore();
     *     }
     * }}
     * ```
     */
    onNearBottom?: (lastVisibleIndex: number) => void;
    /**
     * Snippet for rendering individual list items.
     *
     * The snippet receives an object containing:
     * - `item`: The item from the items array (type T)
     * - `index`: The current item's index in the array
     *
     * This pattern provides type safety and flexibility for
     * rendering different item types without prop drilling.
     *
     * @template T - The type of items in the list
     */
    /**
     * Snippet for rendering individual list items.
     *
     * The snippet receives an object containing:
     * - `item`: The item from the items array (type T)
     * - `index`: The current item's index in the array
     *
     * This pattern provides type safety and flexibility for
     * rendering different item types without prop drilling.
     *
     * @template T - The type of items in the list
     */
    children: Snippet<
        [
            {
                item: T;
                index: number;
                isFullyVisible: boolean;
                isPartiallyVisible: boolean;
                proximity: number;
            },
        ]
    >;
    /**
     * Whether to use the window as the scroll container.
     * @default false
     */
    useWindowScroll?: boolean;
    /**
     * Flag to show loading state
     */
    isLoading?: boolean;
}

let {
    items,
    total = items.length,
    itemHeight = 80,
    overscan = 5,
    class: className,
    onVisibleItemsChange,
    onNearBottom,
    children,
    useWindowScroll = false,
    isLoading = false,
}: Props = $props();

// Reference to the scroll container element for attaching the virtualizer
let viewportRef = $state<HTMLElement | null>(null);

// Use items.length for count to keep existing item positions stable
// But calculate a separate totalSize for scrollbar that accounts for unloaded items
const virtualizer = createVirtualizer(() => ({
    // Only virtualize loaded items - this keeps positions stable when new items load
    count: items.length,
    data: items,
    estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
    overscan,
    useWindowScroll,
}));

// Calculate total size including unloaded items for proper scrollbar sizing
// Use estimateSize() for items that haven't been loaded yet
const estimatedTotalSize = $derived.by(() => {
    if (total === items.length) {
        // No unloaded items, use virtualizer's totalSize
        return virtualizer.totalSize;
    }

    // Start with the virtualized (loaded) items size
    const loadedSize = virtualizer.totalSize;

    // Add estimated size for unloaded items
    const unloadedCount = total - items.length;
    if (unloadedCount <= 0) return loadedSize;

    // Estimate the size of unloaded items
    // Get the average size of loaded items, or use the estimateSize function
    const estimateFn = typeof itemHeight === 'function' ? itemHeight : () => itemHeight;

    // Use estimateSize for unloaded items (index from items.length to total - 1)
    let unloadedSize = 0;
    for (let i = items.length; i < total; i++) {
        unloadedSize += estimateFn(i);
    }

    return loadedSize + unloadedSize;
});

// Attach virtualizer.container action to the viewport when it becomes available
$effect(() => {
    if (viewportRef) {
        const { destroy } = virtualizer.container(viewportRef);
        return destroy;
    }
});

const throttledVisibleChange = throttle((visibleItems: T[]) => {
    onVisibleItemsChange?.(visibleItems);
}, 150); // 150ms throttle

const throttledNearBottom = throttle((lastVisibleIndex: number) => {
    onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce

$effect(() => {
    const visibleItems = virtualizer.items.map(item => items[item.index]);
    throttledVisibleChange(visibleItems);
});

$effect(() => {
    // Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
    // Only trigger if container has sufficient height to avoid false positives
    if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
        const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
        // Compare against loaded items length, not total
        const itemsRemaining = items.length - lastVisibleItem.index;

        if (itemsRemaining <= 5) {
            throttledNearBottom(lastVisibleItem.index);
        }
    }
});
</script>

{#if useWindowScroll}
    <div class={cn('relative w-full', className)} bind:this={viewportRef}>
        <div style:height="{estimatedTotalSize}px" class="relative w-full">
            {#each virtualizer.items as item (item.key)}
                <div
                    use:virtualizer.measureElement
                    data-index={item.index}
                    class="absolute top-0 left-0 w-full will-change-transform"
                    style:transform="translateY({item.start}px)"
                    data-lenis-prevent
                >
                    {#if item.index < items.length}
                        {@render children({
    // TODO: Fix indentation rule for this case
    item: items[item.index],
    index: item.index,
    isFullyVisible: item.isFullyVisible,
    isPartiallyVisible: item.isPartiallyVisible,
    proximity: item.proximity,
})}
                    {/if}
                </div>
            {/each}
        </div>
    </div>
{:else}
    <div
        bind:this={viewportRef}
        class={cn(
            'relative overflow-y-auto overflow-x-hidden',
            'rounded-md bg-background',
            'w-full min-h-[200px]',
            className,
        )}
    >
        <div style:height="{estimatedTotalSize}px" class="relative w-full">
            {#each virtualizer.items as item (item.key)}
                <div
                    use:virtualizer.measureElement
                    data-index={item.index}
                    class="absolute top-0 left-0 w-full will-change-transform"
                    style:transform="translateY({item.start}px)"
                >
                    {#if item.index < items.length}
                        {@render children({
    // TODO: Fix indentation rule for this case
    item: items[item.index],
    index: item.index,
    isFullyVisible: item.isFullyVisible,
    isPartiallyVisible: item.isPartiallyVisible,
    proximity: item.proximity,
})}
                    {/if}
                </div>
            {/each}
        </div>
    </div>
{/if}
