<script lang="ts" generics="P extends GeoJSON.GeoJsonProperties">
	import { getContext } from 'svelte';
	import MapLibreGL from 'maplibre-gl';
	import { generateUUID } from '$lib/utils/uuid';

	interface Props {
		/** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
		data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>;
		/** Maximum zoom level to cluster points on (default: 14) */
		clusterMaxZoom?: number;
		/** Radius of each cluster when clustering points in pixels (default: 50) */
		clusterRadius?: number;
		/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */
		clusterColors?: [string, string, string];
		/** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
		clusterThresholds?: [number, number];
		/** Color for unclustered individual points (default: "#3b82f6") */
		pointColor?: string;
		/** Callback when an unclustered point is clicked */
		onpointclick?: (
			feature: GeoJSON.Feature<GeoJSON.Point, P>,
			coordinates: [number, number]
		) => void;
		/** Callback when a cluster is clicked. If not provided, zooms into the cluster */
		onclusterclick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void;
	}

	const {
		data,
		clusterMaxZoom = 14,
		clusterRadius = 50,
		clusterColors = ['#22c55e', '#eab308', '#ef4444'],
		clusterThresholds = [100, 750],
		pointColor = '#3b82f6',
		onpointclick,
		onclusterclick
	}: Props = $props();

	const mapCtx = getContext<{
		getMap: () => MapLibreGL.Map | null;
		isStyleReady: () => boolean;
	}>('map');

	const id = generateUUID();
	const sourceId = $derived(`cluster-source-${id}`);
	const clusterLayerId = $derived(`clusters-${id}`);
	const clusterCountLayerId = $derived(`cluster-count-${id}`);
	const unclusteredLayerId = $derived(`unclustered-point-${id}`);

	// Add source and layers when map is ready
	$effect(() => {
		const map = mapCtx.getMap();
		const loaded = mapCtx.isStyleReady();

		if (!loaded || !map) return;

		// Remove existing layers and source if they exist
		try {
			if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
			if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
			if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
			if (map.getSource(sourceId)) map.removeSource(sourceId);
		} catch {
			// ignore
		}

		// Add clustered GeoJSON source
		map.addSource(sourceId, {
			type: 'geojson',
			data,
			cluster: true,
			clusterMaxZoom,
			clusterRadius
		});

		// Add cluster circles layer
		map.addLayer({
			id: clusterLayerId,
			type: 'circle',
			source: sourceId,
			filter: ['has', 'point_count'],
			paint: {
				'circle-color': [
					'step',
					['get', 'point_count'],
					clusterColors[0],
					clusterThresholds[0],
					clusterColors[1],
					clusterThresholds[1],
					clusterColors[2]
				],
				'circle-radius': [
					'step',
					['get', 'point_count'],
					20,
					clusterThresholds[0],
					30,
					clusterThresholds[1],
					40
				],
				'circle-stroke-width': 1,
				'circle-stroke-color': '#fff',
				'circle-opacity': 0.85
			}
		});

		// Add cluster count text layer
		map.addLayer({
			id: clusterCountLayerId,
			type: 'symbol',
			source: sourceId,
			filter: ['has', 'point_count'],
			layout: {
				'text-field': '{point_count_abbreviated}',
				'text-font': ['Open Sans'],
				'text-size': 12
			},
			paint: {
				'text-color': '#fff'
			}
		});

		// Add unclustered point layer
		map.addLayer({
			id: unclusteredLayerId,
			type: 'circle',
			source: sourceId,
			filter: ['!', ['has', 'point_count']],
			paint: {
				'circle-color': pointColor,
				'circle-radius': 5,
				'circle-stroke-width': 2,
				'circle-stroke-color': '#fff'
			}
		});

		return () => {
			try {
				if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
				if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
				if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
				if (map.getSource(sourceId)) map.removeSource(sourceId);
			} catch {
				// ignore
			}
		};
	});

	// Update source data when data prop changes (only for non-URL data)
	$effect(() => {
		const map = mapCtx.getMap();
		const loaded = mapCtx.isStyleReady();

		if (!loaded || !map || typeof data === 'string') return;

		const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
		if (source) {
			source.setData(data);
		}
	});

	// Update layer styles when props change
	$effect(() => {
		const map = mapCtx.getMap();
		const loaded = mapCtx.isStyleReady();

		if (!loaded || !map) return;

		// Update cluster layer colors and sizes
		if (map.getLayer(clusterLayerId)) {
			map.setPaintProperty(clusterLayerId, 'circle-color', [
				'step',
				['get', 'point_count'],
				clusterColors[0],
				clusterThresholds[0],
				clusterColors[1],
				clusterThresholds[1],
				clusterColors[2]
			]);
			map.setPaintProperty(clusterLayerId, 'circle-radius', [
				'step',
				['get', 'point_count'],
				20,
				clusterThresholds[0],
				30,
				clusterThresholds[1],
				40
			]);
		}

		// Update unclustered point layer color
		if (map.getLayer(unclusteredLayerId)) {
			map.setPaintProperty(unclusteredLayerId, 'circle-color', pointColor);
		}
	});

	// Handle click events
	$effect(() => {
		const map = mapCtx.getMap();
		const loaded = mapCtx.isStyleReady();

		if (!loaded || !map) return;

		// Cluster click handler - zoom into cluster
		const handleClusterClick = async (
			e: MapLibreGL.MapMouseEvent & {
				features?: MapLibreGL.MapGeoJSONFeature[];
			}
		) => {
			const features = map.queryRenderedFeatures(e.point, {
				layers: [clusterLayerId]
			});
			if (!features.length) return;

			const feature = features[0];
			const clusterId = feature.properties?.cluster_id as number;
			const pointCount = feature.properties?.point_count as number;
			const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number];

			if (onclusterclick) {
				onclusterclick(clusterId, coordinates, pointCount);
			} else {
				// Default behavior: zoom to cluster expansion zoom
				const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
				const zoom = await source.getClusterExpansionZoom(clusterId);
				map.easeTo({
					center: coordinates,
					zoom
				});
			}
		};

		// Unclustered point click handler
		const handlePointClick = (
			e: MapLibreGL.MapMouseEvent & {
				features?: MapLibreGL.MapGeoJSONFeature[];
			}
		) => {
			if (!onpointclick || !e.features?.length) return;

			const feature = e.features[0];
			const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
				number,
				number
			];

			// Handle world copies
			while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
				coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
			}

			onpointclick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates);
		};

		// Cursor style handlers
		const handleMouseEnterCluster = () => {
			map.getCanvas().style.cursor = 'pointer';
		};
		const handleMouseLeaveCluster = () => {
			map.getCanvas().style.cursor = '';
		};
		const handleMouseEnterPoint = () => {
			if (onpointclick) {
				map.getCanvas().style.cursor = 'pointer';
			}
		};
		const handleMouseLeavePoint = () => {
			map.getCanvas().style.cursor = '';
		};

		map.on('click', clusterLayerId, handleClusterClick);
		map.on('click', unclusteredLayerId, handlePointClick);
		map.on('mouseenter', clusterLayerId, handleMouseEnterCluster);
		map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster);
		map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
		map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint);

		return () => {
			map.off('click', clusterLayerId, handleClusterClick);
			map.off('click', unclusteredLayerId, handlePointClick);
			map.off('mouseenter', clusterLayerId, handleMouseEnterCluster);
			map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster);
			map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
			map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
		};
	});
</script>
