<script lang="ts">
	import { browser } from '$app/environment';
	import { goto } from '$app/navigation';
	import { page } from '$app/state';
	import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
	import { toast } from 'svelte-sonner';
	import {
		batchDelete,
		getPhoto,
		listHeaps,
		listPhotos,
		type PpAlbum
	} from '$lib/services/photoprism';
	import {
		filters,
		filtersToQ,
		filtersToUrlParams,
		parseUrlParams,
		setSearch,
		setSection
	} from '$lib/stores/filters.svelte';
	import { isAuthenticated } from '$lib/stores/session.svelte';
	import { untrack } from 'svelte';
	import {
		isSelected,
		selectRange,
		selection,
		setAnchor,
		setFocused,
		setOrder
	} from '$lib/stores/selection.svelte';
	import { openPreview, preview } from '$lib/stores/preview.svelte';
	import {
		setRightSidebarWidth,
		setThumbnailSize,
		THUMBNAIL_SIZE_LABELS,
		THUMBNAIL_SIZE_PRESETS,
		view
	} from '$lib/stores/view.svelte';
	import { tick } from 'svelte';
	import { resizable } from '$lib/actions/resizable';
	import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
	import { nearBottom } from '$lib/actions/nearBottom';
	import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange';
	import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
	import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
	import PhotoTile from '$lib/components/timeline/PhotoTile.svelte';
	import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
	import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
	import Toolbar from '$lib/components/layout/Toolbar.svelte';
	import { type PpPhoto } from '$lib/types/photoprism';

	// ── URL ↔ filter store sync ──────────────────────────────────────────────
	// On nav (back/forward, deep link), reflect the URL into the store.
	$effect(() => {
		if (!browser) return;
		const next = parseUrlParams(page.url.searchParams);
		if (next.section !== undefined) filters.section = next.section;
		if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
		if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
		if (next.search !== undefined) filters.search = next.search;
	});

	// When the store changes from in-app actions (left-sidebar nav, search
	// box, etc.), push the matching query string back so the URL is shareable.
	let lastWritten = $state('');
	$effect(() => {
		if (!browser) return;
		const qs = filtersToUrlParams().toString();
		const here = page.url.searchParams.toString();
		if (qs === here || qs === lastWritten) return;
		lastWritten = qs;
		void goto(`/${qs ? '?' + qs : ''}`, {
			replaceState: true,
			keepFocus: true,
			noScroll: true
		});
	});

	// ── Section title ────────────────────────────────────────────────────────
	const heapsQuery = createQuery<PpAlbum[]>(() => ({
		queryKey: ['heaps'],
		queryFn: listHeaps,
		enabled: isAuthenticated()
	}));

	const sectionLabel = $derived(buildSectionLabel());
	function buildSectionLabel(): string {
		switch (filters.section) {
			case 'favorites':
				return 'Favorites';
			case 'review':
				return 'Review';
			case 'archive':
				return 'Archive';
			case 'hidden':
				return 'Hidden';
			case 'heap': {
				const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
				return heap ? `Heap · ${heap.Title}` : 'Heap';
			}
			default:
				// 'all-photos' is the internal "no section filter" state —
				// the visible context now comes from the folder filter
				// (root by default). Show the folder path so the title
				// reflects what's actually on screen; only the rare
				// `folderPath === null` case (e.g. right after deleting a
				// heap) still reads as "All photos".
				if (filters.folderPath === '/') return 'Folder · /';
				if (filters.folderPath) return `Folder · ${filters.folderPath}`;
				return 'All photos';
		}
	}

	// ── Photo list ───────────────────────────────────────────────────────────
	// Infinite scroll. PhotoPrism's `/photos?merged=true` returns a multi-row-
	// per-photo shape (HEIC + companion JPG + MOV each count toward `count`),
	// so the page size is generous: 120 photo entries per page lands ~250-360
	// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
	// downstream into a single `photos` array consumers iterate.
	const PHOTOS_PAGE_SIZE = 120;
	const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
		queryKey: ['photos', 'q', filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
		queryFn: ({ pageParam }) =>
			listPhotos({
				q: filtersToQ(filters),
				count: PHOTOS_PAGE_SIZE,
				offset: pageParam as number,
				order: 'newest',
				merged: true
			}),
		initialPageParam: 0,
		// PhotoPrism's `count` limits SQL rows; with `merged=true` each
		// photo expands into its file rows, so a "full" page of count=120
		// typically returns ~60 photo entries. The only reliable end-of-
		// pagination signal is an empty page. Costs one extra fetch at the
		// tail (cheap; the empty response is small).
		getNextPageParam: (last, pages) =>
			last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
		enabled: isAuthenticated()
	}));

	/** Flattened view of every loaded page, deduplicated by UID. Adjacent
	 *  pages can repeat a photo when its file-row span straddles the offset
	 *  boundary (a `merged=true` quirk); the Set keeps first occurrence and
	 *  preserves order.
	 *
	 *  Split into two derived values so the heavy dedup pass only runs
	 *  when the query's pages array changes (i.e. a fetch landed). The
	 *  cheap folder-scope filter then runs whenever the user flips the
	 *  filter store — search-as-you-type, section toggle, root vs.
	 *  subfolder — without re-walking every page on each keystroke.
	 *
	 *  When the user picks the root entry in the folder tree we filter
	 *  to `Path === ''` here — PhotoPrism's `path:` operator can't
	 *  express that match, so the query fetches the whole library and
	 *  we strip subfolder rows post-hoc. */
	const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
	const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
	function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
		if (!pages) return [];
		const seen = new Set<string>();
		const out: PpPhoto[] = [];
		for (const page of pages) {
			for (const p of page) {
				if (seen.has(p.UID)) continue;
				seen.add(p.UID);
				out.push(p);
			}
		}
		return out;
	}
	// Root-folder scope is the only client-side filter we apply, and only
	// when the timeline is actually showing a folder view — never when the
	// user is in a heap, has a free-form search, or is on a non-default
	// section (favorites / archive / review / hidden). Those views are
	// scoped server-side via the q-DSL and must not be re-filtered here,
	// or labels / search will silently drop subfolder photos when the
	// store hasn't fully hydrated from the URL yet.
	function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
		if (f.folderPath !== '/') return list;
		if (f.section !== 'all-photos') return list;
		if (f.heapUid) return list;
		if (f.search) return list;
		return list.filter((p) => !p.Path);
	}
	const pageCount = $derived(photosQuery.data?.pages.length ?? 0);

	$effect(() => {
		setOrder(photos.map((p) => p.UID));
	});

	/**
	 * Auto-focus the first photo on the FIRST page only. Subsequent pages
	 * append silently — we never want the focus to jump back to the top of
	 * the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
	 * from immediately re-triggering this effect.
	 *
	 * Always re-anchors to photos[0] when pageCount lands on 1 (which
	 * only happens on initial load or after a filter change resets the
	 * infinite-query) so switching views drops the user back at the top
	 * with a fresh focus cursor. Mid-flow mutations (archive / restore /
	 * etc.) advance focus themselves via `focusAfter` and don't flip
	 * pageCount, so they don't get clobbered by this re-anchor.
	 */
	$effect(() => {
		// Re-read pageCount so the effect bottoms out cleanly on filter
		// changes (which reset pageCount back to 0/1).
		const pages = pageCount;
		untrack(() => {
			if (photos.length === 0) {
				setFocused(null);
				return;
			}
			// Only re-anchor focus on the very first page; later pages
			// must not pull focus back to photo[0].
			if (pages !== 1) return;
			setFocused(photos[0].UID);
		});
	});

	/**
	 * Flatten photos + month headers into a single row list. PhotoPrism
	 * returns photos pre-sorted by TakenAt; we emit a header row whenever
	 * the month-key changes, and tile rows for every photo in order. The
	 * tile's `tileIndex` matches its position in `photos`, which is what
	 * the windowing observer keys off of (so the visible-range math stays
	 * one-dimensional even though the template renders headers inline).
	 */
	type Row =
		| { kind: 'header'; key: string; label: string; count: number }
		| { kind: 'tile'; photo: PpPhoto; tileIndex: number };
	const rows = $derived<Row[]>(buildRows(photos));

	function buildRows(list: PpPhoto[]): Row[] {
		const out: Row[] = [];
		const fmt = new Intl.DateTimeFormat(undefined, {
			month: 'long',
			year: 'numeric'
		});
		// First pass: count per month — used by the header chip. Sparse
		// Map: O(months) memory, O(photos) time, both trivial at 10k.
		const counts = new Map<string, number>();
		const labels = new Map<string, string>();
		for (const p of list) {
			const { key, label } = monthKey(p, fmt);
			counts.set(key, (counts.get(key) ?? 0) + 1);
			if (!labels.has(key)) labels.set(key, label);
		}
		// Second pass: emit rows in document order.
		let prev = '';
		for (let i = 0; i < list.length; i++) {
			const p = list[i];
			const { key } = monthKey(p, fmt);
			if (key !== prev) {
				out.push({ kind: 'header', key, label: labels.get(key) ?? '', count: counts.get(key) ?? 0 });
				prev = key;
			}
			out.push({ kind: 'tile', photo: p, tileIndex: i });
		}
		return out;
	}

	function monthKey(p: PpPhoto, fmt: Intl.DateTimeFormat): { key: string; label: string } {
		const raw = p.TakenAtLocal ?? p.TakenAt ?? '';
		if (!raw) return { key: 'no-date', label: 'No date' };
		const d = new Date(raw);
		if (Number.isNaN(d.getTime())) return { key: 'no-date', label: 'No date' };
		const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
		return { key: k, label: fmt.format(d) };
	}

	// Visible-range window. The observer reports `first`/`last` based on
	// sampled tiles (every 5th, PhotoPrism's value). We render tiles in
	// [first - BUFFER, last + BUFFER] plus any `forcedExpand` index that
	// keyboard scrollToIndex has demanded (so a focused tile that's
	// currently windowed-out is mounted before scrollIntoView runs).
	const TILE_BUFFER = 4;
	const TILE_SAMPLE = 5;
	// Seed `visLast` generously so the first paint already shows a screen-
	// ful of tiles instead of a single buffer of 4. The IntersectionObserver
	// narrows it on the next layout pass.
	const INITIAL_VIS_LAST = 60;
	let visFirst = $state(0);
	let visLast = $state(INITIAL_VIS_LAST);
	let forcedExpand = $state<number | null>(null);
	const renderFirst = $derived(
		Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER)
	);
	const renderLast = $derived(
		Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
	);

	// Hoist the selection reads out of the per-tile each-block. The same
	// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
	// just expressed at the loop level so the render reads as "given this
	// (selected, focused) snapshot, here's each tile's state". No reactivity
	// change — SvelteSet's `has` already subscribes per-key — but it removes
	// the function-call indirection and keeps the hot loop body terse.
	const selectedIds = $derived(selection.ids);
	const focusedUid = $derived(selection.focused);

	// Reset the window when the filter key changes — old indices from the
	// previous photo list otherwise pin renderFirst/renderLast outside the
	// new shorter list and the grid renders empty. Track `filtersToQ` as
	// the trigger; appending pages (which keeps the same filter key) does
	// NOT reset, so scroll position stays stable mid-pagination.
	$effect(() => {
		filtersToQ(filters);
		untrack(() => {
			visFirst = 0;
			visLast = INITIAL_VIS_LAST;
			forcedExpand = null;
		});
	});

	/** `use:tileRegister={i}` — stable-identity Svelte action that hooks
	 *  the tile shell into the visibility observer when it mounts and
	 *  un-hooks it when it unmounts (or when `i` changes because the
	 *  photos array shifted). Using a `use:` action (not `{@attach}`)
	 *  keeps the registration stable across re-renders; `{@attach}` would
	 *  rebuild on every render because the inline arrow has fresh
	 *  identity each time.
	 *
	 *  Resolve the handle lazily off the scroll root each call instead
	 *  of stashing it in a `$state` populated by `$effect`. The effect
	 *  runs *after* the DOM update flush, but on a remount with cached
	 *  photo data the tiles render in the same pass as the scroll root,
	 *  so a `$state`-backed handle is still `null` when tileRegister
	 *  first fires — and the tile never enrols in the observer. Symptom
	 *  was a blank grid on return-trip to the timeline (the bug this
	 *  comment exists for). The visibleRange action sets
	 *  `__visibleRange` on the scroll-root node during its setup phase,
	 *  which runs before any child action, so a synchronous lookup
	 *  always succeeds. */
	function tileRegister(node: HTMLElement, index: number) {
		let current = index;
		const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
		handle?.register(node, current);
		return {
			update(next: number) {
				if (next === current) return;
				handle?.unregister(node);
				current = next;
				handle?.register(node, current);
			},
			destroy() {
				handle?.unregister(node);
			}
		};
	}

	/** Scroll the given tile fully into view. The default `scrollIntoView`
	 *  ignores the sticky month header overlaying the top of the grid, so
	 *  arrow-up into the row immediately under a header would leave the
	 *  tile occluded. We compute the visible region manually, subtract
	 *  the sticky header height from the top, and add a 25% "peek" so
	 *  the focused tile lands with breathing room rather than flush
	 *  against the viewport edge — same heuristic mule-image's Timeline
	 *  uses in its `scrollRowIntoView`. */
	function scrollTileIntoView(el: HTMLElement) {
		if (!scrollRoot) return;
		const stickyH = scrollRoot.querySelector<HTMLElement>('h2.sticky')?.offsetHeight ?? 0;
		const rootRect = scrollRoot.getBoundingClientRect();
		const elRect = el.getBoundingClientRect();
		const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
		const elBot = elTop + elRect.height;
		const peek = Math.round(elRect.height * 0.25);
		const viewTop = scrollRoot.scrollTop + stickyH;
		const viewBot = scrollRoot.scrollTop + scrollRoot.clientHeight;
		if (elTop - peek < viewTop) {
			scrollRoot.scrollTo({ top: Math.max(0, elTop - peek - stickyH) });
		} else if (elBot + peek > viewBot) {
			scrollRoot.scrollTo({ top: elBot + peek - scrollRoot.clientHeight });
		}
	}

	/** Called when arrow-keying lands on a tile that isn't currently
	 *  rendered. Forces the window to include the target, waits for the
	 *  tile shell to mount, then scrolls it fully into view. */
	async function scrollToIndex(i: number) {
		forcedExpand = i;
		await tick();
		if (!scrollRoot) return;
		const el = scrollRoot.querySelector<HTMLElement>(`[data-uid-shell="${photos[i]?.UID ?? ''}"]`);
		if (el) scrollTileIntoView(el);
	}

	// When the preview overlay closes, scroll the just-shown photo back
	// into the timeline window. PreviewOverlay already keeps
	// selection.focused in lockstep with preview.uid, so we just need to
	// make sure that tile is mounted (forcedExpand) and visible — the
	// blue ring renders itself once the inner button is in the DOM.
	let wasPreviewOpen = $state(false);
	$effect(() => {
		const open = preview.uid !== null;
		const closing = wasPreviewOpen && !open;
		wasPreviewOpen = open;
		if (!closing) return;
		const uid = selection.focused;
		if (!uid) return;
		untrack(() => {
			const i = photos.findIndex((p) => p.UID === uid);
			if (i >= 0) void scrollToIndex(i);
		});
	});

	// ── Visual rows for keyboard navigation ──────────────────────────────────
	// The CSS Grid lays each photo into a cell with column count derived from
	// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
	// full row, so a new month forces a row break even if the previous month's
	// last row had empty slots. Linear "+/-cols" arrow math doesn't account
	// for that and crosses headers wrong; mule-image's `useGridKeyNav` solves
	// it by operating on an explicit `string[][]` visual-row map. We do the
	// same: build the row map from `photos` + `tilesPerRow` + month
	// boundaries, then translate arrow keys into (row, col) moves.

	/** Cached column count of the photo grid. Measured from
	 *  `grid-template-columns` on the grid element itself (the only place
	 *  with a reliable value) via a Svelte action that runs *after* the
	 *  grid mounts. An earlier $effect-based version observed
	 *  `scrollRoot` and ran before the {#if photos.length > 0} branch
	 *  evaluated, so the grid element didn't exist and `cols` stayed at
	 *  1 — making the arrow-keyboard nav feel like a 1-column list. */
	let cols = $state(1);
	let gridEl: HTMLElement | undefined = $state();

	function trackGridCols(node: HTMLElement) {
		gridEl = node;
		const measure = () => {
			const n = getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length;
			cols = Math.max(1, n);
		};
		measure();
		// ResizeObserver fires on width changes (sidebar toggle, window
		// resize, container reflow). Thumbnail-size changes don't change
		// the grid's width but DO change its column count — handled by a
		// separate effect below that re-runs `measure` on the next tick.
		const ro = new ResizeObserver(measure);
		ro.observe(node);
		return {
			destroy() {
				ro.disconnect();
				if (gridEl === node) gridEl = undefined;
			}
		};
	}

	// Thumbnail-size changes alter column count without resizing the grid,
	// so the ResizeObserver above misses them. Re-measure on the next
	// microtask so the new computed style is in place.
	$effect(() => {
		void view.thumbnailSize;
		queueMicrotask(() => {
			if (!gridEl) return;
			const n = getComputedStyle(gridEl).gridTemplateColumns.split(' ').filter(Boolean).length;
			cols = Math.max(1, n);
		});
	});

	interface VisualRow {
		uids: string[];
		/** Index of the first tile in this row, in the flat `photos` array.
		 *  Used to call `scrollToIndex` after a move. */
		firstTileIndex: number;
	}
	/** Pre-broken visual rows + a `uid → (row, col)` index. Whenever a new
	 *  month begins, the prior row is flushed regardless of how full it was
	 *  — this matches the CSS Grid where a full-span header forces the
	 *  next tile onto a fresh row. */
	const visualGrid = $derived(buildVisualGrid(photos, cols));

	function buildVisualGrid(
		list: PpPhoto[],
		colsPerRow: number
	): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
		const rows: VisualRow[] = [];
		const pos = new Map<string, [number, number]>();
		const fmt = new Intl.DateTimeFormat(undefined, {
			month: 'long',
			year: 'numeric'
		});
		let curMonth = '';
		let curRow: VisualRow | null = null;
		for (let i = 0; i < list.length; i++) {
			const p = list[i];
			const { key } = monthKey(p, fmt);
			const monthChanged = key !== curMonth;
			const rowFull = curRow !== null && curRow.uids.length >= colsPerRow;
			if (!curRow || monthChanged || rowFull) {
				curRow = { uids: [], firstTileIndex: i };
				rows.push(curRow);
				curMonth = key;
			}
			pos.set(p.UID, [rows.length - 1, curRow.uids.length]);
			curRow.uids.push(p.UID);
		}
		return { rows, pos };
	}

	// "Intended" column for sticky-column behaviour. Updated by horizontal
	// arrow presses; vertical presses look up the destination cell with
	// this column clamped to the destination row's width — so traversing
	// a partial row doesn't permanently drift your column. Reset whenever
	// focus is established by a non-arrow path (click, Escape).
	let intendedCol: number | null = $state(null);
	$effect(() => {
		// Auto-clear when focus is dropped (Escape, photos refetch, etc.).
		if (selection.focused === null) intendedCol = null;
	});

	function onArrow(key: ArrowKey, extending: boolean) {
		const { rows, pos } = visualGrid;
		if (rows.length === 0) return;

		// Resolve starting (row, col). Without a focused tile, default to
		// the top-left so the first ArrowDown/Right lands on the first
		// real photo instead of doing nothing. The explicit tuple type
		// keeps TS from widening `[number, number]` to `string | number`.
		const cur = selection.focused ? pos.get(selection.focused) : undefined;
		const start: [number, number] = cur ?? [0, -1];
		let r = start[0];
		let c = start[1];

		// Bootstrap intendedCol from the current column so the first
		// vertical move preserves whatever column the user is already on.
		if (intendedCol === null) intendedCol = c < 0 ? 0 : c;

		if (key === 'ArrowLeft' || key === 'ArrowRight') {
			c += key === 'ArrowRight' ? 1 : -1;
			// Wrap across row boundaries (LeftArrow at col 0 → previous
			// row's last col, RightArrow at last col → next row's col 0).
			while (c < 0 && r > 0) {
				r -= 1;
				c = rows[r].uids.length - 1;
			}
			while (r < rows.length && c >= rows[r].uids.length) {
				if (r === rows.length - 1) {
					c = rows[r].uids.length - 1;
					break;
				}
				r += 1;
				c = 0;
			}
			if (c < 0) c = 0;
			// User explicitly chose this column → it's the new "home" for
			// subsequent vertical moves.
			intendedCol = c;
		} else {
			r += key === 'ArrowDown' ? 1 : -1;
			if (r < 0) r = 0;
			if (r >= rows.length) r = rows.length - 1;
			// Vertical: prefer the intended column; clamp to destination
			// row width so a narrower row doesn't fall off the edge.
			// intendedCol is preserved so going down through narrow rows
			// then back up returns to the original column.
			const w = rows[r].uids.length;
			c = Math.min(intendedCol, w - 1);
			if (c < 0) c = 0;
		}

		const destUid = rows[r]?.uids[c];
		if (!destUid) return;

		setFocused(destUid);
		if (!extending) setAnchor(destUid);
		if (extending && selection.focused) selectRange(destUid);

		// Try the cheap path first: rendered tile, our scroll helper that
		// respects the sticky header. Fall back to scrollToIndex (windowing
		// expand + scroll) when the destination is currently unmounted.
		const tile = scrollRoot?.querySelector<HTMLElement>(`[data-uid="${destUid}"]`);
		if (tile) {
			scrollTileIntoView(tile);
		} else {
			const flatIdx = rows[r].firstTileIndex + c;
			scrollToIndex(flatIdx);
		}
	}

	const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
		queryKey: ['photo', selection.focused ?? ''],
		queryFn: () =>
			selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
		enabled: isAuthenticated() && Boolean(selection.focused),
		staleTime: 0
	}));

	const qc = useQueryClient();
	let emptyingArchive = $state(false);

	/**
	 * Walk every archived photo and delete it in pages. The timeline's
	 * infinite query only holds what the user has scrolled; we re-query
	 * `archived:true` directly so even unloaded pages get cleared.
	 * PhotoPrism caps `count` at 1000 — pull the max each round so we
	 * spend one HTTP call per chunk.
	 */
	async function onEmptyArchive() {
		if (emptyingArchive) return;
		if (
			!confirm(
				'Permanently delete EVERY photo in the Archive? This cannot be undone.'
			)
		) {
			return;
		}
		emptyingArchive = true;
		let total = 0;
		try {
			while (true) {
				const batch = await listPhotos({
					q: 'archived:true',
					count: 1000,
					offset: 0,
					order: 'newest',
					merged: false
				});
				if (batch.length === 0) break;
				const uids = Array.from(new Set(batch.map((p) => p.UID)));
				await batchDelete(uids);
				total += uids.length;
			}
			toast.success(total === 0 ? 'Archive already empty' : `Deleted ${total}`);
		} catch (err) {
			toast.error(err instanceof Error ? err.message : 'Empty archive failed');
		} finally {
			emptyingArchive = false;
			void qc.invalidateQueries({ queryKey: ['photos'] });
		}
	}

	function onTileClick(e: MouseEvent, uid: string) {
		// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
		// document-level click handler — let them bubble.
		if (e.shiftKey || e.metaKey || e.ctrlKey) return;
		// Plain click: select this tile only. Replaces the previous
		// "click opens preview" semantics — preview now lives on dblclick.
		// Reset both focus and anchor so a subsequent shift-click extends
		// the range from this tile, and clear sticky-column intent so
		// arrow nav re-anchors off this tile's actual column.
		selection.ids.clear();
		selection.ids.add(uid);
		setFocused(uid);
		setAnchor(uid);
		intendedCol = null;
	}

	function onTileDblclick(e: MouseEvent, uid: string) {
		// Modifier-modified dblclicks shouldn't open the preview either —
		// gridKeyNav already handled the underlying click.
		if (e.shiftKey || e.metaKey || e.ctrlKey) return;
		e.preventDefault();
		openPreview(uid, photos.map((p) => p.UID));
	}

	// Single-click fallback for the dblclick preview gesture. Wired to the
	// hover-only Maximize icon in PhotoTile so users who haven't discovered
	// dblclick can still get to the preview.
	function onTileOpenPreview(uid: string) {
		openPreview(uid, photos.map((p) => p.UID));
	}

	// Scroll root for the infinite-scroll IntersectionObserver. Bound by
	// the <main> element below; the sentinel's `root` references this so
	// the observer measures intersections relative to the timeline pane
	// (the page itself doesn't scroll).
	let scrollRoot: HTMLElement | undefined = $state();

	let searchDraft = $state(filters.search);
	$effect(() => {
		searchDraft = filters.search;
	});
	function onSearchSubmit(e: SubmitEvent) {
		e.preventDefault();
		setSearch(searchDraft.trim());
	}

	// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
	// focus turns the placeholder hint into a clickable cheat-sheet.
	const SEARCH_EXAMPLES = [
		'label:dog',
		'keyword:vacation',
		'taken:2024',
		'"exact phrase"'
	];
	let searchFocused = $state(false);
	function onSearchFocus() {
		searchFocused = true;
	}
	function onSearchBlur() {
		// Defer so a click on an example fires before the popover unmounts.
		setTimeout(() => (searchFocused = false), 120);
	}
	function applySearchExample(ex: string) {
		searchDraft = ex;
		setSearch(ex);
		searchFocused = false;
	}
</script>

<Toolbar showRightToggle>
	<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
		{sectionLabel}
	</span>
	<!--
		Persistent gesture hint. The new click-semantics (single = select,
		double = open) aren't intuitive for users arriving from Google
		Photos / Apple Photos, so surface them in plain text where the eye
		can see them without hover. Hidden below sm: so the search bar
		still gets room on narrow viewports.
	-->
	{#if photos.length > 0}
		<span class="hidden text-[10px] text-muted-foreground lg:inline">
			click select · ⇧ range · ⌘ toggle · dblclick open
		</span>
	{/if}
	{#if filters.section === 'archive' && photos.length > 0}
		<button
			type="button"
			class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
			disabled={emptyingArchive}
			onclick={onEmptyArchive}
			title="Permanently delete every archived photo"
		>
			{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
		</button>
	{/if}
	<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
		<input
			type="search"
			placeholder='Search · label:website / "vacation"'
			class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
			bind:value={searchDraft}
			onfocus={onSearchFocus}
			onblur={onSearchBlur}
		/>
		<button
			type="submit"
			class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
		>
			Go
		</button>
		{#if filters.search}
			<button
				type="button"
				class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
				onclick={() => {
					searchDraft = '';
					setSearch('');
				}}
				title="Clear search"
			>
				✕
			</button>
		{/if}
		<!--
			Cheat-sheet popover: opens on input focus, lists working q-DSL
			patterns. Clicking an example fills the input AND fires the
			search, so it doubles as a one-click "try it" affordance.
		-->
		{#if searchFocused}
			<div
				class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
			>
				<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
					Examples
				</div>
				{#each SEARCH_EXAMPLES as ex (ex)}
					<button
						type="button"
						class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
						onmousedown={(e) => {
							e.preventDefault();
							applySearchExample(ex);
						}}
					>
						{ex}
					</button>
				{/each}
			</div>
		{/if}
	</form>

	{#snippet trailing()}
		<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
		     Persisted to localStorage via view.svelte.ts. -->
		<div
			class="flex items-center overflow-hidden rounded border border-border"
			role="group"
			aria-label="Thumbnail size"
		>
			{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
				<button
					type="button"
					class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
					class:bg-accent={view.thumbnailSize === size}
					class:text-foreground={view.thumbnailSize === size}
					class:text-muted-foreground={view.thumbnailSize !== size}
					onclick={() => setThumbnailSize(size)}
					title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
				>
					{THUMBNAIL_SIZE_LABELS[i]}
				</button>
			{/each}
		</div>

	{/snippet}
</Toolbar>

<div class="flex min-h-0 flex-1">
	<!--
		Main column wraps the scrollable timeline and the action bar so
		the bar's width matches the timeline only — the right aside is a
		sibling at row level and stays full height when the bar appears.
	-->
	<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
	<main
		bind:this={scrollRoot}
		class="flex-1 overflow-y-auto outline-none focus:outline-none"
		use:gridKeyNav={{ scrollToIndex, onArrow }}
		use:visibleRange={{
			onChange: (f, l) => {
				visFirst = f;
				visLast = l;
			},
			sampleEvery: TILE_SAMPLE
		}}
	>
	  <div class="p-6 pb-24">
		{#if photosQuery.isPending}
			<SkeletonGrid />
		{:else if photosQuery.isError}
			<p class="text-sm text-destructive">
				Failed to load photos: {photosQuery.error instanceof Error
					? photosQuery.error.message
					: 'unknown error'}
			</p>
		{:else if photos.length === 0}
			<p class="text-sm text-muted-foreground">
				{#if filters.section === 'archive'}
					Archive is empty.
				{:else if filters.section === 'favorites'}
					No favorites yet. Heart a photo to add it here.
				{:else if filters.section === 'review'}
					Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
					land here — use Keep to accept them into the timeline or Archive to
					set them aside.
				{:else if filters.section === 'hidden'}
					No hidden photos. PhotoPrism auto-hides files it can't index (broken
					files, very low quality); they only ever show up here.
				{:else if filters.section === 'heap'}
					This heap has no photos yet. Select some photos and use the bulk bar's
					"＋ Add to heap" button.
				{:else}
					No photos. Index a folder via PhotoPrism's reindex command.
				{/if}
			</p>
		{:else}
			<div
				data-photo-grid
				use:trackGridCols
				class="grid gap-2"
				style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
			>
				{#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)}
					{#if row.kind === 'header'}
						<!-- col-span-full + position:sticky pins the month label to
						     the top of the scrolling main as the user passes through.
						     -mx-6 stretches the bar past the wrapper padding so it
						     reads edge-to-edge in the viewport. -->
						<h2
							class="sticky top-0 z-10 -mx-6 border-b border-border bg-background/95 px-6 py-2 text-xs font-semibold uppercase tracking-wide text-foreground/80 backdrop-blur"
							style="grid-column: 1 / -1;"
						>
							{row.label}
							<span class="ml-2 text-[10px] font-normal text-muted-foreground">
								{row.count}
							</span>
						</h2>
					{:else}
						{@const photo = row.photo}
						{@const i = row.tileIndex}
						{@const inWindow = i >= renderFirst && i <= renderLast}
						<!-- Shell: always rendered. Holds grid-cell space + the
						     stable `data-uid-shell` anchor that gridKeyNav.scrollToIndex
						     can query even when the inner button is windowed out. -->
						<div
							data-uid-shell={photo.UID}
							class="aspect-square"
							use:tileRegister={i}
						>
							{#if inWindow}
								{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID}
								<PhotoTile
									{photo}
									selected={sel}
									onClick={(e) => onTileClick(e, photo.UID)}
									onDblclick={(e) => onTileDblclick(e, photo.UID)}
									onOpenPreview={() => onTileOpenPreview(photo.UID)}
								/>
							{/if}
						</div>
					{/if}
				{/each}
			</div>

			<!-- Sentinel: when this div nears the viewport we kick the next
			     page. The 4-viewport rootMargin (handled by the action) is
			     PhotoPrism's preload distance from page/photos.vue. -->
			<div
				aria-hidden="true"
				class="h-4"
				use:nearBottom={{
					onHit: () => photosQuery.fetchNextPage(),
					enabled: photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
					root: scrollRoot
				}}
			></div>
			{#if photosQuery.isFetchingNextPage}
				<p class="py-3 text-center text-xs text-muted-foreground">Loading more…</p>
			{/if}
		{/if}
		</div>
	</main>
	<BulkActionBar />
	</div>

	{#if !view.rightSidebarCollapsed}
		<aside
			class="relative h-full shrink-0 border-l border-border bg-card/30"
			style="width: {view.rightSidebarWidth}px;"
		>
			<div class="h-full overflow-y-auto">
				{#if selection.ids.size >= 2}
					<!-- Multi-select swaps to a bulk-edit panel: edits fan out across
					     the whole selection (note / date / keyword). The single-photo
					     metadata view returns when the user drops back to one. -->
					<BulkMetadataSidebar ids={Array.from(selection.ids)} />
				{:else if focusedPhotoQuery.data}
					<RightSidebar photo={focusedPhotoQuery.data} />
				{:else if focusedPhotoQuery.isFetching}
					<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
				{:else}
					<div class="space-y-2 p-4 text-center">
						<div class="text-xl">ⓘ</div>
						<p class="text-xs text-muted-foreground">
							Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a thumbnail
							to view its metadata here.
						</p>
					</div>
				{/if}
			</div>
			<!-- Resize handle on the left edge; mirrors the layout's left aside
			     hot-zone for symmetry. -->
			<div
				class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
				use:resizable={{
					edge: 'left',
					getWidth: () => view.rightSidebarWidth,
					setWidth: setRightSidebarWidth
				}}
				role="separator"
				aria-orientation="vertical"
				aria-label="Resize info panel"
			>
				<div
					class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
				></div>
			</div>
		</aside>
	{/if}
</div>
