<!--
    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;
    /**
     * Number of columns for grid layout.
     * @default 1
     */
    columns?: number;
    /**
     * Gap between items in pixels.
     * @default 0
     */
    gap?: number;
    /**
     * 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,
    columns = 1,
    gap = 0,
}: Props = $props();

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

// Calculate row-based counts for grid layout
const rowCount = $derived(Math.ceil(items.length / columns));
const totalRows = $derived(Math.ceil(total / columns));

// 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: rowCount,
    data: items,
    estimateSize: typeof itemHeight === 'function'
        ? (index: number) => itemHeight(index) + gap
        : () => itemHeight + gap,
    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 (totalRows === rowCount) {
        // No unloaded items, use virtualizer's totalSize
        return virtualizer.totalSize;
    }

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

    // Add estimated size for unloaded rows
    const unloadedRows = totalRows - rowCount;
    if (unloadedRows <= 0) return loadedSize;

    // Estimate the size of unloaded rows
    const estimateFn = typeof itemHeight === 'function'
        ? (index: number) => itemHeight(index * columns) + gap
        : () => itemHeight + gap;

    // Use estimateSize for unloaded rows (index from rowCount to totalRows - 1)
    let unloadedSize = 0;
    for (let i = rowCount; i < totalRows; 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

// Calculate top/bottom padding for spacer elements
const topPad = $derived(
    virtualizer.items.length > 0 ? virtualizer.items[0].start - gap : 0,
);
const botPad = $derived(
    virtualizer.items.length > 0
        ? Math.max(
            0,
            estimatedTotalSize
                - (virtualizer.items[virtualizer.items.length - 1].end + gap),
        )
        : estimatedTotalSize,
);

$effect(() => {
    // Expand row indices to item indices
    const visibleItemIndices: number[] = [];
    for (const row of virtualizer.items) {
        const startItemIndex = row.index * columns;
        const endItemIndex = Math.min(startItemIndex + columns, items.length);
        for (let i = startItemIndex; i < endItemIndex; i++) {
            visibleItemIndices.push(i);
        }
    }
    const visibleItems = visibleItemIndices.map(index => items[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 lastVisibleRow = virtualizer.items[virtualizer.items.length - 1];
        // Convert row index to last item index in that row
        const lastVisibleItemIndex = Math.min(
            (lastVisibleRow.index + 1) * columns - 1,
            items.length - 1,
        );
        // Compare against loaded items length, not total
        const itemsRemaining = items.length - lastVisibleItemIndex;

        if (itemsRemaining <= 5) {
            throttledNearBottom(lastVisibleItemIndex);
        }
    }
});
</script>

{#snippet content()}
    <div
        style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
        class="grid w-full"
    >
        <!-- Top spacer for padding-based virtualization -->
        <div
            style:grid-column="1 / -1"
            style:height="{topPad}px"
            class="shrink-0"
        >
        </div>

        {#each virtualizer.items as row (row.key)}
            {#if row.index < rowCount}
                {@const startItemIndex = row.index * columns}
                {@const endItemIndex = Math.min(startItemIndex + columns, items.length)}
                {#each Array.from({ length: endItemIndex - startItemIndex }) as _, colIndex (startItemIndex + colIndex)}
                    {@const itemIndex = startItemIndex + colIndex}
                    {#if colIndex === 0}
                        <div
                            use:virtualizer.measureElement
                            data-index={row.index}
                            class="min-h-0"
                        >
                            {#if itemIndex < items.length}
                                {@render children({
    item: items[itemIndex],
    index: itemIndex,
    isFullyVisible: row.isFullyVisible,
    isPartiallyVisible: row.isPartiallyVisible,
    proximity: row.proximity,
})}
                            {/if}
                        </div>
                    {:else}
                        <div class="min-h-0">
                            {#if itemIndex < items.length}
                                {@render children({
    item: items[itemIndex],
    index: itemIndex,
    isFullyVisible: row.isFullyVisible,
    isPartiallyVisible: row.isPartiallyVisible,
    proximity: row.proximity,
})}
                            {/if}
                        </div>
                    {/if}
                {/each}
            {/if}
        {/each}

        <!-- Bottom spacer for padding-based virtualization -->
        <div
            style:grid-column="1 / -1"
            style:height="{botPad}px"
            class="shrink-0"
        >
        </div>
    </div>
{/snippet}

{#if useWindowScroll}
    <div class={cn('relative w-full', className)} bind:this={viewportRef}>
        {@render content()}
    </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,
        )}
    >
        {@render content()}
    </div>
{/if}
