<script lang="ts">
	import { onDestroy, tick } from 'svelte';
	import { beforeNavigate } from '$app/navigation';
	import type { FeedUpload } from '$lib/types';
	import { api } from '$lib/api';
	import { onSseEvent } from '$lib/sse';
	import { getUserId } from '$lib/auth';
	import { isStaff } from '$lib/role-store';
	import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
	import { doubletap } from '$lib/actions/doubletap';
	import { focusTrap } from '$lib/actions/focus-trap';
	import { scrollLock } from '$lib/actions/scroll-lock';
	import { modalInert } from '$lib/actions/modal-inert';
	import { toastError } from '$lib/toast-store';
	import { vibrate } from '$lib/haptics';
	import { commentsEnabled } from '$lib/event-config-store';
	import HeartBurst from './HeartBurst.svelte';
	import ConfirmSheet from './ConfirmSheet.svelte';

	const COMMENT_MAX = 500;

	interface CommentDto {
		id: string;
		upload_id: string;
		user_id: string;
		uploader_name: string;
		body: string;
		created_at: string;
	}

	interface Props {
		upload: FeedUpload;
		onclose: () => void;
		onlike: (id: string) => void;
		/** Whether a previous/next item exists in the caller's list. Controls the chevrons. */
		hasPrev?: boolean;
		hasNext?: boolean;
		onprev?: () => void;
		onnext?: () => void;
	}

	let {
		upload,
		onclose,
		onlike,
		hasPrev = false,
		hasNext = false,
		onprev,
		onnext
	}: Props = $props();

	// The system back gesture must close the photo, not leave the app.
	//
	// On a phone, back is how you dismiss a full-screen view — but this modal is component
	// state, not a route, so back navigated away from /feed entirely and the guest lost their
	// scroll position. Since the modal is only mounted while open, this subscription is
	// active exactly while it is open (Svelte tears it down on destroy).
	//
	// Cancelling a popstate navigation is explicitly supported: SvelteKit counteracts it with
	// `history.go`, restoring the entry we just left (see its client runtime, "if a
	// popstate-driven navigation is cancelled"). So the URL stays put and the photo closes.
	// Only `popstate` is intercepted — an in-app link or a programmatic `goto` should still
	// navigate, closing the modal with the page.
	beforeNavigate((nav) => {
		if (nav.type === 'popstate') {
			nav.cancel();
			onclose();
		}
	});

	let comments = $state<CommentDto[]>([]);
	let newComment = $state('');
	let loading = $state(false);
	let userId = getUserId();
	let heartBurst = $state(false);
	let burstTimer: ReturnType<typeof setTimeout> | null = null;

	// Videos always play the ORIGINAL. `pickMediaUrl` is mime-agnostic and compression only
	// ever produces a *thumbnail* for a video (one ffmpeg frame), so in the default saver
	// mode it hands back `/thumbnail` — a JPEG, served as image/jpeg with nosniff. Feeding
	// that to <video> is why every video failed with DEMUXER_ERROR_COULD_NOT_OPEN. There is
	// no smaller video derivative to offer, so `preload="none"` keeps saver-mode users on
	// cellular from fetching anything until they actually press play; the poster is the
	// thumbnail, which is what they saw in the feed anyway.
	// Fixed here rather than in `pickMediaUrl` because FeedListCard shares that helper and
	// legitimately wants the thumbnail for its <img>. Same rule as the diashow.
	const mediaSrc = $derived(
		isVideo(upload.mime_type)
			? `/api/v1/upload/${upload.id}/original`
			: pickMediaUrl($dataMode, upload)
	);

	// ── Prev / next ──────────────────────────────────────────────────────────────────
	//
	// Until now the only way out of a photo was to close the modal, so browsing hundreds
	// of party photos meant open-close-open-close all evening — while FEATURES.md and
	// USER_JOURNEYS §8.6/§17 both promised swipe navigation. Three affordances, because
	// each covers a different user: on-screen chevrons are the discoverable baseline (a
	// swipe nobody knows about is not a feature), arrow keys for the projector laptop,
	// swipe for the phone in one hand.
	let prevBtn = $state<HTMLButtonElement | undefined>();
	let nextBtn = $state<HTMLButtonElement | undefined>();
	let closeBtn = $state<HTMLButtonElement | undefined>();

	async function step(dir: -1 | 1) {
		if (dir === -1) {
			if (!hasPrev) return;
			onprev?.();
		} else {
			if (!hasNext) return;
			onnext?.();
		}
		// Stepping onto the first/last item removes the chevron that was just used. A
		// focused node disappearing drops focus to <body> — outside `focusTrap`'s node — so
		// the next Tab would leave for the browser chrome instead of cycling the modal.
		// Hand focus to whichever control survived.
		await tick();
		if (document.activeElement === document.body) {
			const fallback = dir === -1 ? nextBtn : prevBtn;
			(fallback ?? closeBtn)?.focus();
		}
	}

	function handleKeydown(e: KeyboardEvent) {
		if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
		// Never hijack caret movement inside the comment composer.
		const target = e.target as HTMLElement | null;
		if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
		e.preventDefault();
		void step(e.key === 'ArrowLeft' ? -1 : 1);
	}

	// Swipe. Bound to the media wrapper rather than the whole modal so it cannot fight the
	// comment list's vertical scroll, and read on touchend rather than continuously so the
	// browser keeps ownership of the gesture until we know it was horizontal.
	const SWIPE_MIN_PX = 50;
	let touchStartX = 0;
	let touchStartY = 0;
	let touchTracking = false;

	function onTouchStart(e: TouchEvent) {
		// A second finger means pinch-zoom, which must not turn into a page change.
		touchTracking = e.touches.length === 1;
		if (!touchTracking) return;
		touchStartX = e.touches[0].clientX;
		touchStartY = e.touches[0].clientY;
	}

	function onTouchEnd(e: TouchEvent) {
		if (!touchTracking) return;
		touchTracking = false;
		const t = e.changedTouches[0];
		if (!t) return;
		const dx = t.clientX - touchStartX;
		const dy = t.clientY - touchStartY;
		// Require a decisively HORIZONTAL gesture: the modal body scrolls vertically, so a
		// diagonal drag must not steal a photo change from someone who meant to scroll.
		if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
		void step(dx < 0 ? 1 : -1);
	}

	// ── Media fallback ───────────────────────────────────────────────────────────────
	//
	// A failed <img> used to render as an empty black box with `alt=""` — no icon, no
	// message, nothing to tap. And the failure is common rather than exotic: `pickMediaUrl`
	// falls back to `/original` whenever preview AND thumbnail are still null, i.e. for
	// everything still compressing, which in a newest-first feed is the top of the list
	// during every burst. That also makes the cause usually TRANSIENT, so one delayed retry
	// of the same URL recovers most of them; the nonce is there because the browser would
	// otherwise replay its cached failure rather than re-request.
	const MEDIA_RETRY_MS = 4000;
	let mediaRetryNonce = $state(0);
	let mediaFailed = $state(false);
	let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;

	const displaySrc = $derived(
		mediaRetryNonce
			? `${mediaSrc}${mediaSrc.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`
			: mediaSrc
	);

	// A different photo — or the same one after `upload-processed` swapped the original for
	// a real preview — deserves a clean attempt rather than inheriting the old verdict.
	$effect(() => {
		void mediaSrc;
		mediaRetryNonce = 0;
		mediaFailed = false;
	});

	function handleMediaError() {
		if (mediaRetryNonce) {
			mediaFailed = true;
			return;
		}
		if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
		mediaRetryTimer = setTimeout(() => {
			mediaRetryTimer = null;
			mediaRetryNonce = Date.now();
		}, MEDIA_RETRY_MS);
	}

	function triggerHeartBurst() {
		heartBurst = true;
		vibrate(10);
		onlike(upload.id);
		if (burstTimer) clearTimeout(burstTimer);
		burstTimer = setTimeout(() => (heartBurst = false), 700);
	}

	// Drop a comment live when it's deleted elsewhere (host moderation or the
	// author on another device), so the open panel doesn't show a ghost comment.
	const unsubCommentDeleted = onSseEvent('comment-deleted', (data) => {
		try {
			const { comment_id } = JSON.parse(data) as { comment_id: string };
			comments = comments.filter((c) => c.id !== comment_id);
		} catch {
			/* ignore */
		}
	});

	// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
	// open list matches the count. The `new-comment` broadcast carries only the count (not
	// the body), so refetch — skipped when it's for a different upload, and the dedupe
	// below makes our own just-posted optimistic comment a no-op.
	const unsubNewComment = onSseEvent('new-comment', (data) => {
		try {
			const { upload_id } = JSON.parse(data) as { upload_id: string };
			if (upload_id === upload.id) void loadComments(upload.id);
		} catch {
			/* ignore */
		}
	});

	onDestroy(() => {
		if (burstTimer) clearTimeout(burstTimer);
		if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
		unsubCommentDeleted();
		unsubNewComment();
	});

	// Only refetch when a *different* upload is shown. The feed reassigns the
	// `upload` prop object on every SSE like/comment count update; keying the
	// effect off the memoized id avoids a refetch storm that would also clobber
	// a just-posted optimistic comment.
	const uploadId = $derived(upload.id);

	$effect(() => {
		loadComments(uploadId);
	});

	async function loadComments(id: string) {
		try {
			comments = await api.get<CommentDto[]>(`/upload/${id}/comments`);
		} catch {
			// Background fetch — failure leaves the panel empty; reopening the lightbox retries.
		}
	}

	async function submitComment() {
		if (!newComment.trim()) return;
		loading = true;
		try {
			const comment = await api.post<CommentDto>(`/upload/${upload.id}/comments`, {
				body: newComment.trim()
			});
			comments = [...comments, comment];
			newComment = '';
		} catch (e) {
			toastError(e);
		} finally {
			loading = false;
		}
	}

	// Comment deletion is permanent and had NO confirmation at all — from a ~14px ✕ glyph
	// sitting a few pixels from the comment text, which a host could also use on someone
	// else's words. Deleting a POST two components away has always gone through ConfirmSheet;
	// this is the same irreversible act and now takes the same route.
	// `asHost` routes to the moderation endpoint: the guest route only ever deletes the
	// caller's OWN comment, and it refuses a banned author outright — so without it a host
	// who banned an abusive guest was left with the abuse on screen and no way to remove it,
	// since the ban itself blocks the author's own delete.
	let pendingCommentDelete = $state<{ id: string; asHost: boolean } | null>(null);

	async function confirmDeleteComment() {
		const pending = pendingCommentDelete;
		if (!pending) return;
		pendingCommentDelete = null;
		try {
			await api.delete(pending.asHost ? `/host/comment/${pending.id}` : `/comment/${pending.id}`);
			comments = comments.filter((c) => c.id !== pending.id);
		} catch (e) {
			toastError(e);
		}
	}

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

	function formatTime(iso: string): string {
		return new Date(iso).toLocaleString('de-DE', {
			day: '2-digit',
			month: '2-digit',
			hour: '2-digit',
			minute: '2-digit'
		});
	}
</script>

<svelte:window onkeydown={handleKeydown} />

<div
	class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
	role="dialog"
	aria-modal="true"
	aria-labelledby="lightbox-title"
	use:focusTrap={{ onclose }}
	use:scrollLock
	use:modalInert
>
	<!-- Backdrop — real <button> so keyboard / switch-control users get parity, matching
	     ContextSheet / ConfirmSheet / UploadSheet. The lightbox was the one overlay in the app
	     that did NOT close on an outside tap, which is why it felt broken: every other sheet
	     does. `tabindex="-1"` keeps it out of the tab order, so it adds no stop in front of the
	     photo; Escape still closes via `focusTrap` and the labelled ✕ stays the keyboard route.
	     It sits behind the content, which is `relative` for exactly that reason. -->
	<button
		type="button"
		class="absolute inset-0 cursor-default"
		onclick={onclose}
		tabindex="-1"
		aria-label="Schließen"
	></button>
	<div
		class="relative flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
	>
		<!-- Media -->
		<div class="relative bg-black">
			<button
				bind:this={closeBtn}
				onclick={onclose}
				aria-label="Schließen"
				class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
			>
				<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
					<path
						stroke-linecap="round"
						stroke-linejoin="round"
						stroke-width="2"
						d="M6 18L18 6M6 6l12 12"
					/>
				</svg>
			</button>

			<!-- Chevrons sit OUTSIDE the doubletap wrapper below, so tapping one can never be
			     read as half of a double-tap and fire a like on the photo you are leaving. -->
			{#if hasPrev}
				<button
					bind:this={prevBtn}
					type="button"
					onclick={() => void step(-1)}
					aria-label="Vorheriges Foto"
					class="absolute left-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
				>
					<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
						<path
							stroke-linecap="round"
							stroke-linejoin="round"
							stroke-width="2"
							d="M15.75 19.5 8.25 12l7.5-7.5"
						/>
					</svg>
				</button>
			{/if}
			{#if hasNext}
				<button
					bind:this={nextBtn}
					type="button"
					onclick={() => void step(1)}
					aria-label="Nächstes Foto"
					class="absolute right-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
				>
					<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
						<path
							stroke-linecap="round"
							stroke-linejoin="round"
							stroke-width="2"
							d="M8.25 4.5l7.5 7.5-7.5 7.5"
						/>
					</svg>
				</button>
			{/if}

			<!-- `role="group"` is here for the touch handlers, not for semantics: swipe is a
			     redundant shortcut for the labelled chevrons above and the arrow keys, so
			     assistive tech loses nothing by treating this as a plain grouping. -->
			<div
				class="relative"
				role="group"
				aria-label="Foto – wischen zum Blättern"
				use:doubletap
				ondoubletap={triggerHeartBurst}
				ontouchstart={onTouchStart}
				ontouchend={onTouchEnd}
				ontouchcancel={() => (touchTracking = false)}
			>
				{#if isVideo(upload.mime_type)}
					<video
						src={mediaSrc}
						controls
						preload="none"
						playsinline
						class="max-h-[60vh] w-full object-contain"
						poster={upload.thumbnail_url ?? undefined}
					></video>
				{:else if mediaFailed}
					<!-- Both attempts failed. Anything is better than the empty black box this used
					     to leave behind, which read as a broken app rather than a missing file. -->
					<div
						class="flex h-[40vh] flex-col items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
					>
						<svg class="h-12 w-12" 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>
						<p class="text-sm">Bild konnte nicht geladen werden.</p>
					</div>
				{:else}
					<img
						src={displaySrc}
						alt=""
						class="max-h-[60vh] w-full select-none object-contain"
						draggable="false"
						onerror={handleMediaError}
					/>
				{/if}

				<HeartBurst active={heartBurst} />
			</div>
		</div>

		<!-- Info + Comments -->
		<div class="flex flex-1 flex-col overflow-hidden">
			<div class="border-b border-gray-100 p-3 dark:border-gray-800">
				<div class="flex items-center justify-between gap-2">
					<!-- `min-w-0` + `truncate`: without them a long display name (guests type their
					     own) is unshrinkable and pushes the like button off the right edge. -->
					<div class="flex min-w-0 items-baseline gap-2">
						<span id="lightbox-title" class="truncate font-medium text-gray-900 dark:text-gray-100"
							>{upload.uploader_name}</span
						>
						<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
							>{formatTime(upload.created_at)}</span
						>
					</div>
					<button
						onclick={() => onlike(upload.id)}
						class="flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
							? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
							: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
					>
						<svg
							class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}"
							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>
				</div>
				{#if upload.caption}
					<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{upload.caption}</p>
				{/if}
			</div>

			<!-- Comments list + composer — hidden entirely when comments are disabled instance-wide -->
			{#if $commentsEnabled}
				<div class="flex-1 overflow-y-auto p-3">
					{#if comments.length === 0}
						<p class="text-center text-sm text-gray-400 dark:text-gray-500">
							Noch keine Kommentare.
						</p>
					{:else}
						<div class="space-y-3">
							{#each comments as comment (comment.id)}
								<div class="flex items-start gap-2">
									<div class="flex-1">
										<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
											>{comment.uploader_name}</span
										>
										<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span
										>
										<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
											{formatTime(comment.created_at)}
										</div>
									</div>
									{#if comment.user_id === userId || $isStaff}
										<!-- A trash glyph, not a ✕ (which reads as "dismiss"), and a real 44px tap
										     target: the old 14px icon sat ~4px from the comment text, so a thumb
										     aiming at the text destroyed the comment instead. -->
										<button
											type="button"
											onclick={() =>
												(pendingCommentDelete = {
													id: comment.id,
													asHost: comment.user_id !== userId
												})}
											class="-m-1.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
											aria-label={comment.user_id === userId
												? 'Kommentar löschen'
												: 'Kommentar entfernen'}
										>
											<svg
												class="h-4 w-4"
												fill="none"
												viewBox="0 0 24 24"
												stroke="currentColor"
												stroke-width="1.8"
											>
												<path
													stroke-linecap="round"
													stroke-linejoin="round"
													d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
												/>
											</svg>
										</button>
									{/if}
								</div>
							{/each}
						</div>
					{/if}
				</div>

				<!-- Comment input -->
				<form
					onsubmit={(e) => {
						e.preventDefault();
						submitComment();
					}}
					class="border-t border-gray-100 p-3 dark:border-gray-800"
				>
					<div class="flex gap-2">
						<input
							type="text"
							bind:value={newComment}
							placeholder="Kommentar schreiben..."
							maxlength={COMMENT_MAX}
							class="input flex-1"
						/>
						<button
							type="submit"
							disabled={loading || !newComment.trim()}
							class="btn btn-primary btn-sm"
						>
							Senden
						</button>
					</div>
					<div
						class="mt-1 text-right text-xs"
						class:text-gray-400={newComment.length < 450}
						class:dark:text-gray-500={newComment.length < 450}
						class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
						class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
						class:text-red-600={newComment.length >= COMMENT_MAX}
						class:dark:text-red-400={newComment.length >= COMMENT_MAX}
					>
						{newComment.length}/{COMMENT_MAX}
					</div>
				</form>
			{/if}
		</div>
	</div>
</div>

<!-- Same branded confirmation the post-delete flow uses. -->
<ConfirmSheet
	open={pendingCommentDelete !== null}
	title={pendingCommentDelete?.asHost ? 'Kommentar entfernen?' : 'Kommentar löschen?'}
	message={pendingCommentDelete?.asHost
		? 'Der Kommentar verschwindet für alle Gäste. Diese Aktion kann nicht rückgängig gemacht werden.'
		: 'Diese Aktion kann nicht rückgängig gemacht werden.'}
	confirmLabel={pendingCommentDelete?.asHost ? 'Entfernen' : 'Löschen'}
	tone="danger"
	onConfirm={confirmDeleteComment}
	onCancel={() => (pendingCommentDelete = null)}
/>
