<script lang="ts">
	import { tick } from 'svelte';
	import { get } from 'svelte/store';
	import { fade, fly } from 'svelte/transition';

	import { afterNavigate, onNavigate } from '$app/navigation';
	import { page } from '$app/stores';

	import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
	import { storedTheme } from '$lib/stores/settings';

	import Footer from '$lib/components/navigation/footer.svelte';
	import Header from '$lib/components/navigation/header.svelte';
	import WeatherNav from '$lib/components/navigation/weather-nav.svelte';

	import favicon from '$lib/assets/favicon.svg';

	import { routePath } from '$lib/i18n';

	import './layout.css';

	let { children } = $props();

	// keep the .dark class in sync with the persisted theme; in 'system' mode
	// follow the OS preference live
	let themeSettled = false;
	let themeTimer = 0;
	$effect(() => {
		const theme = $storedTheme;
		const mq = window.matchMedia('(prefers-color-scheme: dark)');
		const apply = () => {
			const root = document.documentElement;
			const dark = theme === 'dark' || (theme === 'system' && mq.matches);
			const paint = () => root.classList.toggle('dark', dark);

			// The very first application is just painting the stored theme - only
			// an actual switch afterwards is worth cross-fading.
			const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
			if (!themeSettled || reduced) {
				themeSettled = true;
				paint();
				return;
			}

			if (document.startViewTransition) {
				// one cross-fade of the whole document; component transitions untouched
				document.startViewTransition(paint);
				return;
			}

			// no view transitions: fall back to fading the colours for one window
			root.classList.add('theme-transition');
			clearTimeout(themeTimer);
			themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
			paint();
		};

		apply();
		mq.addEventListener('change', apply);
		return () => mq.removeEventListener('change', apply);
	});

	// ── Page cross-fade ───────────────────────────────────────────────────────
	// A real cross-fade needs the outgoing page still on screen while the
	// incoming one appears - so the view transition is opened at navigation and
	// deliberately held open until the new page reports that its data has landed.
	// The browser keeps showing the old snapshot for that whole time (rather than
	// flashing a skeleton), then fades it into the finished page.
	//
	// The wait is capped: past the ceiling we cross-fade into whatever is on
	// screen instead of freezing the UI on a slow network.
	const READY_CEILING_MS = 2200;
	const FADE_OUT_MS = 170;

	let contentVisible = $state(true);
	let revealTimer = 0;

	const reducedMotion = () =>
		typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

	// Routes that fetch their own forecast after mounting. Knowing this up front
	// is what makes the wait reliable: the layout clears the ready flag before
	// the swap rather than trusting the incoming page to have done it.
	const DATA_ROUTES = new Set([
		'/weather/week/[location]',
		'/weather/14-day/[location]',
		'/weather/compare/[location]',
		'/weather/seasonal/[location]',
		'/weather/historical/[location]'
	]);

	/** Resolves once the freshly mounted page has its data, or at the ceiling. */
	async function waitForContent(): Promise<void> {
		const deadline = Date.now() + READY_CEILING_MS;
		while (!get(pageContentReady) && Date.now() < deadline) {
			await new Promise((resolve) => setTimeout(resolve, 40));
		}
		// one more frame so the page paints its data before the snapshot is taken
		await tick();
	}

	onNavigate((navigation) => {
		if (reducedMotion()) return;

		const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
		if (loadsData) markPageLoading();

		if (typeof document.startViewTransition === 'function') {
			return new Promise<void>((swap) => {
				document.startViewTransition(async () => {
					// hand control back so SvelteKit swaps the DOM underneath the
					// frozen snapshot of the old page
					swap();
					// A superseded navigation (a redirect, or a fast second click)
					// rejects this promise; that is not an error worth surfacing,
					// and leaving it unhandled shows up as "navigation aborted".
					await navigation.complete.catch(() => {});
					if (loadsData) await waitForContent();
				});
			});
		}

		// No view transitions: fall back to fading out, then back in on arrival.
		clearTimeout(revealTimer);
		contentVisible = false;
		return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
	});

	let mainEl = $state<HTMLElement | null>(null);

	afterNavigate((navigation) => {
		// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
		// handling never touches it and a new page would open half way down.
		// Back/forward and in-page anchors keep their position.
		if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
			mainEl?.scrollTo({ top: 0 });
		}

		if (typeof document.startViewTransition === 'function' || reducedMotion()) {
			contentVisible = true;
			return;
		}
		const startedAt = Date.now();
		const reveal = () => {
			if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
				contentVisible = true;
				return;
			}
			revealTimer = window.setTimeout(reveal, 60);
		};
		reveal();
	});

	// the maps page embeds a full-bleed map: no padding, no scrolling
	let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));

	let sidebarCollapsed = $state(false);
	let mobileMenuOpen = $state(false);

	const toggleSidebar = () => {
		sidebarCollapsed = !sidebarCollapsed;
	};

	const toggleMobileMenu = () => {
		mobileMenuOpen = !mobileMenuOpen;
	};

	const closeMobileMenu = () => {
		mobileMenuOpen = false;
	};
</script>

<svelte:head>
	<link rel="icon" href={favicon} />
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>

<div class="flex h-screen overflow-hidden bg-background text-foreground">
	<!-- Desktop sidebar -->
	<div class="hidden h-full shrink-0 md:block">
		<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
	</div>

	<!-- Mobile overlay -->
	{#if mobileMenuOpen}
		<div class="fixed inset-0 z-50 md:hidden" role="presentation">
			<!-- svelte-ignore a11y_no_static_element_interactions -->
			<div
				class="absolute inset-0 bg-black/30"
				transition:fade={{ duration: 150 }}
				onclick={closeMobileMenu}
				onkeydown={closeMobileMenu}
			></div>
			<div
				class="relative z-1 h-full w-55 shadow-lg"
				transition:fly={{ x: -220, duration: 200, opacity: 1 }}
			>
				<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
			</div>
		</div>
	{/if}

	<!-- Main area: topbar + content -->
	<div class="flex min-w-0 flex-1 flex-col h-full">
		<Header onMenuToggle={toggleMobileMenu} />

		<!-- The padding stays on <main> itself: the day strip sticks with a
		     negative offset that exactly cancels it, so moving it to an inner
		     wrapper would dock the strip too high and clip its top row.
		     flex-col + flex-1 below keeps the footer on the bottom edge even when
		     the page is too short to fill the viewport. -->
		<main
			bind:this={mainEl}
			class={fullBleed
				? 'flex-1 overflow-hidden'
				: 'flex flex-1 flex-col overflow-y-auto p-3 lg:px-8 lg:py-6'}
		>
			{#if fullBleed}
				{@render children()}
			{:else}
				<!-- cap the content width on very large screens; the footer below
				     gives the page its ending, so only modest bottom room is needed -->
				<div
					class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
					class:page-fade-hidden={!contentVisible}
				>
					{@render children()}
				</div>
				<!-- full-bleed footer inside the scroll area (its own inner max-w),
				     cancelling main's padding so it sits flush with the edges -->
				<div class="-mx-3 -mb-3 lg:-mx-8 lg:-mb-6">
					<Footer />
				</div>
			{/if}
		</main>
	</div>
</div>
