// Based on https://github.com/shramov/leaflet-plugins

// GridLayer like https://avinmathew.com/leaflet-and-google-maps/ , but using MutationObserver instead of jQuery





// 🍂class GridLayer.GoogleMutant

// 🍂extends GridLayer

L.GridLayer.GoogleMutant = L.GridLayer.extend({

	options: {

		minZoom: 0,

		maxZoom: 23,

		tileSize: 256,

		subdomains: 'abc',

		errorTileUrl: '',

		attribution: '',	// The mutant container will add its own attribution anyways.

		opacity: 1,

		continuousWorld: false,

		noWrap: false,

		// 🍂option type: String = 'roadmap'

		// Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.

		type: 'roadmap',

		maxNativeZoom: 21

	},



	initialize: function (options) {

		L.GridLayer.prototype.initialize.call(this, options);



		this._ready = !!window.google && !!window.google.maps && !!window.google.maps.Map;



		this._GAPIPromise = this._ready ? Promise.resolve(window.google) : new Promise(function (resolve, reject) {

			var checkCounter = 0;

			var intervalId = null;

			intervalId = setInterval(function () {

				if (checkCounter >= 10) {

					clearInterval(intervalId);

					return reject(new Error('window.google not found after 10 attempts'));

				}

				if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {

					clearInterval(intervalId);

					return resolve(window.google);

				}

				checkCounter++;

			}, 500);

		});



		// Couple data structures indexed by tile key

		this._tileCallbacks = {};	// Callbacks for promises for tiles that are expected

		this._freshTiles = {};	// Tiles from the mutant which haven't been requested yet



		this._imagesPerTile = (this.options.type === 'hybrid') ? 2 : 1;



		this._boundOnMutatedImage = this._onMutatedImage.bind(this);

	},



	onAdd: function (map) {

		L.GridLayer.prototype.onAdd.call(this, map);

		this._initMutantContainer();



		this._GAPIPromise.then(function () {

			this._ready = true;

			this._map = map;



			this._initMutant();



			map.on('viewreset', this._reset, this);

			if (this.options.updateWhenIdle) {

				map.on('moveend', this._update, this);

			} else {

				map.on('move', this._update, this);

			}

			map.on('zoomend', this._handleZoomAnim, this);

			map.on('resize', this._resize, this);



			//handle layer being added to a map for which there are no Google tiles at the given zoom

			google.maps.event.addListenerOnce(this._mutant, 'idle', function () {

				this._checkZoomLevels();

				this._mutantIsReady = true;

			}.bind(this));



			//20px instead of 1em to avoid a slight overlap with google's attribution

			map._controlCorners.bottomright.style.marginBottom = '20px';

			map._controlCorners.bottomleft.style.marginBottom = '20px';



			this._reset();

			this._update();



			if (this._subLayers) {

				//restore previously added google layers

				for (var layerName in this._subLayers) {

					this._subLayers[layerName].setMap(this._mutant);

				}

			}

		}.bind(this));

	},



	onRemove: function (map) {

		L.GridLayer.prototype.onRemove.call(this, map);

		this._observer.disconnect();

		map._container.removeChild(this._mutantContainer);



		google.maps.event.clearListeners(map, 'idle');

		google.maps.event.clearListeners(this._mutant, 'idle');

		map.off('viewreset', this._reset, this);

		map.off('move', this._update, this);

		map.off('moveend', this._update, this);

		map.off('zoomend', this._handleZoomAnim, this);

		map.off('resize', this._resize, this);



		if (map._controlCorners) {

			map._controlCorners.bottomright.style.marginBottom = '0em';

			map._controlCorners.bottomleft.style.marginBottom = '0em';

		}

	},



	getAttribution: function () {

		return this.options.attribution;

	},



	setElementSize: function (e, size) {

		e.style.width = size.x + 'px';

		e.style.height = size.y + 'px';

	},





	addGoogleLayer: function (googleLayerName, options) {

		if (!this._subLayers) this._subLayers = {};

		return this._GAPIPromise.then(function () {

			var Constructor = google.maps[googleLayerName];

			var googleLayer = new Constructor(options);

			googleLayer.setMap(this._mutant);

			this._subLayers[googleLayerName] = googleLayer;

			return googleLayer;

		}.bind(this));

	},



	removeGoogleLayer: function (googleLayerName) {

		var googleLayer = this._subLayers && this._subLayers[googleLayerName];

		if (!googleLayer) return;



		googleLayer.setMap(null);

		delete this._subLayers[googleLayerName];

	},





	_initMutantContainer: function () {

		if (!this._mutantContainer) {

			this._mutantContainer = L.DomUtil.create('div', 'leaflet-google-mutant leaflet-top leaflet-left');

			this._mutantContainer.id = '_MutantContainer_' + L.Util.stamp(this._mutantContainer);

			this._mutantContainer.style.zIndex = '800'; //leaflet map pane at 400, controls at 1000

			this._mutantContainer.style.pointerEvents = 'none';

			

			L.DomEvent.off(this._mutantContainer);



		}

		this._map.getContainer().appendChild(this._mutantContainer);



		this.setOpacity(this.options.opacity);

		this.setElementSize(this._mutantContainer, this._map.getSize());



		this._attachObserver(this._mutantContainer);

	},



	_initMutant: function () {

		if (!this._ready || !this._mutantContainer) return;



		if (this._mutant) {

			// reuse old _mutant, just make sure it has the correct size

			this._resize();

			return;

		}



		this._mutantCenter = new google.maps.LatLng(0, 0);



		var map = new google.maps.Map(this._mutantContainer, {

			center: this._mutantCenter,

			zoom: 0,

			tilt: 0,

			mapTypeId: this.options.type,

			disableDefaultUI: true,

			keyboardShortcuts: false,

			draggable: false,

			disableDoubleClickZoom: true,

			scrollwheel: false,

			streetViewControl: false,

			styles: this.options.styles || {},

			backgroundColor: 'transparent'

		});



		this._mutant = map;



		google.maps.event.addListenerOnce(map, 'idle', function () {

			var nodes = this._mutantContainer.querySelectorAll('a');

			for (var i = 0; i < nodes.length; i++) {

				nodes[i].style.pointerEvents = 'auto';

			}

		}.bind(this));



		// 🍂event spawned

		// Fired when the mutant has been created.

		this.fire('spawned', {mapObject: map});

	},



	_attachObserver: function _attachObserver (node) {

// 		console.log('Gonna observe', node);



		if (!this._observer)

			this._observer = new MutationObserver(this._onMutations.bind(this));



		// pass in the target node, as well as the observer options

		this._observer.observe(node, { childList: true, subtree: true });



		// if we are reusing an old _mutantContainer, we must manually detect

		// all existing tiles in it

		Array.prototype.forEach.call(

			node.querySelectorAll('img'),

			this._boundOnMutatedImage

		);

	},



	_onMutations: function _onMutations (mutations) {

		for (var i = 0; i < mutations.length; ++i) {

			var mutation = mutations[i];

			for (var j = 0; j < mutation.addedNodes.length; ++j) {

				var node = mutation.addedNodes[j];



				if (node instanceof HTMLImageElement) {

					this._onMutatedImage(node);

				} else if (node instanceof HTMLElement) {

					Array.prototype.forEach.call(

						node.querySelectorAll('img'),

						this._boundOnMutatedImage

					);



					// Check for, and remove, the "Google Maps can't load correctly" div.

					// You *are* loading correctly, you dumbwit.

					if (node.style.backgroundColor === 'white') {

						L.DomUtil.remove(node);

					}

                    

					// Check for, and remove, the "For development purposes only" divs on the aerial/hybrid tiles.

					if (node.textContent.indexOf('For development purposes only') === 0) {

						L.DomUtil.remove(node);

					}

                    

					// Check for, and remove, the "Sorry, we have no imagery here"

					// empty <div>s. The [style*="text-align: center"] selector

					// avoids matching the attribution notice.

					// This empty div doesn't have a reference to the tile

					// coordinates, so it's not possible to mark the tile as

					// failed.

					Array.prototype.forEach.call(

						node.querySelectorAll('div[draggable=false][style*="text-align: center"]'),

						L.DomUtil.remove

					);

				}

			}

		}

	},



	// Only images which 'src' attrib match this will be considered for moving around.

	// Looks like some kind of string-based protobuf, maybe??

	// Only the roads (and terrain, and vector-based stuff) match this pattern

	_roadRegexp: /!1i(\d+)!2i(\d+)!3i(\d+)!/,



	// On the other hand, raster imagery matches this other pattern

	_satRegexp: /x=(\d+)&y=(\d+)&z=(\d+)/,



	// On small viewports, when zooming in/out, a static image is requested

	// This will not be moved around, just removed from the DOM.

	_staticRegExp: /StaticMapService\.GetMapImage/,



	_onMutatedImage: function _onMutatedImage (imgNode) {

// 		if (imgNode.src) {

// 			console.log('caught mutated image: ', imgNode.src);

// 		}



		var coords;

		var match = imgNode.src.match(this._roadRegexp);

		var sublayer = 0;



		if (match) {

			coords = {

				z: match[1],

				x: match[2],

				y: match[3]

			};

			if (this._imagesPerTile > 1) { 

				imgNode.style.zIndex = 1;

				sublayer = 1;

			}

		} else {

			match = imgNode.src.match(this._satRegexp);

			if (match) {

				coords = {

					x: match[1],

					y: match[2],

					z: match[3]

				};

			}

// 			imgNode.style.zIndex = 0;

			sublayer = 0;

		}



		if (coords) {

			var tileKey = this._tileCoordsToKey(coords);

			imgNode.style.position = 'absolute';

			imgNode.style.visibility = 'hidden';



			var key = tileKey + '/' + sublayer;

			// console.log('mutation for tile', key)

			//store img so it can also be used in subsequent tile requests

			this._freshTiles[key] = imgNode;



			if (key in this._tileCallbacks && this._tileCallbacks[key]) {

// console.log('Fullfilling callback ', key);

				//fullfill most recent tileCallback because there maybe callbacks that will never get a 

				//corresponding mutation (because map moved to quickly...)

				this._tileCallbacks[key].pop()(imgNode); 

				if (!this._tileCallbacks[key].length) { delete this._tileCallbacks[key]; }

			} else {

				if (this._tiles[tileKey]) {

					//we already have a tile in this position (mutation is probably a google layer being added)

					//replace it

					var c = this._tiles[tileKey].el;

					var oldImg = (sublayer === 0) ? c.firstChild : c.firstChild.nextSibling;

					var cloneImgNode = this._clone(imgNode);

					c.replaceChild(cloneImgNode, oldImg);

				}

			}

		} else if (imgNode.src.match(this._staticRegExp)) {

			imgNode.style.visibility = 'hidden';

		}

	},





	createTile: function (coords, done) {

		var key = this._tileCoordsToKey(coords);



		var tileContainer = L.DomUtil.create('div');

		tileContainer.dataset.pending = this._imagesPerTile;

		done = done.bind(this, null, tileContainer);



		for (var i = 0; i < this._imagesPerTile; i++) {

			var key2 = key + '/' + i;

			if (key2 in this._freshTiles) {

				var imgNode = this._freshTiles[key2];

				tileContainer.appendChild(this._clone(imgNode));

				tileContainer.dataset.pending--;

// 				console.log('Got ', key2, ' from _freshTiles');

			} else {

				this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];

				this._tileCallbacks[key2].push( (function (c/*, k2*/) {

					return function (imgNode) {

						c.appendChild(this._clone(imgNode));

						c.dataset.pending--;

						if (!parseInt(c.dataset.pending)) { done(); }

// 						console.log('Sent ', k2, ' to _tileCallbacks, still ', c.dataset.pending, ' images to go');

					}.bind(this);

				}.bind(this))(tileContainer/*, key2*/) );

			}

		}



		if (!parseInt(tileContainer.dataset.pending)) {

			L.Util.requestAnimFrame(done);

		}

		return tileContainer;

	},



	_clone: function (imgNode) {

		var clonedImgNode = imgNode.cloneNode(true);

		clonedImgNode.style.visibility = 'visible';

		return clonedImgNode;

	},



	_checkZoomLevels: function () {

		//setting the zoom level on the Google map may result in a different zoom level than the one requested

		//(it won't go beyond the level for which they have data).

		var zoomLevel = this._map.getZoom();

		var gMapZoomLevel = this._mutant.getZoom();

		if (!zoomLevel || !gMapZoomLevel) return;





		if ((gMapZoomLevel !== zoomLevel) || //zoom levels are out of sync, Google doesn't have data

			(gMapZoomLevel > this.options.maxNativeZoom)) { //at current location, Google does have data (contrary to maxNativeZoom)

			//Update maxNativeZoom

			this._setMaxNativeZoom(gMapZoomLevel);

		}

	},



	_setMaxNativeZoom: function (zoomLevel) {

		if (zoomLevel != this.options.maxNativeZoom) {

			this.options.maxNativeZoom = zoomLevel;

			this._resetView();

		}

	},



	_reset: function () {

		this._initContainer();

	},



	_update: function () {

		// zoom level check needs to happen before super's implementation (tile addition/creation)

		// otherwise tiles may be missed if maxNativeZoom is not yet correctly determined

		if (this._mutant) {

			var center = this._map.getCenter();

			var _center = new google.maps.LatLng(center.lat, center.lng);



			this._mutant.setCenter(_center);

			var zoom = this._map.getZoom();

			var fractionalLevel = zoom !== Math.round(zoom);

			var mutantZoom = this._mutant.getZoom();



			//ignore fractional zoom levels

			if (!fractionalLevel && (zoom != mutantZoom)) {

				this._mutant.setZoom(zoom);

							

				if (this._mutantIsReady) this._checkZoomLevels();

				//else zoom level check will be done later by 'idle' handler

			}

		}



		L.GridLayer.prototype._update.call(this);

	},



	_resize: function () {

		var size = this._map.getSize();

		if (this._mutantContainer.style.width === size.x &&

			this._mutantContainer.style.height === size.y)

			return;

		this.setElementSize(this._mutantContainer, size);

		if (!this._mutant) return;

		google.maps.event.trigger(this._mutant, 'resize');

	},



	_handleZoomAnim: function () {

		if (!this._mutant) return;

		var center = this._map.getCenter();

		var _center = new google.maps.LatLng(center.lat, center.lng);



		this._mutant.setCenter(_center);

		this._mutant.setZoom(Math.round(this._map.getZoom()));

	},



	// Agressively prune _freshtiles when a tile with the same key is removed,

	// this prevents a problem where Leaflet keeps a loaded tile longer than

	// GMaps, so that GMaps makes two requests but Leaflet only consumes one,

	// polluting _freshTiles with stale data.

	_removeTile: function (key) {

		if (!this._mutant) return;



		//give time for animations to finish before checking it tile should be pruned

		setTimeout(this._pruneTile.bind(this, key), 1000);





		return L.GridLayer.prototype._removeTile.call(this, key);

	},



	_pruneTile: function (key) {

		var gZoom = this._mutant.getZoom();

		var tileZoom = key.split(':')[2];

		var googleBounds = this._mutant.getBounds();

		var sw = googleBounds.getSouthWest();

		var ne = googleBounds.getNorthEast();

		var gMapBounds = L.latLngBounds([[sw.lat(), sw.lng()], [ne.lat(), ne.lng()]]);



		for (var i=0; i<this._imagesPerTile; i++) {

			var key2 = key + '/' + i;

			if (key2 in this._freshTiles) { 

				var tileBounds = this._map && this._keyToBounds(key);

				var stillVisible = this._map && tileBounds.overlaps(gMapBounds) && (tileZoom == gZoom);



				if (!stillVisible) delete this._freshTiles[key2]; 

//				console.log('Prunning of ', key, (!stillVisible))

			}

		}

	}

});





// 🍂factory gridLayer.googleMutant(options)

// Returns a new `GridLayer.GoogleMutant` given its options

L.gridLayer.googleMutant = function (options) {

	return new L.GridLayer.GoogleMutant(options);

};

