<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, pickMediaUrl } from '$lib/data-mode-store';
	import { commentsEnabled } from '$lib/event-config-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 prefer the SMALLEST derivative — three across, so a thumbnail is plenty and
	// the full file is one tap away in the lightbox.
	//
	// Everything past that preference defers to `pickMediaUrl`, the centralised rule the card
	// and the lightbox already follow (saver → preview → thumbnail → original). This used to
	// return '' when no derivative existed yet, which rendered the grey "broken image" tile
	// for every upload still in the compression queue. Because the feed is newest-first, that
	// was precisely the top of the grid during a burst — the photos people had just taken —
	// while the same items opened fine in the lightbox, which has always had the fallback.
	// An upload with no derivatives is a TRANSIENT state (SSE `upload-processed` swaps in the
	// preview as soon as the worker finishes), so the original is only ever fetched for the
	// short window before compression catches up.
	function tileUrl(upload: FeedUpload): string {
		// VIDEOS: a poster image or nothing. `pickMediaUrl` is deliberately mime-agnostic and
		// bottoms out at `/original` — for a video that is the raw MP4, and this is an <img>.
		// Videos also never get a `preview_url` (compression.rs gives them a thumbnail only,
		// and only when a poster frame was actually extracted), so falling through would put a
		// multi-hundred-MB file behind three broken-image tiles per row. Returning '' keeps the
		// `{#if tileUrl(...)}` guard meaningful and leaves the play icon on its own. This is the
		// same carve-out LightboxModal documents for the same reason.
		if (isVideo(upload.mime_type)) return upload.thumbnail_url ?? '';
		if ($dataMode === 'saver' && upload.thumbnail_url) return upload.thumbnail_url;
		return pickMediaUrl($dataMode, upload);
	}

	// ── Tile media fallback ──────────────────────────────────────────────────────────
	//
	// A tile whose <img> 404s used to render as an empty grey box with `alt=""` — no icon,
	// no message, nothing to tap. And it is not an exotic case: `tileUrl` bottoms out at
	// `/original` while an upload is still compressing, which in a newest-first grid is the
	// top-left corner during every burst. That also makes the cause usually TRANSIENT, so
	// one delayed retry recovers most of them; the nonce is required because the browser
	// would otherwise replay its cached failure instead of re-requesting.
	//
	// Keyed by upload id rather than per-<img>, because the virtualizer recycles rows: a
	// tile that scrolls out and back must not restart the whole dance, and must not lose a
	// verdict we already reached. The recorded `url` is what makes that safe to keep — an
	// `upload-processed` that swaps the original for a real preview is a DIFFERENT url and
	// gets a clean attempt.
	const MEDIA_RETRY_MS = 4000;
	type TileError = { url: string; phase: 'retrying' | 'failed'; nonce: number };
	let tileErrors = $state<Record<string, TileError>>({});

	function tileSrc(upload: FeedUpload): string {
		const base = tileUrl(upload);
		if (!base) return '';
		const e = tileErrors[upload.id];
		if (!e || e.url !== base) return base;
		if (e.phase === 'failed') return '';
		return e.nonce ? `${base}${base.includes('?') ? '&' : '?'}r=${e.nonce}` : base;
	}

	function handleTileError(upload: FeedUpload) {
		const base = tileUrl(upload);
		if (!base) return;
		const e = tileErrors[upload.id];
		if (e && e.url === base) {
			// The retry failed too — show the placeholder rather than a blank tile.
			tileErrors[upload.id] = { ...e, phase: 'failed' };
			return;
		}
		tileErrors[upload.id] = { url: base, phase: 'retrying', nonce: 0 };
		setTimeout(() => {
			const cur = tileErrors[upload.id];
			if (cur && cur.url === base && cur.phase === 'retrying') {
				tileErrors[upload.id] = { ...cur, nonce: Date.now() };
			}
		}, MEDIA_RETRY_MS);
	}

	// 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 tileSrc(upload)}
													<img
														src={tileSrc(upload)}
														alt=""
														class="h-full w-full object-cover"
														loading="lazy"
														decoding="async"
														onerror={() => handleTileError(upload)}
													/>
												{/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 tileSrc(upload)}
											<img
												src={tileSrc(upload)}
												alt=""
												class="h-full w-full object-cover"
												loading="lazy"
												decoding="async"
												onerror={() => handleTileError(upload)}
											/>
										{: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>
											{#if $commentsEnabled}
												<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>
											{/if}
										</div>
									</div>
								</div>
							{/each}
						</div>
					</div>
				{/if}
			{/each}
		</div>
	{/if}
</div>
