<script lang="ts">
	import favicon from '$lib/assets/favicon.svg';
	import '../app.css';
	import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
	import { initTheme } from '$lib/theme-store';
	import { onMount, onDestroy } from 'svelte';
	import BottomNav from '$lib/components/BottomNav.svelte';
	import UploadSheet from '$lib/components/UploadSheet.svelte';
	import Toaster from '$lib/components/Toaster.svelte';
	import { showBottomNav } from '$lib/ui-store';
	import { isAuthenticated } from '$lib/auth';
	import {
		queueItems,
		isProcessing,
		loadQueue,
		rateLimitRetryAt,
		releaseResolvedParks
	} from '$lib/upload-queue';
	import { privacyNote } from '$lib/privacy-note-store';
	import { refreshQuota } from '$lib/quota-store';
	import { onSseEvent } from '$lib/sse';
	import { api, ApiError } from '$lib/api';
	import type { MeContextDto } from '$lib/types';
	import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
	import { setRole } from '$lib/role-store';
	import { loadEventConfig, commentsEnabled } from '$lib/event-config-store';
	import { isBanned } from '$lib/ban-store';

	let { children } = $props();

	let unsubs: Array<() => void> = [];

	// Slim progress bar: ratio of completed items to total, shown while processing.
	let progressPct = $derived.by(() => {
		const total = $queueItems.length;
		if (total === 0) return 0;
		const done = $queueItems.filter((i) => i.status === 'done').length;
		return Math.round((done / total) * 100);
	});

	// Rate-limit countdown, mirrored from UploadQueue.svelte. That component is mounted ONLY on
	// /upload, but the composer sends the guest straight to /feed after staging — which is where
	// they invariably are when the 429 lands. The documented "Wird in Xs automatisch fortgesetzt"
	// reassurance was therefore unreachable in the exact situation it exists for: all the guest
	// saw was a stuck badge and a queue that appeared to have died.
	let rateLimitCountdown = $state(0);

	$effect(() => {
		const retryAt = $rateLimitRetryAt;
		if (!retryAt) {
			rateLimitCountdown = 0;
			return;
		}
		rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
		const interval = setInterval(() => {
			rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
			if (rateLimitCountdown <= 0) clearInterval(interval);
		}, 1000);
		return () => clearInterval(interval);
	});

	onMount(async () => {
		// With `ssr = false` the server ships an empty shell; `app.html` paints a boot
		// spinner to cover the JS-load gap. The app has now mounted and painted, so drop it.
		document.getElementById('app-boot')?.remove();
		initAuth();
		// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
		// first paint after hydration matches the saved preference.
		initTheme();
		// Load the public event config (colour theme + comments flag) and apply the
		// palette. Applies for authed and pre-auth (/join) alike; the app.html boot script
		// already painted the cached palette, this reconciles it with the server.
		void loadEventConfig();
		// Hydrate cross-cutting stores once on boot if the user is already authenticated.
		// Page-level mounts will refresh again as needed.
		if (getToken()) {
			// Rehydrate the persisted upload queue from IndexedDB on EVERY boot, not just
			// when /upload happens to mount. `queueItems` is a module-level store that starts
			// empty, and the drain loop reads only that store — so without this, a guest whose
			// PWA is evicted mid-upload (iOS does this aggressively) and who reopens onto /feed
			// has pending blobs sitting in IndexedDB that nothing ever reads. The badge shows
			// 0, the photos never upload, and there is no symptom to act on. This is also what
			// arms the other resume paths below (`online`, `event-opened`, `feed-delta`), all
			// of which call processQueue() against the same store.
			void loadQueue();
			try {
				const ctx = await api.get<MeContextDto>('/me/context');
				privacyNote.set(ctx.privacy_note);
				// The live role — the JWT claim is frozen for the token's lifetime, so a
				// promotion/demotion only reaches the UI through this. See role-store.ts.
				setRole(ctx.role);
				eventState.set({
					uploadsLocked: ctx.uploads_locked,
					galleryReleased: ctx.gallery_released
				});
				isBanned.set(ctx.is_banned);
				// Now that we know the AUTHORITATIVE state, release anything the queue parked
				// waiting on a host action that has already happened. The live `user-shown` /
				// `event-opened` events only reach a tab that was open when the host acted, and
				// the usual sequence is the other way round: the guest closes the app, the host
				// lifts the ban or reopens uploads later. Without this the photo stays parked
				// forever, which is exactly the "my pictures never sent" the host gets asked about.
				void releaseResolvedParks({
					banned: ctx.is_banned,
					uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
				});
			} catch (err) {
				// Cross-cutting hydration on boot — failure is non-fatal; users without
				// a session land on /join anyway, and the per-page mount will retry.
				//
				// But ONE consequence is not recoverable by a per-page mount: the park release
				// above. A parked photo's other two release paths are the live `user-shown` /
				// `event-opened` SSE events, and the routes that open a stream are /feed, /diashow,
				// /export, /host and /admin — NOT /upload, which is exactly where the toast sends
				// the guest to watch their queue. So on venue wifi, the condition this branch
				// exists for, one failed request could strand the photo for the whole session with
				// the queue row still reading "Du bist gesperrt.". Retry once, briefly.
				//
				// NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and
				// redirecting to /join, so there is no session left to hydrate and a second attempt
				// can only fire a SECOND redirect — two seconds later, by which time the guest may
				// have navigated somewhere else. Retry the transient case this exists for (offline,
				// 5xx, a dropped request) and nothing else.
				//
				// A guard, not an early `return`: everything below this block — `refreshQuota`
				// and, outside it, every SSE listener registration — still has to run.
				const worthRetrying = !(err instanceof ApiError && err.status === 401);
				if (worthRetrying) {
					try {
						await new Promise((r) => setTimeout(r, 2000));
						const ctx = await api.get<MeContextDto>('/me/context');
						isBanned.set(ctx.is_banned);
						eventState.set({
							uploadsLocked: ctx.uploads_locked,
							galleryReleased: ctx.gallery_released
						});
						void releaseResolvedParks({
							banned: ctx.is_banned,
							uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
						});
					} catch {
						// Still down. The "Erneut" button on the parked row remains the way back.
					}
				}
			}
			void refreshQuota();
		}

		// Global pin-reset listener — clears the now-invalid plaintext PIN from
		// localStorage no matter which route the user is currently on. The reactive
		// `currentPin` store carries the change into any page that reads it (My
		// Account in particular).
		unsubs.push(
			// Server contract: `data` is a JSON string of the shape `{ user_id: UUID }`.
			// We clear the cached PIN only for our own user; admin resets for other guests
			// arrive on the same channel but aren't ours to act on.
			onSseEvent('pin-reset', (data) => {
				try {
					const payload = JSON.parse(data) as { user_id: string };
					if (payload.user_id === getUserId()) clearPin();
				} catch {
					// Malformed payload — discard; nothing actionable for the user.
				}
			}),
			// A host banned someone. Same contract as `pin-reset`: `data` is a JSON string of
			// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
			// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
			// on boot and never moved — a guest banned mid-party kept the full UI and learned
			// about it one 403 toast at a time, which reads as the app being broken.
			onSseEvent('user-hidden', (data) => {
				try {
					const payload = JSON.parse(data) as { user_id: string };
					if (payload.user_id === getUserId()) isBanned.set(true);
				} catch {
					// Malformed payload — discard; nothing actionable for the user.
				}
			}),
			// And the mirror. This used to be one-way ("an unban has no SSE, and the next
			// `/me/context` clears it") because `unban_user` broadcast nothing at all — so a
			// guest whose ban was lifted kept the banned UI until they happened to reload,
			// which for a PWA with no URL bar is not a thing they can easily do.
			onSseEvent('user-shown', (data) => {
				try {
					const payload = JSON.parse(data) as { user_id: string };
					if (payload.user_id === getUserId()) isBanned.set(false);
				} catch {
					// Malformed payload — discard; nothing actionable for the user.
				}
			}),
			// Reflect a host closing/reopening uploads live, so the composer switches to a
			// locked state immediately instead of a guest finding out via a rejected upload.
			// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
			// which markClosed can't tell apart — follow it with a server refresh so a release
			// correctly flips `galleryReleased` too (else the composer briefly mislabels it as a
			// plain lock until the next navigation).
			onSseEvent('event-closed', () => {
				markClosed();
				void refreshEventState();
			}),
			onSseEvent('event-opened', () => markOpened()),
			// A host changed the theme (or privacy note) — re-fetch the public config so the
			// palette updates live on every open client, not just after a reload.
			onSseEvent('event-updated', () => void loadEventConfig())
		);
	});

	onDestroy(() => {
		for (const unsub of unsubs) unsub();
	});
</script>

<svelte:head>
	<link rel="icon" href={favicon} />
</svelte:head>

{@render children()}

<!-- Slim upload progress bar — sits just above the bottom nav -->
{#if $isProcessing && $isAuthenticated && $showBottomNav}
	<div
		class="fixed z-30 h-0.5 bg-gray-200 transition-all"
		style="bottom: calc(3.5rem + env(safe-area-inset-bottom)); left: 0; right: 0"
	>
		<div class="h-full bg-blue-500 transition-all duration-500" style="width: {progressPct}%"></div>
	</div>
{/if}

<!-- Rate-limit countdown, rendered next to the progress bar for the same reason: the queue's
     own banner lives on /upload, which is not where the guest is standing when the 429 hits.
     Suppressed while the bottom nav is (i.e. on /upload), where UploadQueue already shows it
     in place and this would duplicate it over the sticky submit bar — and for a banned guest,
     whose read-only banner occupies the same slot and is the more relevant message. -->
{#if $rateLimitRetryAt && rateLimitCountdown > 0 && $isAuthenticated && $showBottomNav && !$isBanned}
	<div
		role="status"
		class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
		style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
	>
		<div
			class="rounded-xl bg-amber-50 px-4 py-2 text-center text-sm text-amber-800 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:text-amber-300 dark:ring-amber-900"
		>
			Upload-Limit erreicht. Wird in {rateLimitCountdown} Sek. automatisch fortgesetzt.
		</div>
	</div>
{/if}

<!-- Read-only notice for a banned guest. Rendered in the layout so it follows them across
     every route, and above the bottom nav so it is not hidden behind it. A ban blocks every
     write server-side but leaves the feed and the keepsake readable, so the guest needs to be
     told once — otherwise the upload, like and delete controls all look available and answer
     with a 403 toast each time, which reads as the app being broken.

     The offset must be computed, not the fixed `bottom-16` it used to be: BottomNav is h-14
     (3.5rem) PLUS env(safe-area-inset-bottom), so on a notched iPhone the nav is ~90px tall and
     paints over the lower part of this banner (same z-40, later in the DOM). And it only renders
     alongside the nav — on /upload the nav is suppressed and this would float over the sticky
     submit bar; a banned guest can no longer open the composer anyway (see UploadSheet). -->
{#if $isBanned && $isAuthenticated && $showBottomNav}
	<div
		role="status"
		class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
		style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
	>
		<div
			class="rounded-xl bg-amber-50 px-4 py-3 text-center shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:ring-amber-900"
		>
			<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
			<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
				Du kannst alle Fotos ansehen und die Galerie später herunterladen. Hochladen, Liken{$commentsEnabled
					? ' und Kommentieren'
					: ''} sind für dich deaktiviert.
			</p>
		</div>
	</div>
{/if}

<!-- UploadSheet is always mounted for smooth enter/exit animation -->
<UploadSheet />

{#if $showBottomNav && $isAuthenticated}
	<BottomNav />
{/if}

<Toaster />
