/**
 * GoogleProvider — Google Maps adapter implementing the same MapProvider
 * surface as LeafletProvider, so the host frontend is map-agnostic. Uses the
 * Google Maps JS API loaded globally as `window.google` (enqueued by the plugin
 * when the provider is Google and an API key is set). Clustering uses
 * @googlemaps/markerclusterer (window.markerClusterer) when present.
 */
import { MapProvider } from './MapProvider';
import { clusterSvgDataUri, clusterSize } from './clusterIcons';

export class GoogleProvider extends MapProvider {
	init() {
		const g = window.google;
		if ( ! g || ! g.maps ) {
			return false; // API not ready yet — caller retries.
		}
		this.g = g;
		// Coerce to finite numbers — `??` alone lets '' / NaN through and Google
		// then throws "setZoom: not a number" / bad center.
		const num = ( v, d ) => {
			const n = parseFloat( v );
			return Number.isFinite( n ) ? n : d;
		};
		this.map = new g.maps.Map( this.el, {
			center: { lat: num( this.opts.centerLat, 0 ), lng: num( this.opts.centerLng, 0 ) },
			zoom: num( this.opts.zoom, 4 ),
			mapTypeId: this.opts.mapType || 'roadmap',
			scrollwheel: this.opts.scrollWheel !== false,
			streetViewControl: false,
			mapTypeControl: false,
			fullscreenControl: this.opts.fullscreenControl !== false,
			// Google's base map tiles render their own clickable POI icons (real
			// businesses Google knows about). One can sit under a store's marker;
			// without this, clicking it opens GOOGLE'S OWN default info window
			// (a server round-trip, hence the delay) which then covers our custom
			// popup that opened instantly on the same click.
			clickableIcons: false,
			// JSON theme (Google sample / Snazzy / custom) supplied by the host.
			styles: Array.isArray( this.opts.styles ) ? this.opts.styles : undefined,
		} );
		this.markers = new Map();
		this.info = new g.maps.InfoWindow();
		this.watchDirectionSteps();
		return true;
	}

	// The DirectionsRenderer pops a Google InfoWindow (.gm-iw) with the step text
	// when a route step is clicked — Google-generated markup we can't class at
	// creation. Tag each one with `cliq-direction-step` so it's styleable.
	watchDirectionSteps() {
		if ( typeof MutationObserver === 'undefined' || ! this.el ) return;
		const tag = ( root ) => {
			if ( root.nodeType !== 1 ) return;
			if ( root.classList?.contains( 'gm-iw' ) ) root.classList.add( 'cliq-direction-step' );
			root.querySelectorAll?.( '.gm-iw' ).forEach( ( el ) => el.classList.add( 'cliq-direction-step' ) );
		};
		this.stepObserver = new MutationObserver( ( mutations ) => {
			for ( const m of mutations ) {
				m.addedNodes.forEach( tag );
			}
		} );
		this.stepObserver.observe( this.el, { childList: true, subtree: true } );
	}

	iconFor( record ) {
		const m = this.markerOf( record );
		if ( ! m || ! m.icon_url ) return undefined;
		let w = m.width || 32;
		let h = m.height || 40;
		// Global marker-size setting scales every custom icon to a target height,
		// keeping the icon's aspect ratio.
		const target = Number( this.opts.markerSize );
		if ( Number.isFinite( target ) && target > 0 && h > 0 ) {
			w = Math.round( w * ( target / h ) );
			h = target;
		}
		return { url: m.icon_url, scaledSize: new this.g.maps.Size( w, h ), anchor: new this.g.maps.Point( w / 2, h ) };
	}

	// A minimal HTML-content marker built directly on google.maps.OverlayView —
	// the same technique the (now-archived, unmaintained) RichMarker library
	// used, done in-house so there's no third-party dependency and, crucially,
	// no Map ID requirement (unlike google.maps.marker.AdvancedMarkerElement,
	// which silently fails to render without one). Duck-types legacy Marker's
	// surface (setMap/getPosition, a `.content` node for animateMarker's
	// CSS-class toggle) closely enough that the rest of this file — clustering,
	// fitToMarkers, openStore, destroy — treats it identically to a legacy
	// Marker. Built lazily since it extends google.maps.OverlayView, which only
	// exists once the Maps JS API has loaded (i.e. after init()).
	htmlMarkerClass() {
		if ( this._HtmlMarker ) return this._HtmlMarker;
		const g = this.g;
		class HtmlMarker extends g.maps.OverlayView {
			constructor( { position, content, map } ) {
				super();
				this.position_ = position;
				this.content = content;
				this.content.style.position = 'absolute';
				if ( map ) this.setMap( map );
			}
			onAdd() {
				this.getPanes().overlayMouseTarget.appendChild( this.content );
				this.content.addEventListener( 'click', () => g.maps.event.trigger( this, 'click' ) );
			}
			draw() {
				const point = this.getProjection()?.fromLatLngToDivPixel( this.position_ );
				if ( point ) {
					this.content.style.left = `${ point.x }px`;
					this.content.style.top = `${ point.y }px`;
				}
			}
			onRemove() {
				if ( this.content.parentNode ) this.content.parentNode.removeChild( this.content );
			}
			getPosition() {
				return this.position_;
			}
			// @googlemaps/markerclusterer calls this on every non-AdvancedMarkerElement
			// marker to count cluster membership — always visible once added/removed
			// via setMap, same semantics the library itself uses for Advanced Markers.
			getVisible() {
				return true;
			}
		}
		this._HtmlMarker = HtmlMarker;
		return this._HtmlMarker;
	}

	setMarkers( records = [], { onMarkerClick, renderPopup, renderMarkerHtml } = {} ) {
		if ( ! this.map ) return;
		// Clear previous markers. `clearMarkers(true)` skips the clusterer's
		// internal re-render — rendering an emptied clusterer is what trips the
		// supercluster "assertion failed" crash.
		this.markers.forEach( ( mk ) => mk.setMap( null ) );
		this.markers.clear();
		if ( this.cluster ) {
			try {
				this.cluster.clearMarkers( true );
			} catch ( e ) {} // eslint-disable-line no-empty
		}

		const list = [];
		// (marker build loop below populates `list`)
		records.forEach( ( record ) => {
			const ll = this.latLngOf( record );
			if ( ! ll || ! Number.isFinite( ll.lat ) || ! Number.isFinite( ll.lng ) ) return;
			let marker;
			if ( this.opts.markerType === 'html' && renderMarkerHtml ) {
				const node = renderMarkerHtml( record );
				if ( node ) {
					node.title = this.titleOf( record );
					const HtmlMarker = this.htmlMarkerClass();
					marker = new HtmlMarker( {
						position: new this.g.maps.LatLng( ll.lat, ll.lng ),
						content: node,
					} );
				}
			}
			if ( ! marker ) {
				marker = new this.g.maps.Marker( {
					position: { lat: ll.lat, lng: ll.lng },
					icon: this.iconFor( record ),
					title: this.titleOf( record ),
				} );
			}
			marker.__store = record;
			// HtmlMarker forwards a DOM click as a Maps 'click' event (see
			// htmlMarkerClass), so both marker kinds support .addListener the same way.
			marker.addListener( 'click', () => {
				if ( onMarkerClick ) onMarkerClick( record );
				if ( renderPopup ) {
					this.openPopup( marker, renderPopup( record ) );
				}
				this.animateMarker( marker );
			} );
			this.markers.set( String( this.idOf( record ) ), marker );
			list.push( marker );
		} );

		// Empty result set → tear the clusterer down entirely. clearMarkers(true)
		// above skips the redraw (avoids the supercluster "assertion failed" crash
		// on an emptied clusterer), but that redraw is also what would REMOVE the
		// previously-rendered cluster bubbles — so without this they linger on the
		// map after e.g. a category with no stores. setMap(null) runs the overlay's
		// onRemove, clearing every rendered cluster; the next non-empty call
		// recreates the instance.
		if ( list.length === 0 && this.cluster ) {
			try {
				this.cluster.setMap( null );
			} catch ( e ) {} // eslint-disable-line no-empty
			this.cluster = null;
		}

		// Cluster only when enabled, the library is present, AND there are markers
		// (an empty clusterer render is what crashes). Reuse one instance. If the
		// clusterer throws for any reason, degrade to plain Google markers — we
		// never switch map providers.
		const wantCluster =
			this.opts.clustering !== false &&
			!! window.markerClusterer?.MarkerClusterer &&
			list.length > 0;

		let clustered = false;
		if ( wantCluster ) {
			try {
				if ( ! this.cluster ) {
					const clusterColor = this.opts.clusterColor;
					const clusterStyle = this.opts.clusterStyle;
					this.cluster = new window.markerClusterer.MarkerClusterer( {
						map: this.map,
						// Themeable cluster bubble (style + colour from host settings).
						renderer: {
							render: ( { count, position } ) => {
								const size = clusterSize( count );
								return new window.google.maps.Marker( {
									position,
									icon: {
										url: clusterSvgDataUri( clusterStyle, clusterColor, count ),
										scaledSize: new window.google.maps.Size( size, size ),
										anchor: new window.google.maps.Point( size / 2, size / 2 ),
									},
									zIndex: 1000 + count,
								} );
							},
						},
					} );
				}
				this.cluster.addMarkers( list );
				clustered = true;
			} catch ( e ) {
				this.cluster = null;
			}
		}
		if ( ! clustered ) {
			list.forEach( ( mk ) => mk.setMap( this.map ) );
		}

		this.renderPopup = renderPopup;
		this.fitToMarkers();
	}

	// Play the configured click animation. Legacy overlay markers aren't
	// DOM-addressable, so they use Google's native marker animations ('pulse'
	// maps to BOUNCE, the closest native effect; 'drop' uses DROP).
	// HtmlMarker has NO setAnimation() at all — its `.content` is a real DOM
	// node, so an HTML marker instead toggles the same `cliq-marker-anim--<type>`
	// CSS class LeafletProvider already uses.
	animateMarker( marker ) {
		const anim = this.opts.markerAnimation;
		if ( ! anim || anim === 'none' || ! marker ) return;
		if ( typeof marker.setAnimation === 'function' ) {
			const A = this.g?.maps?.Animation;
			if ( ! A ) return;
			if ( anim === 'drop' ) {
				marker.setAnimation( A.DROP );
				return;
			}
			marker.setAnimation( A.BOUNCE );
			setTimeout( () => marker.setAnimation( null ), 1400 );
		} else if ( marker.content ) {
			const cls = `cliq-marker-anim--${ anim }`;
			marker.content.classList.remove( 'cliq-marker-anim', cls );
			void marker.content.offsetWidth; // reflow → restart on repeat clicks
			marker.content.classList.add( 'cliq-marker-anim', cls );
			clearTimeout( marker.content.__cliqAnimT );
			marker.content.__cliqAnimT = setTimeout( () => marker.content.classList.remove( 'cliq-marker-anim', cls ), 1000 );
		}
	}

	openStore( id ) {
		const marker = this.markers.get( String( id ) );
		if ( ! marker || ! this.map ) return;
		this.map.panTo( marker.getPosition() );
		// Honour the configured "clicked zoom". Zooming in also breaks the marker
		// out of any cluster it was hidden in (clusters only form at low zoom).
		const cz = parseFloat( this.opts.clickedZoom );
		if ( Number.isFinite( cz ) && this.map.getZoom() < cz ) {
			this.map.setZoom( cz );
		}
		if ( this.renderPopup && marker.__store ) {
			this.openPopup( marker, this.renderPopup( marker.__store ) );
		} else {
			this.info.open( this.map, marker );
		}
	}

	// Open the infobox for a marker, colouring the bubble + tail to the design's
	// background (carried on the popup node by the host's renderPopup) via a CSS
	// var on the map container — it cascades to Google's .gm-style-iw-* internals.
	openPopup( marker, node ) {
		const data = ( node && node.dataset ) || {};
		if ( this.el && this.el.style ) {
			this.el.style.setProperty( '--cliq-iw-bg', data.cliqBg || '#fff' );
			// Inherit the design's text colour too, so the directions step bubble
			// (which has no design of its own) is readable on the same background.
			this.el.style.setProperty( '--cliq-iw-color', data.cliqColor || '' );
		}
		this.info.setContent( node );
		this.info.open( this.map, marker );
	}

	panTo( record ) {
		const ll = this.latLngOf( record );
		if ( ll && this.map ) this.map.panTo( { lat: ll.lat, lng: ll.lng } );
	}

	// Centre on an explicit coordinate at an optional zoom (used to apply the
	// configured search-zoom after a location search).
	setCenter( lat, lng, zoom ) {
		if ( ! this.map ) return;
		this.map.panTo( { lat: parseFloat( lat ), lng: parseFloat( lng ) } );
		const z = parseFloat( zoom );
		if ( Number.isFinite( z ) ) this.map.setZoom( z );
	}

	fitToMarkers() {
		if ( ! this.map || ! this.markers.size ) return;
		const bounds = new this.g.maps.LatLngBounds();
		this.markers.forEach( ( mk ) => bounds.extend( mk.getPosition() ) );
		this.map.fitBounds( bounds );
	}

	drawRadius( center, meters ) {
		if ( ! this.map ) return;
		if ( this.circle ) { this.circle.setMap( null ); this.circle = null; }
		if ( ! center || ! meters ) return; // null → just clear
		this.circle = new this.g.maps.Circle( {
			center: { lat: center.lat, lng: center.lng },
			radius: meters,
			map: this.map,
			strokeColor: '#3858e9',
			strokeWeight: 1,
			fillColor: '#3858e9',
			fillOpacity: 0.08,
		} );
	}

	// Draw a real driving route origin→dest on the map via the Directions API, and
	// render turn-by-turn steps into `opts.panel` (DOM node) when provided. Requires
	// the Directions API enabled on the key; resolves false on failure so the caller
	// can fall back to the external maps link. On success resolves a summary
	// { distance, duration, origin } for the panel header.
	showDirections( origin, dest, opts = {} ) {
		return import( /* webpackChunkName: "cliq-directions-google" */ './GoogleDirections' )
			.then( ( module ) => module.showGoogleDirections( this, origin, dest, opts ) );
	}

	clearDirections() {
		if ( this.directionsRenderer ) {
			this.directionsRenderer.setMap( null );
			this.directionsRenderer.setPanel( null );
		}
	}

	destroy() {
		if ( this.stepObserver ) {
			this.stepObserver.disconnect();
			this.stepObserver = null;
		}
		this.clearDirections();
		if ( this.markers ) this.markers.forEach( ( mk ) => mk.setMap( null ) );
		if ( this.cluster ) {
			try {
				this.cluster.clearMarkers( true );
				if ( this.cluster.setMap ) this.cluster.setMap( null );
			} catch ( e ) {} // eslint-disable-line no-empty
			this.cluster = null;
		}
		this.map = null;
	}
}
