<script lang="ts">
	import { onMount } from 'svelte';
	import { createQuery } from '@tanstack/svelte-query';
	import maplibregl, {
		type GeoJSONSource,
		type MapMouseEvent,
		type MapSourceDataEvent
	} from 'maplibre-gl';
	import 'maplibre-gl/dist/maplibre-gl.css';
	import { goto } from '$app/navigation';
	import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
	import { isAuthenticated, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
	import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
	import Toolbar from '$lib/components/layout/Toolbar.svelte';

	// Helper to quote if needed (matches filters.svelte.ts logic)
	function quoteIfNeeded(v: string): string {
		if (!v) return '';
		if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v;
		return `"${v.replace(/"/g, '\\"')}"`;
	}

	const geoQuery = createQuery<PpGeoCollection>(() => {
		const bp = userBasePath();
		// Build path filter: add wildcard first, then quote if needed
		const q = bp ? `path:${quoteIfNeeded(bp + '*')}` : '';
		return {
			queryKey: ['geo', bp],
			queryFn: () => listGeo(q),
			enabled: isAuthenticated()
		};
	});

	let mapEl: HTMLDivElement | undefined = $state();
	let map: maplibregl.Map | undefined;
	/** Reactive flag flipped on once the MapLibre `load` event has fired
	 *  and the `photos` source has been installed. The data-push `$effect`
	 *  depends on this — otherwise, if the geoQuery resolves before the
	 *  basemap style finishes loading, the effect runs with no source
	 *  available and never re-runs (since `map` itself is not `$state`),
	 *  leaving the map permanently empty. */
	let mapReady = $state(false);

	/** Markers currently attached to the map, keyed by feature id (UIDs
	 *  for photos, `cluster:<clusterId>` for clusters). Diffed against the
	 *  current `querySourceFeatures` set on every render to add markers
	 *  that came into view and remove ones that scrolled out / got
	 *  swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
	 *  See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
	const markers = new Map<string, maplibregl.Marker>();
	const markersOnScreen = new Map<string, maplibregl.Marker>();

	onMount(() => {
		if (!mapEl) return;
		map = new maplibregl.Map({
			container: mapEl,
			// PhotoPrism's default basemap style (CDN-hosted, no key required).
			// The style JSON already references the correct glyphs URL, so
			// no explicit override is needed here.
			style: 'https://cdn.photoprism.app/maps/default.json',
			center: [0, 20],
			zoom: 1,
			attributionControl: { compact: true }
		});
		map.addControl(
			new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
			'top-right'
		);
		map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');

		map.on('load', () => {
			addPhotoLayers();
			mapReady = true;
		});

		// PhotoPrism's update strategy: re-reconcile markers on every map
		// movement, on resize (so cluster bubbles re-balance when the
		// viewport changes), on idle (catches the post-`fitBounds` settle),
		// and on `sourcedata` filtered to "source fully loaded" — that's
		// the moment MapLibre has processed clustering and
		// `querySourceFeatures` returns meaningful results.
		const onSourceData = (e: MapSourceDataEvent) => {
			if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
		};
		map.on('sourcedata', onSourceData);
		map.on('move', updateMarkers);
		map.on('moveend', updateMarkers);
		map.on('resize', updateMarkers);
		map.on('idle', updateMarkers);

		return () => {
			map?.off('sourcedata', onSourceData);
			map?.off('move', updateMarkers);
			map?.off('moveend', updateMarkers);
			map?.off('resize', updateMarkers);
			map?.off('idle', updateMarkers);
			markersOnScreen.forEach((m) => m.remove());
			markersOnScreen.clear();
			markers.clear();
			map?.remove();
			map = undefined;
			mapReady = false;
		};
	});

	function addPhotoLayers() {
		if (!map) return;
		map.addSource('photos', {
			type: 'geojson',
			data: { type: 'FeatureCollection', features: [] },
			cluster: true,
			// PhotoPrism's clustering parameters — points within ~80px merge
			// below zoom 17, individual photos render above that.
			clusterMaxZoom: 17,
			clusterRadius: 80
		});
		// Invisible layer for clusters — PhotoPrism does this so the source
		// reports cluster features via `querySourceFeatures` (which only
		// returns features actually rendered by some layer) while the
		// visual presentation is owned by HTML markers below.
		map.addLayer({
			id: 'clusters',
			type: 'circle',
			source: 'photos',
			filter: ['has', 'point_count'],
			paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
		});
		// Click an (invisible) cluster anywhere on the map → zoom to its
		// expansion level. The marker DOM also has a click handler, but
		// pointer-through to the map needs this as a fallback.
		map.on('click', 'clusters', (e: MapMouseEvent) => {
			const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
			const clusterId = features[0]?.properties?.cluster_id;
			if (clusterId == null) return;
			const source = map!.getSource('photos') as GeoJSONSource;
			source.getClusterExpansionZoom(clusterId).then((zoom) => {
				const geometry = features[0]?.geometry;
				if (!geometry || geometry.type !== 'Point') return;
				map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
			});
		});
	}

	/** Cluster bubble diameter, scaled by the number of contained photos
	 *  — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
	function clusterSize(count: number): number {
		if (count >= 10000) return 74;
		if (count >= 1000) return 70;
		if (count >= 750) return 68;
		if (count >= 200) return 66;
		if (count >= 100) return 64;
		return 60;
	}

	/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
	function abbreviateCount(value: number): string {
		if (value >= 1000) return `${Math.round(value / 1000)}k`;
		return String(value);
	}

	function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
		const el = document.createElement('div');
		el.className = 'marker';
		if (title) el.title = title;
		el.style.width = '50px';
		el.style.height = '50px';
		el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
		el.addEventListener('click', (ev) => {
			ev.stopPropagation();
			setOrder(allUids);
			setFocused(uid);
			setAnchor(uid);
			void goto('/');
		});
		return el;
	}

	function buildClusterMarker(clusterId: number, count: number) {
		const size = clusterSize(count);
		const el = document.createElement('div');
		el.className = 'marker';
		el.style.width = `${size}px`;
		el.style.height = `${size}px`;

		const grid = document.createElement('div');
		grid.className = 'cluster-marker';
		el.appendChild(grid);

		const badge = document.createElement('div');
		badge.className = 'badge';
		badge.textContent = abbreviateCount(count);
		el.appendChild(badge);

		// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
		// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
		// source is captured once here; `getClusterLeaves` returns a
		// Promise, so this populates asynchronously and the bubble shows a
		// dark placeholder until the thumbs arrive.
		if (map) {
			const source = map.getSource('photos') as GeoJSONSource | undefined;
			if (source && typeof source.getClusterLeaves === 'function') {
				source
					.getClusterLeaves(clusterId, 4, 0)
					.then((leaves) => {
						const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
						grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
						for (let i = 0; i < previewCount; i++) {
							const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
							const props = (leaf?.properties ?? {}) as { Hash?: string };
							if (!props.Hash) continue;
							const tile = document.createElement('div');
							tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
							grid.appendChild(tile);
						}
					})
					.catch(() => {});
			}
		}

		el.addEventListener('click', (ev) => {
			ev.stopPropagation();
			if (!map) return;
			const source = map.getSource('photos') as GeoJSONSource;
			source.getClusterExpansionZoom(clusterId).then((zoom) => {
				// Use the marker's current LngLat — set just below in updateMarkers.
				const m = markers.get(`cluster:${clusterId}`);
				const ll = m?.getLngLat();
				if (!ll) return;
				map!.easeTo({ center: ll, zoom });
			});
		});
		return el;
	}

	/** Reconcile HTML markers against what's currently in the rendered
	 *  source. PhotoPrism's `updateMarkers`. */
	function updateMarkers() {
		if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
		const features = map.querySourceFeatures('photos');
		const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
		const seen = new Set<string>();

		for (const f of features) {
			const props = (f.properties ?? {}) as Record<string, unknown> & {
				cluster?: boolean;
				cluster_id?: number;
				point_count?: number;
				UID?: string;
				Hash?: string;
				Title?: string;
			};
			const geom = f.geometry;
			if (geom.type !== 'Point') continue;
			const coords = geom.coordinates as [number, number];

			let key: string;
			let buildEl: () => HTMLElement;
			if (props.cluster) {
				if (props.cluster_id == null) continue;
				key = `cluster:${props.cluster_id}`;
				const cid = props.cluster_id;
				const count = props.point_count ?? 0;
				buildEl = () => buildClusterMarker(cid, count);
			} else {
				if (!props.UID || !props.Hash) continue;
				key = props.UID;
				const uid = props.UID;
				const hash = props.Hash;
				const title = props.Title;
				buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
			}

			seen.add(key);
			let marker = markers.get(key);
			if (!marker) {
				marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
				markers.set(key, marker);
			} else {
				marker.setLngLat(coords);
			}
			if (!markersOnScreen.has(key)) {
				marker.addTo(map);
				markersOnScreen.set(key, marker);
			}
		}

		for (const [key, marker] of markersOnScreen) {
			if (!seen.has(key)) {
				marker.remove();
				markersOnScreen.delete(key);
			}
		}
	}

	// Push new geo data into the source whenever the query resolves AND
	// the map is ready. Both orderings are handled: if data arrives first,
	// the effect re-runs when `mapReady` flips; if the map is ready first,
	// it re-runs when `data` arrives.
	$effect(() => {
		const data = geoQuery.data as
			| (PpGeoCollection & { bbox?: number[] })
			| undefined;
		if (!map || !mapReady || !data) return;
		const src = map.getSource('photos') as GeoJSONSource | undefined;
		if (!src) return;
		src.setData(data as GeoJSON.FeatureCollection);

		// Drop stale markers; updateMarkers will rebuild for the current
		// visible set on the next `sourcedata` (fired by setData) or `idle`.
		markersOnScreen.forEach((m) => m.remove());
		markersOnScreen.clear();
		markers.clear();

		// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
		// (+ optional `zoom`, `focus`) flies the map directly to the photo
		// rather than fitting to the full library extent. Strip the params
		// afterwards so a manual zoom-out + reload doesn't snap back. Falls
		// through to the default fitBounds when the params aren't present.
		const sp = new URL(window.location.href).searchParams;
		const latParam = Number(sp.get('lat'));
		const lngParam = Number(sp.get('lng'));
		if (
			(data.features?.length ?? 0) > 0 &&
			Number.isFinite(latParam) &&
			Number.isFinite(lngParam) &&
			sp.has('lat') &&
			sp.has('lng')
		) {
			const zoom = Number(sp.get('zoom')) || 17;
			map.jumpTo({ center: [lngParam, latParam], zoom });
			const stripped = new URL(window.location.href);
			stripped.searchParams.delete('lat');
			stripped.searchParams.delete('lng');
			stripped.searchParams.delete('zoom');
			stripped.searchParams.delete('focus');
			const qs = stripped.searchParams.toString();
			void goto(`/map${qs ? `?${qs}` : ''}`, {
				replaceState: true,
				keepFocus: true,
				noScroll: true
			});
			return;
		}

		// Fit to data extent on the first non-empty load — prefer the
		// server-provided bbox (PhotoPrism returns one), else compute from
		// the features.
		if ((data.features?.length ?? 0) > 0) {
			let bounds: maplibregl.LngLatBoundsLike | null = null;
			if (Array.isArray(data.bbox) && data.bbox.length === 4) {
				bounds = [
					[data.bbox[0], data.bbox[1]],
					[data.bbox[2], data.bbox[3]]
				];
			} else {
				const b = new maplibregl.LngLatBounds();
				for (const f of data.features as PpGeoFeature[]) {
					const c = f.geometry.coordinates as [number, number];
					if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
				}
				if (!b.isEmpty()) bounds = b;
			}
			if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
		}
	});
</script>

<Toolbar>
	<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
		Map
	</span>
	{#snippet trailing()}
		<span class="text-[11px] text-muted-foreground">
			{geoQuery.data?.features?.length ?? 0} geotagged
		</span>
	{/snippet}
</Toolbar>

<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>

<style>
	/* PhotoPrism's marker / cluster styling, ported from
	   frontend/src/css/places.css. `:global` because MapLibre appends
	   markers outside Svelte's scoped CSS reach. */
	:global(.maplibregl-map .marker) {
		display: block;
		border-radius: 50%;
		cursor: pointer;
		border: 1px solid #ffffff99;
		background-color: rgba(23, 23, 23, 0.23);
		background-size: cover;
		background-position: center;
		overflow: hidden;
		position: relative;
		box-shadow:
			0px 3px 1px -2px rgba(0, 0, 0, 0.2),
			0px 2px 2px 0px rgba(0, 0, 0, 0.14),
			0px 1px 5px 0px rgba(0, 0, 0, 0.12);
	}
	:global(.maplibregl-map .cluster-marker) {
		display: grid;
		grid-template-columns: 1fr 1fr;
		grid-gap: 1px;
		overflow: hidden;
		width: 100%;
		height: 100%;
		border-radius: 50%;
	}
	:global(.maplibregl-map .cluster-marker > div) {
		width: 100%;
		height: 100%;
		background-size: cover;
		background-position: center;
	}
	:global(.maplibregl-map .badge) {
		position: absolute;
		top: -5px;
		right: -5px;
		min-width: 24px;
		height: 24px;
		padding: 0 6px;
		border-radius: 999px;
		display: flex;
		align-items: center;
		justify-content: center;
		font-size: 12px;
		font-weight: 600;
		color: #ffffff;
		background: #53478a;
		box-shadow:
			0px 3px 1px -2px rgba(0, 0, 0, 0.2),
			0px 2px 2px 0px rgba(0, 0, 0, 0.14),
			0px 1px 5px 0px rgba(0, 0, 0, 0.12);
	}
</style>
