<script lang="ts">
	// DOM-windowing for the feed. Only the cards/rows inside (and a small overscan
	// around) the viewport are kept in the DOM — at ~1000 uploads this is the
	// difference between ~1000 heavy cards (each with its own image, HeartBurst,
	// long-press + double-tap listeners) and ~10-15.
	//
	// We use TanStack's *window* virtualizer (not an inner scroll container) on
	// purpose: the feed scrolls the document, and the page's sticky header,
	// pull-to-refresh, infinite-scroll sentinel and bottom nav all rely on that.
	// The window virtualizer measures against `window` scroll, so every one of
	// those keeps working untouched.
	//
	// Two layouts share one mechanism:
	//   list — one full-width FeedListCard per row, heights *measured* (captions
	//          make them variable). Keyed by upload id + `anchorTo:'start'` so an
	//          SSE prepend (new upload) doesn't yank a scrolled-down reader.
	//   grid — three square tiles per row, uniform height; still measured to shrug
	//          off sub-pixel drift over hundreds of rows.
	import { createWindowVirtualizer } from '@tanstack/svelte-virtual';
	import { untrack } from 'svelte';
	import { get } from 'svelte/store';
	import { browser } from '$app/environment';
	import type { FeedUpload } from '$lib/types';
	import { dataMode } from '$lib/data-mode-store';
	import { longpress } from '$lib/actions/longpress';
	import FeedListCard from './FeedListCard.svelte';

	interface Props {
		uploads: FeedUpload[];
		mode: 'list' | 'grid';
		myUserId: string | null;
		onlike: (id: string) => void;
		oncomment: (id: string) => void;
		onselect: (upload: FeedUpload) => void;
		oncontextmenu?: (upload: FeedUpload) => void;
	}

	let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();

	const COLS = 3;
	const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
	const LIST_ESTIMATE = 700; // px — first-paint guess; real heights replace it on measure.

	let listEl = $state<HTMLDivElement>();
	let containerWidth = $state(0);

	// Distance from the top of the document to the list container, i.e. the height
	// of everything above it (sticky header + chips/search). Items' `start` values
	// are document-absolute (they include this margin), so we feed it back as
	// `scrollMargin` and subtract it again when positioning. Read live from layout
	// (getBoundingClientRect + scrollY is scroll-independent) so it self-corrects
	// when the header grows — e.g. grid filter chips appear.
	let scrollMargin = $state(0);

	const rowCount = $derived(mode === 'grid' ? Math.ceil(uploads.length / COLS) : uploads.length);
	const colWidth = $derived(
		containerWidth > 0 ? (containerWidth - (COLS - 1) * GRID_GAP) / COLS : 120
	);

	function isVideo(mime: string): boolean {
		return mime.startsWith('video/');
	}

	// Grid tiles always use the small thumbnail — full media is one tap away in the
	// lightbox where the data-mode picker decides for real.
	function tileUrl(upload: FeedUpload): string {
		if (upload.thumbnail_url) return upload.thumbnail_url;
		if (upload.preview_url) return upload.preview_url;
		return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
	}

	// STABLE option callbacks — created once, never swapped. They read the live
	// reactive values (`colWidth`, `uploads`, `mode`) at *call* time, so they stay
	// current without needing a new function reference. This matters: `getItemKey`
	// is a dependency of virtual-core's measurements memo, so handing it a fresh
	// closure on every render would force an O(n) recompute. List keys by upload id
	// (so an SSE prepend keeps measured heights attached to the right card); grid
	// keys by row index (uniform rows, nothing to preserve).
	const estimateSize = (_i: number) => (mode === 'grid' ? colWidth : LIST_ESTIMATE);
	const getItemKey = (i: number) => (mode === 'list' ? (uploads[i]?.id ?? i) : i);

	// `mode` is fixed for the lifetime of an instance (list and grid are rendered as
	// separate <VirtualFeed> elements in the parent's {#if} branches, so toggling
	// remounts rather than mutating this prop). Snapshot it without a reactive read
	// to set the layout-constant options once.
	const isGrid = untrack(() => mode === 'grid');

	// Created with static placeholder count/margin; `applyOptions` pushes those.
	const virtualizer = createWindowVirtualizer<HTMLDivElement>({
		count: 0,
		estimateSize,
		getItemKey,
		overscan: isGrid ? 4 : 3,
		gap: isGrid ? GRID_GAP : 0,
		anchorTo: 'start',
		scrollMargin: 0
	});

	// Only `count` (load-more / prepend / delete) and `scrollMargin` (header height
	// shifts) actually need to be pushed into the virtualizer. Like/comment SSE
	// patches reassign `uploads` without changing its length — those must NOT
	// trigger a setOptions (and its getBoundingClientRect reflow + store churn); the
	// affected card re-renders through normal reactivity instead. We read the raw
	// instance via `get()` rather than `$virtualizer` so writing never re-triggers
	// this effect (that would loop).
	let appliedCount = -1;
	let appliedMargin = Number.NaN;
	let appliedWidth = Number.NaN;

	function applyOptions() {
		const count = rowCount;
		const width = containerWidth;
		const margin = listEl ? listEl.getBoundingClientRect().top + window.scrollY : 0;
		scrollMargin = margin; // drives the template transforms
		// A width change (rotation, or the first 0→real clientWidth landing) changes
		// every card's / tile-row's real height, so the heights cached from the old
		// width are stale even when count and margin are unchanged. Invalidate the
		// measurement cache so getTotalSize + positions recompute (rendered rows then
		// re-measure immediately via their ResizeObserver) instead of trusting them.
		const widthChanged = width > 0 && Math.abs(width - appliedWidth) > 0.5;
		if (count === appliedCount && Math.abs(margin - appliedMargin) < 0.5 && !widthChanged) return;
		appliedCount = count;
		appliedMargin = margin;
		appliedWidth = width;
		get(virtualizer).setOptions({ count, scrollMargin: margin });
		if (widthChanged) get(virtualizer).measure();
	}

	// Re-apply when the row count or container width changes (load-more, prepend,
	// delete, filter, rotate). `containerWidth`/`rowCount` are the only tracked
	// reads, so a length-stable like/comment patch doesn't re-run this.
	$effect(() => {
		void rowCount;
		void containerWidth;
		applyOptions();
	});

	// Header offset can also shift on resize without a count change (orientation,
	// on-screen keyboard, font scaling).
	$effect(() => {
		if (!browser) return;
		const onResize = () => applyOptions();
		window.addEventListener('resize', onResize);
		return () => window.removeEventListener('resize', onResize);
	});

	// Hand each rendered row to the virtualizer's ResizeObserver (it reads the row's
	// `data-index` and caches the real height by item key). On `destroy` we call
	// `measureElement(null)`, which sweeps now-disconnected nodes out of the
	// internal cache + ResizeObserver — without it, every card that scrolls out of
	// the window stays observed and retained, defeating the point of virtualizing.
	function measure(node: HTMLDivElement) {
		get(virtualizer).measureElement(node);
		return {
			update() {
				get(virtualizer).measureElement(node);
			},
			destroy() {
				get(virtualizer).measureElement(null);
			}
		};
	}
</script>

<div bind:this={listEl} bind:clientWidth={containerWidth} class="w-full">
	{#if browser}
		<div style="position: relative; width: 100%; height: {$virtualizer.getTotalSize()}px;">
			{#each $virtualizer.getVirtualItems() as item (item.key)}
				{#if mode === 'list'}
					{@const upload = uploads[item.index]}
					<div
						data-index={item.index}
						use:measure
						style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
							scrollMargin}px);"
					>
						{#if upload}
							<FeedListCard
								{upload}
								isOwn={upload.user_id === myUserId}
								{onlike}
								{oncomment}
								{onselect}
								{oncontextmenu}
							/>
						{/if}
					</div>
				{:else}
					<div
						data-index={item.index}
						use:measure
						style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
							scrollMargin}px);"
					>
						<div class="grid grid-cols-3 gap-0.5">
							{#each uploads.slice(item.index * COLS, item.index * COLS + COLS) as upload (upload.id)}
								<!-- Tile — mirrors the markup the old FeedGrid used. -->
								<div
									class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
									use:longpress={{ duration: 500 }}
									onlongpress={() => oncontextmenu?.(upload)}
								>
									<button
										onclick={() => onselect(upload)}
										class="block h-full w-full"
										aria-label="Upload anzeigen"
									>
										{#if isVideo(upload.mime_type)}
											<div class="flex h-full items-center justify-center bg-gray-800">
												{#if tileUrl(upload)}
													<img
														src={tileUrl(upload)}
														alt=""
														class="h-full w-full object-cover"
														loading="lazy"
														decoding="async"
													/>
												{/if}
												<div class="absolute inset-0 flex items-center justify-center">
													<svg
														class="h-10 w-10 text-white/80"
														fill="currentColor"
														viewBox="0 0 24 24"
													>
														<path d="M8 5v14l11-7z" />
													</svg>
												</div>
											</div>
										{:else if tileUrl(upload)}
											<img
												src={tileUrl(upload)}
												alt=""
												class="h-full w-full object-cover"
												loading="lazy"
												decoding="async"
											/>
										{:else}
											<div class="flex h-full items-center justify-center text-gray-400">
												<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
													<path
														stroke-linecap="round"
														stroke-linejoin="round"
														stroke-width="1.5"
														d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
													/>
												</svg>
											</div>
										{/if}
									</button>

									<div
										class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2"
									>
										<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
										<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
											<button
												class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
												onclick={(e) => {
													e.stopPropagation();
													onlike(upload.id);
												}}
												aria-pressed={upload.liked_by_me}
												aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
											>
												<svg
													class="h-4 w-4 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}"
													fill="none"
													viewBox="0 0 24 24"
													stroke="currentColor"
												>
													<path
														stroke-linecap="round"
														stroke-linejoin="round"
														stroke-width="2"
														d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
													/>
												</svg>
												{upload.like_count}
											</button>
											<button
												class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
												onclick={(e) => {
													e.stopPropagation();
													oncomment(upload.id);
												}}
												aria-label="Kommentare anzeigen"
											>
												<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
													<path
														stroke-linecap="round"
														stroke-linejoin="round"
														stroke-width="2"
														d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
													/>
												</svg>
												{upload.comment_count}
											</button>
										</div>
									</div>
								</div>
							{/each}
						</div>
					</div>
				{/if}
			{/each}
		</div>
	{/if}
</div>
