<script lang="ts">
	import { goto } from '$app/navigation';
	import { getToken, getUserId } from '$lib/auth';
	import { role as myRoleStore } from '$lib/role-store';
	import { api } from '$lib/api';
	import type { MeContextDto } from '$lib/types';
	import { onMount, onDestroy } from 'svelte';
	import { onSseEvent } from '$lib/sse';
	import { toast, toastError } from '$lib/toast-store';
	import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
	import Modal from '$lib/components/Modal.svelte';
	import IconButton from '$lib/components/IconButton.svelte';
	import { commentsEnabled } from '$lib/event-config-store';

	interface UserSummary {
		id: string;
		display_name: string;
		role: string;
		is_banned: boolean;
		uploads_hidden: boolean;
		upload_count: number;
		total_upload_bytes: number;
		created_at: string;
	}

	interface EventStatus {
		name: string;
		is_active: boolean;
		uploads_locked: boolean;
		export_released: boolean;
	}

	interface PinResetRequest {
		id: string;
		user_id: string;
		display_name: string;
		created_at: string;
	}

	interface ExportJob {
		status: string;
		progress_pct: number;
	}
	interface ExportStatusDto {
		released: boolean;
		zip: ExportJob | null;
		html: ExportJob | null;
	}

	let event = $state<EventStatus | null>(null);
	let users = $state<UserSummary[]>([]);
	let pinResetRequests = $state<PinResetRequest[]>([]);
	let exportInfo = $state<ExportStatusDto | null>(null);
	let rebuilding = $state(false);
	let loading = $state(true);
	let error = $state<string | null>(null);

	// Live keepsake state for the host dashboard: are both halves done, and if not, how far.
	let exportReady = $derived(
		exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
	);
	// The keepsake needs BOTH halves, so EITHER failing means the whole thing failed — use
	// `&&` (not `||`), else a one-half failure stays stuck on "wird erstellt…" forever and the
	// host never sees the "re-release" recovery hint.
	let exportGenerating = $derived(
		!!exportInfo?.released &&
			!exportReady &&
			exportInfo?.zip?.status !== 'failed' &&
			exportInfo?.html?.status !== 'failed'
	);
	let exportProgress = $derived(
		Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
	);

	// SSE unsubscribers, torn down on destroy.
	let sseOff: Array<() => void> = [];

	// Collapsible section state
	let statsOpen = $state(true);
	let settingsOpen = $state(true);
	let usersOpen = $state(true);

	// User search
	let userSearch = $state('');
	let filteredUsers = $derived(
		userSearch.trim()
			? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
			: users
	);

	// Ban modal state
	let banTarget = $state<UserSummary | null>(null);
	let banSubmitting = $state(false);

	// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
	// shown once and forgotten on close.
	let pinResetTarget = $state<UserSummary | null>(null);
	let pinResetSubmitting = $state(false);
	let pinModal = $state<{ name: string; pin: string } | null>(null);

	// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
	const myRole = $derived($myRoleStore);
	const myUserId = getUserId();

	// Generic confirm-then-run for the irreversible / privilege-changing actions
	// (promote, demote, unban, release gallery) that previously fired on one tap.
	// Reuses the shared ConfirmSheet; the wrapped fns keep their own toast/reload.
	interface PendingConfirm {
		title: string;
		message: string;
		confirmLabel: string;
		tone: 'default' | 'danger';
		run: () => Promise<void>;
	}
	let confirmAction = $state<PendingConfirm | null>(null);

	async function runConfirmAction() {
		const action = confirmAction;
		if (!action) return;
		await action.run();
		confirmAction = null;
	}

	/**
	 * Mirrors the backend authorisation rules shared by ban / unban / PIN-reset / set-role:
	 * a plain host may only act on GUESTS; only an admin may act on a host; nobody may act on
	 * an admin. Used to hide moderation buttons that would always 403 — a non-admin host must
	 * not see Sperren/Degradieren/PIN on a peer-host row (they'd tap it and get a bare 403).
	 */
	function canModerate(target: UserSummary): boolean {
		if (target.role === 'admin') return false;
		if (myRole === 'admin') return true;
		if (myRole === 'host') return target.role === 'guest';
		return false;
	}

	onMount(async () => {
		const token = getToken();
		if (!token) {
			goto('/join');
			return;
		}
		// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
		// stale 'host' in the token, but the backend now 403s every host call. Fetch
		// the current role and bounce a demoted host to the feed instead of leaving
		// them on a dashboard that errors on load.
		try {
			const ctx = await api.get<MeContextDto>('/me/context');
			if (ctx.role !== 'host' && ctx.role !== 'admin') {
				goto('/feed');
				return;
			}
		} catch {
			// Expired/invalid session (api.ts cleared it) — send them to re-auth.
			goto('/join');
			return;
		}
		await reload();

		// The awaits above mean the component can already be destroyed by the time we get
		// here (user navigated away mid-load). Svelte doesn't cancel an async onMount, so
		// without this guard onDestroy would have run with an empty `sseOff` and we'd leak
		// these handlers into the module-global SSE map forever.
		if (destroyed) return;

		// Live updates so the dashboard doesn't need a manual reload:
		// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
		// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
		//   the other host's stale row disappears instead of yielding a conflicting PIN).
		// - export-progress/-available: keepsake generation moves → refresh the status line.
		sseOff = [
			onSseEvent('pin-reset-requested', () => void refreshPinRequests()),
			onSseEvent('pin-reset', () => void refreshPinRequests()),
			onSseEvent('export-progress', () => void refreshExportStatus()),
			onSseEvent('export-available', () => void refreshExportStatus())
		];
	});

	let destroyed = false;
	onDestroy(() => {
		destroyed = true;
		for (const off of sseOff) off();
	});

	async function reload() {
		loading = true;
		error = null;
		try {
			[event, users, pinResetRequests] = await Promise.all([
				api.get<EventStatus>('/host/event'),
				api.get<UserSummary[]>('/host/users'),
				api.get<PinResetRequest[]>('/host/pin-reset-requests')
			]);
		} catch (e: unknown) {
			error = e instanceof Error ? e.message : 'Fehler beim Laden.';
		} finally {
			loading = false;
		}
		// Export status is a secondary widget — fetch it OUTSIDE the all-or-nothing block above
		// (and error-swallowing) so a transient /export/status failure can't blank the whole
		// dashboard (users, moderation, settings).
		void refreshExportStatus();
	}

	/** Refetch just the pending PIN-reset requests (live badge; avoids a full-page reload). */
	async function refreshPinRequests() {
		try {
			pinResetRequests = await api.get<PinResetRequest[]>('/host/pin-reset-requests');
		} catch {
			/* non-fatal — the next manual reload picks it up */
		}
	}

	/** Refetch just the keepsake export status (live progress/ready). */
	async function refreshExportStatus() {
		try {
			exportInfo = await api.get<ExportStatusDto>('/export/status');
		} catch {
			/* non-fatal */
		}
	}

	async function resetPinForRequest(req: PinResetRequest) {
		try {
			const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
			pinModal = { name: req.display_name, pin: res.pin };
			await reload();
		} catch (e: unknown) {
			toastError(e);
		}
	}

	async function dismissPinRequest(req: PinResetRequest) {
		try {
			await api.delete(`/host/pin-reset-requests/${req.id}`);
			pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
		} catch (e: unknown) {
			toastError(e);
		}
	}

	async function reopenEvent() {
		try {
			await api.post('/host/event/open');
			toast('Uploads wurden wieder geöffnet.', 'success');
			await reload();
		} catch (e: unknown) {
			toastError(e);
		}
	}

	function toggleEventLock() {
		if (!event) return;
		if (event.uploads_locked) {
			// Reopening after a release INVALIDATES the published keepsake: it clears the
			// release + ready flags, so every guest's download 404s until the host re-releases.
			// That's destructive and easy to trigger by accident ("let a few more photos in"),
			// so gate it behind a danger confirm — but a plain lock (not yet released) reopens
			// with no ceremony.
			if (event.export_released) {
				confirmAction = {
					title: 'Galerie-Freigabe zurücknehmen?',
					message:
						'Wenn du die Uploads wieder öffnest, wird die veröffentlichte Galerie zurückgezogen. ' +
						'Gäste können das Keepsake erst nach einer erneuten Freigabe wieder herunterladen.',
					confirmLabel: 'Wieder öffnen',
					tone: 'danger',
					run: reopenEvent
				};
				return;
			}
			void reopenEvent();
		} else {
			void (async () => {
				try {
					await api.post('/host/event/close');
					toast('Uploads wurden gesperrt.', 'success');
					await reload();
				} catch (e: unknown) {
					toastError(e);
				}
			})();
		}
	}

	async function releaseGallery() {
		try {
			await api.post('/host/gallery/release');
			toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
			await reload();
		} catch (e: unknown) {
			toastError(e);
			// Release marks `export_released_at` before enqueuing the workers; if that second
			// step errored the event is already released, so reconcile the UI (otherwise the
			// button still reads "Galerie freigeben" and a retry just 409s "bereits freigegeben").
			await reload();
		}
	}

	// The escape hatch for a keepsake that is failed or stale. Without this the only recovery is
	// reopening uploads (which unlocks the gallery to every guest and retracts the release) or a
	// container restart — neither of which a host at 2am can reasonably be asked to do.
	async function rebuildExport() {
		rebuilding = true;
		try {
			await api.post('/host/export/rebuild', {});
			toast('Keepsake wird neu erstellt…', 'success');
			await refreshExportStatus();
		} catch (e: unknown) {
			toastError(e);
		} finally {
			rebuilding = false;
		}
	}

	function openBanModal(user: UserSummary) {
		banTarget = user;
	}

	async function confirmBan() {
		if (!banTarget) return;
		banSubmitting = true;
		try {
			await api.post(`/host/users/${banTarget.id}/ban`, {});
			toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
			banTarget = null;
			await reload();
		} catch (e: unknown) {
			toastError(e);
		} finally {
			banSubmitting = false;
		}
	}

	async function unban(user: UserSummary) {
		try {
			await api.post(`/host/users/${user.id}/unban`);
			toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
			await reload();
		} catch (e: unknown) {
			toastError(e);
		}
	}

	async function promoteToHost(user: UserSummary) {
		try {
			await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
			toast(`${user.display_name} ist jetzt Host.`, 'success');
			await reload();
		} catch (e: unknown) {
			toastError(e);
		}
	}

	async function demoteToGuest(user: UserSummary) {
		try {
			await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
			toast(`${user.display_name} ist jetzt Gast.`, 'success');
			await reload();
		} catch (e: unknown) {
			toastError(e);
		}
	}

	function askResetPin(user: UserSummary) {
		pinResetTarget = user;
	}

	async function confirmResetPin() {
		if (!pinResetTarget) return;
		pinResetSubmitting = true;
		try {
			const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
			pinModal = { name: pinResetTarget.display_name, pin: res.pin };
			pinResetTarget = null;
		} catch (e: unknown) {
			toastError(e);
		} finally {
			pinResetSubmitting = false;
		}
	}

	function copyPinModal() {
		if (!pinModal) return;
		navigator.clipboard.writeText(pinModal.pin);
		toast('PIN kopiert.', 'success');
	}

	function formatBytes(bytes: number): string {
		if (bytes < 1024) return `${bytes} B`;
		if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
		return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
	}
</script>

<!-- Confirmation for irreversible / privilege-changing actions (promote/demote/unban/release). -->
<ConfirmSheet
	open={confirmAction !== null}
	title={confirmAction?.title ?? ''}
	message={confirmAction?.message ?? ''}
	confirmLabel={confirmAction?.confirmLabel ?? 'Bestätigen'}
	tone={confirmAction?.tone ?? 'default'}
	onConfirm={runConfirmAction}
	onCancel={() => (confirmAction = null)}
/>

<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
<ConfirmSheet
	open={pinResetTarget !== null}
	title="PIN zurücksetzen"
	message={pinResetTarget
		? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
		: ''}
	confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
	tone="danger"
	onConfirm={confirmResetPin}
	onCancel={() => (pinResetTarget = null)}
/>

<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
<Modal open={pinModal !== null} titleId="host-pin-modal-title" onClose={() => (pinModal = null)}>
	{#if pinModal}
		<h2 id="host-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
			Neue PIN für {pinModal.name}
		</h2>
		<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
			Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie
			verworfen.
		</p>
		<div
			class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30"
		>
			<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
				>{pinModal.pin}</span
			>
			<button
				onclick={copyPinModal}
				class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60"
			>
				Kopieren
			</button>
		</div>
		<button onclick={() => (pinModal = null)} class="btn btn-primary btn-block"> Schließen </button>
	{/if}
</Modal>

<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
	{#if banTarget}
		<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
			Benutzer sperren
		</h2>
		<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
			<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
			Galerie, Diashow und Export, und Hochladen, Liken{$commentsEnabled ? ' und Kommentieren' : ''} werden
			blockiert. Der Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar
			über „Entsperren“.
		</p>
		<div class="flex gap-2">
			<button onclick={() => (banTarget = null)} class="btn btn-secondary flex-1">
				Abbrechen
			</button>
			<button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger flex-1">
				{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
			</button>
		</div>
	{/if}
</Modal>

<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
	<!-- Header -->
	<div
		class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900"
	>
		<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
			<IconButton label="Zurück" onclick={() => goto('/account')} class="shrink-0">
				<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
					<path
						stroke-linecap="round"
						stroke-linejoin="round"
						d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
					/>
				</svg>
			</IconButton>
			<div class="min-w-0">
				<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Host-Dashboard</h1>
				{#if event}
					<p class="truncate text-sm text-gray-500 dark:text-gray-400">{event.name}</p>
				{/if}
			</div>
		</div>
	</div>

	<div class="mx-auto max-w-3xl space-y-3 p-4">
		{#if loading}
			<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
		{:else if error}
			<div
				class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300"
			>
				{error}
			</div>
		{:else if event}
			<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
			{#if pinResetRequests.length > 0}
				<div
					class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30"
				>
					<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
						<h2 class="font-semibold text-amber-900 dark:text-amber-200">
							PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1
								? ''
								: 'n'}
						</h2>
						<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
							Prüfe die Identität, bevor du eine PIN zurücksetzt.
						</p>
					</div>
					<ul class="divide-y divide-amber-200 dark:divide-amber-900">
						{#each pinResetRequests as req (req.id)}
							<li class="flex items-center justify-between gap-3 px-5 py-3">
								<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100"
									>{req.display_name}</span
								>
								<div class="flex shrink-0 gap-2">
									<button
										onclick={() => dismissPinRequest(req)}
										class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
									>
										Ablehnen
									</button>
									<button
										onclick={() => resetPinForRequest(req)}
										class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
									>
										PIN zurücksetzen
									</button>
								</div>
							</li>
						{/each}
					</ul>
				</div>
			{/if}

			<!-- ── Statistiken ─────────────────────────────────────────────── -->
			<div class="card overflow-hidden">
				<button
					onclick={() => (statsOpen = !statsOpen)}
					aria-expanded={statsOpen}
					class="flex w-full items-center justify-between px-5 py-4"
				>
					<h2 class="section-title">Statistiken</h2>
					<svg
						class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen
							? 'rotate-180'
							: ''}"
						fill="none"
						viewBox="0 0 24 24"
						stroke="currentColor"
						stroke-width="2"
					>
						<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
					</svg>
				</button>
				<div
					class="overflow-hidden transition-[max-height] duration-200 {statsOpen
						? 'max-h-[500px]'
						: 'max-h-0'}"
				>
					<div
						class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4"
					>
						<div class="surface-muted p-4 text-center">
							<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
							<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
						</div>
						<div class="surface-muted p-4 text-center">
							<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
								{users.reduce((s, u) => s + u.upload_count, 0)}
							</p>
							<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
						</div>
						<div class="surface-muted flex flex-col items-center p-4 text-center">
							<span
								class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.uploads_locked
									? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
									: 'bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300'}"
							>
								<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
								{event.uploads_locked ? 'Gesperrt' : 'Offen'}
							</span>
							<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
						</div>
						<div class="surface-muted flex flex-col items-center p-4 text-center">
							<span
								class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.export_released
									? 'bg-purple-100 text-purple-800 dark:bg-purple-950/50 dark:text-purple-200'
									: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}"
							>
								<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
								{event.export_released ? 'Freigegeben' : 'Noch nicht'}
							</span>
							<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Galerie</p>
						</div>
					</div>
				</div>
			</div>

			<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
			<div class="card overflow-hidden">
				<button
					onclick={() => (settingsOpen = !settingsOpen)}
					aria-expanded={settingsOpen}
					class="flex w-full items-center justify-between px-5 py-4"
				>
					<h2 class="section-title">Event-Einstellungen</h2>
					<svg
						class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen
							? 'rotate-180'
							: ''}"
						fill="none"
						viewBox="0 0 24 24"
						stroke="currentColor"
						stroke-width="2"
					>
						<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
					</svg>
				</button>
				<div
					class="overflow-hidden transition-[max-height] duration-200 {settingsOpen
						? 'max-h-[500px]'
						: 'max-h-0'}"
				>
					<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
						<button
							onclick={toggleEventLock}
							class="rounded-lg border px-4 py-2 text-sm font-medium transition
								{event.uploads_locked
								? 'border-green-600 bg-green-600 text-white hover:bg-green-700 dark:border-green-500 dark:bg-green-500 dark:hover:bg-green-400'
								: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'}"
						>
							{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
						</button>
						<button
							onclick={() =>
								(confirmAction = {
									title: 'Galerie freigeben?',
									message:
										'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
									confirmLabel: 'Freigeben',
									tone: 'danger',
									run: releaseGallery
								})}
							disabled={event.export_released}
							class="btn btn-primary btn-sm"
						>
							{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
						</button>
					</div>

					<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and
					     downloads 404 until they're done — surface it so a host doesn't announce
					     "released!" while guest downloads still fail. -->
					{#if event.export_released}
						<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
							{#if exportGenerating}
								<p class="font-medium text-amber-700 dark:text-amber-300">
									Keepsake wird erstellt… {exportProgress}%
								</p>
								<div
									class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
								>
									<div
										class="h-full rounded-full bg-amber-500 transition-all"
										style="width: {exportProgress}%"
									></div>
								</div>
							{:else if exportReady}
								<p class="flex items-center justify-between gap-2">
									<span class="font-medium text-green-700 dark:text-green-300"
										>Keepsake ist bereit.</span
									>
									<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
										>Zum Download</a
									>
								</p>
							{:else}
								<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
							{/if}

							<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches above.
							     Those branches are driven by SSE (`export-progress` / `export-available`), and
							     `rebuildExport` itself makes the backend broadcast `export-progress` at 0%
							     immediately. If the button lived inside a branch, activating it would flip
							     `exportGenerating` and UNMOUNT THE BUTTON BEING PRESSED — and a worker tick
							     landing between mousedown and mouseup would do the same unprompted. Chromium
							     fires no `click` when the element dies mid-sequence, so the host would tap
							     "Erneut versuchen" and nothing at all would happen, on the one screen whose
							     entire purpose is recovering a broken keepsake. (Same class of bug as the feed
							     autocomplete: never let a handler destroy the node that is being clicked.)

							     So the button's EXISTENCE is invariant; only its label and `disabled` change. -->
							<button
								onclick={() => {
									// Rebuilding a READY keepsake is disruptive — guests who tap Download during
									// the rebuild get nothing until it finishes — so it gets a confirm. A FAILED
									// keepsake has nothing to lose, so it retries immediately.
									if (exportReady) {
										confirmAction = {
											title: 'Keepsake neu erstellen?',
											message:
												'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
												'Während der Erstellung können Gäste es nicht herunterladen.',
											confirmLabel: 'Neu erstellen',
											tone: 'danger',
											run: rebuildExport
										};
									} else {
										void rebuildExport();
									}
								}}
								disabled={rebuilding || exportGenerating}
								data-testid="export-rebuild"
								class="mt-2 rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
									{exportReady
									? 'text-gray-500 underline dark:text-gray-400'
									: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
							>
								{rebuilding
									? 'Wird gestartet…'
									: exportReady
										? 'Neu erstellen'
										: 'Erneut versuchen'}
							</button>
						</div>
					{/if}
				</div>
			</div>

			<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
			<div class="card overflow-hidden">
				<button
					onclick={() => (usersOpen = !usersOpen)}
					aria-expanded={usersOpen}
					class="flex w-full items-center justify-between px-5 py-4"
				>
					<h2 class="section-title">Nutzerverwaltung</h2>
					<svg
						class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen
							? 'rotate-180'
							: ''}"
						fill="none"
						viewBox="0 0 24 24"
						stroke="currentColor"
						stroke-width="2"
					>
						<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
					</svg>
				</button>
				<div
					class="overflow-hidden transition-[max-height] duration-300 {usersOpen
						? 'max-h-[9999px]'
						: 'max-h-0'}"
				>
					<div class="border-t border-gray-100 dark:border-gray-700">
						<!-- Search -->
						<div class="px-4 py-3">
							<div
								class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900"
							>
								<svg
									class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
									fill="none"
									viewBox="0 0 24 24"
									stroke="currentColor"
									stroke-width="2"
								>
									<path
										stroke-linecap="round"
										stroke-linejoin="round"
										d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
									/>
								</svg>
								<input
									type="search"
									placeholder="Nutzer suchen…"
									bind:value={userSearch}
									class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
								/>
							</div>
						</div>
						{#if filteredUsers.length === 0}
							<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
								Keine Treffer.
							</p>
						{:else}
							<div class="divide-y divide-gray-100 dark:divide-gray-700">
								{#each filteredUsers as user (user.id)}
									<div class="flex items-center gap-3 px-5 py-3">
										<div class="min-w-0 flex-1">
											<div class="flex flex-wrap items-center gap-1.5">
												<span class="font-medium text-gray-900 dark:text-gray-100"
													>{user.display_name}</span
												>
												{#if user.role === 'host'}
													<span class="badge badge-primary">Host</span>
												{:else if user.role === 'admin'}
													<span class="badge badge-gold">Admin</span>
												{/if}
												{#if user.is_banned}
													<span class="badge badge-danger">Gesperrt</span>
												{/if}
											</div>
											<p class="text-xs text-gray-400 dark:text-gray-500">
												{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(
													user.total_upload_bytes
												)}
											</p>
										</div>
										<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
											{#if user.role !== 'admin'}
												{#if user.is_banned}
													<!-- Only show Entsperren to someone allowed to act on this user (a plain
													     host can't unban a peer host — that's an admin-only action, F1). -->
													{#if canModerate(user)}
														<button
															onclick={() =>
																(confirmAction = {
																	title: 'Sperre aufheben?',
																	message: `${user.display_name} kann danach wieder hochladen${$commentsEnabled ? ', liken und kommentieren' : ' und liken'}.`,
																	confirmLabel: 'Entsperren',
																	tone: 'default',
																	run: () => unban(user)
																})}
															class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
														>
															Entsperren
														</button>
													{/if}
												{:else if user.id !== myUserId}
													<!-- Never render target-actions (promote/demote/PIN/ban) on the
													     caller's own row: the backend rejects every self-action
													     (self-ban / self-demote / self-PIN) with a 400, so the button
													     would only ever fail. -->
													{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
														<button
															onclick={() =>
																(confirmAction = {
																	title: 'Zum Host befördern?',
																	message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
																	confirmLabel: 'Befördern',
																	tone: 'default',
																	run: () => promoteToHost(user)
																})}
															class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
														>
															Host
														</button>
													{/if}
													{#if user.role === 'host' && myRole === 'admin'}
														<!-- Only an admin may demote a host (F1). A plain host demoting a
														     peer host is a 403 on the backend, so the button is hidden. -->
														<button
															onclick={() =>
																(confirmAction = {
																	title: 'Zum Gast degradieren?',
																	message: `${user.display_name} verliert alle Host-Rechte.`,
																	confirmLabel: 'Degradieren',
																	tone: 'danger',
																	run: () => demoteToGuest(user)
																})}
															class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
														>
															Degradieren
														</button>
													{/if}
													{#if canModerate(user)}
														<button
															onclick={() => askResetPin(user)}
															class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
														>
															PIN zurücksetzen
														</button>
													{/if}
													{#if canModerate(user)}
														<button
															onclick={() => openBanModal(user)}
															class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
														>
															Sperren
														</button>
													{/if}
												{/if}
											{/if}
										</div>
									</div>
								{/each}
							</div>
						{/if}
					</div>
				</div>
			</div>
		{/if}
	</div>
</div>
