/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx.  See /www/extensions/sse.md for usage instructions.

*/

(function() {

	/** @type {import("../htmx").HtmxInternalApi} */
	var api;

	htmx.defineExtension("sse", {

		/**
		 * Init saves the provided reference to the internal HTMX API.
		 * 
		 * @param {import("../htmx").HtmxInternalApi} api 
		 * @returns void
		 */
		init: function(apiRef) {
			// store a reference to the internal API.
			api = apiRef;

			// set a function in the public API for creating new EventSource objects
			if (htmx.createEventSource == undefined) {
				htmx.createEventSource = createEventSource;
			}
		},

		/**
		 * onEvent handles all events passed to this extension.
		 * 
		 * @param {string} name 
		 * @param {Event} evt 
		 * @returns void
		 */
		onEvent: function(name, evt) {

			switch (name) {

				case "htmx:beforeCleanupElement":
					var internalData = api.getInternalData(evt.target)
					// Try to remove remove an EventSource when elements are removed
					if (internalData.sseEventSource) {
						internalData.sseEventSource.close();
					}

					return;

				// Try to create EventSources when elements are processed
				case "htmx:afterProcessNode":
					ensureEventSourceOnElement(evt.target);
					registerSSE(evt.target);
			}
		}
	});

	///////////////////////////////////////////////
	// HELPER FUNCTIONS
	///////////////////////////////////////////////


	/**
	 * createEventSource is the default method for creating new EventSource objects.
	 * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
	 * 
	 * @param {string} url 
	 * @returns EventSource
	 */
	function createEventSource(url) {
		return new EventSource(url, { withCredentials: true });
	}

	function splitOnWhitespace(trigger) {
		return trigger.trim().split(/\s+/);
	}

	function getLegacySSEURL(elt) {
		var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
		if (legacySSEValue) {
			var values = splitOnWhitespace(legacySSEValue);
			for (var i = 0; i < values.length; i++) {
				var value = values[i].split(/:(.+)/);
				if (value[0] === "connect") {
					return value[1];
				}
			}
		}
	}

	function getLegacySSESwaps(elt) {
		var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
		var returnArr = [];
		if (legacySSEValue != null) {
			var values = splitOnWhitespace(legacySSEValue);
			for (var i = 0; i < values.length; i++) {
				var value = values[i].split(/:(.+)/);
				if (value[0] === "swap") {
					returnArr.push(value[1]);
				}
			}
		}
		return returnArr;
	}

	/**
	 * registerSSE looks for attributes that can contain sse events, right 
	 * now hx-trigger and sse-swap and adds listeners based on these attributes too
	 * the closest event source
	 *
	 * @param {HTMLElement} elt
	 */
	function registerSSE(elt) {
		// Find closest existing event source
		var sourceElement = api.getClosestMatch(elt, hasEventSource);
		if (sourceElement == null) {
			// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
			return null; // no eventsource in parentage, orphaned element
		}

		// Set internalData and source
		var internalData = api.getInternalData(sourceElement);
		var source = internalData.sseEventSource;

		// Add message handlers for every `sse-swap` attribute
		queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {

			var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
			if (sseSwapAttr) {
				var sseEventNames = sseSwapAttr.split(",");
			} else {
				var sseEventNames = getLegacySSESwaps(child);
			}

			for (var i = 0; i < sseEventNames.length; i++) {
				var sseEventName = sseEventNames[i].trim();
				var listener = function(event) {

					// If the source is missing then close SSE
					if (maybeCloseSSESource(sourceElement)) {
						return;
					}

					// If the body no longer contains the element, remove the listener
					if (!api.bodyContains(child)) {
						source.removeEventListener(sseEventName, listener);
					}

					// swap the response into the DOM and trigger a notification
					swap(child, event.data);
					api.triggerEvent(elt, "htmx:sseMessage", event);
				};

				// Register the new listener
				api.getInternalData(child).sseEventListener = listener;
				source.addEventListener(sseEventName, listener);
			}
		});

		// Add message handlers for every `hx-trigger="sse:*"` attribute
		queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {

			var sseEventName = api.getAttributeValue(child, "hx-trigger");
			if (sseEventName == null) {
				return;
			}

			// Only process hx-triggers for events with the "sse:" prefix
			if (sseEventName.slice(0, 4) != "sse:") {
				return;
			}
			
			// remove the sse: prefix from here on out
			sseEventName = sseEventName.substr(4);

			var listener = function() {
				if (maybeCloseSSESource(sourceElement)) {
					return
				}

				if (!api.bodyContains(child)) {
					source.removeEventListener(sseEventName, listener);
				}
			}
		});
	}

	/**
	 * ensureEventSourceOnElement creates a new EventSource connection on the provided element.
	 * If a usable EventSource already exists, then it is returned.  If not, then a new EventSource
	 * is created and stored in the element's internalData.
	 * @param {HTMLElement} elt
	 * @param {number} retryCount
	 * @returns {EventSource | null}
	 */
	function ensureEventSourceOnElement(elt, retryCount) {

		if (elt == null) {
			return null;
		}

		// handle extension source creation attribute
		queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
			var sseURL = api.getAttributeValue(child, "sse-connect");
			if (sseURL == null) {
				return;
			}

			ensureEventSource(child, sseURL, retryCount);
		});

		// handle legacy sse, remove for HTMX2
		queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
			var sseURL = getLegacySSEURL(child);
			if (sseURL == null) {
				return;
			}

			ensureEventSource(child, sseURL, retryCount);
		});

	}

	function ensureEventSource(elt, url, retryCount) {
		var source = htmx.createEventSource(url);

		source.onerror = function(err) {

			// Log an error event
			api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });

			// If parent no longer exists in the document, then clean up this EventSource
			if (maybeCloseSSESource(elt)) {
				return;
			}

			// Otherwise, try to reconnect the EventSource
			if (source.readyState === EventSource.CLOSED) {
				retryCount = retryCount || 0;
				var timeout = Math.random() * (2 ^ retryCount) * 500;
				window.setTimeout(function() {
					ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
				}, timeout);
			}
		};

		source.onopen = function(evt) {
			api.triggerEvent(elt, "htmx:sseOpen", { source: source });
		}

		api.getInternalData(elt).sseEventSource = source;
	}

	/**
	 * maybeCloseSSESource confirms that the parent element still exists.
	 * If not, then any associated SSE source is closed and the function returns true.
	 * 
	 * @param {HTMLElement} elt 
	 * @returns boolean
	 */
	function maybeCloseSSESource(elt) {
		if (!api.bodyContains(elt)) {
			var source = api.getInternalData(elt).sseEventSource;
			if (source != undefined) {
				source.close();
				// source = null
				return true;
			}
		}
		return false;
	}

	/**
	 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
	 * 
	 * @param {HTMLElement} elt 
	 * @param {string} attributeName 
	 */
	function queryAttributeOnThisOrChildren(elt, attributeName) {

		var result = [];

		// If the parent element also contains the requested attribute, then add it to the results too.
		if (api.hasAttribute(elt, attributeName)) {
			result.push(elt);
		}

		// Search all child nodes that match the requested attribute
		elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
			result.push(node);
		});

		return result;
	}

	/**
	 * @param {HTMLElement} elt
	 * @param {string} content 
	 */
	function swap(elt, content) {

		api.withExtensions(elt, function(extension) {
			content = extension.transformResponse(content, null, elt);
		});

		var swapSpec = api.getSwapSpecification(elt);
		var target = api.getTarget(elt);
		var settleInfo = api.makeSettleInfo(elt);

		api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);

		settleInfo.elts.forEach(function(elt) {
			if (elt.classList) {
				elt.classList.add(htmx.config.settlingClass);
			}
			api.triggerEvent(elt, 'htmx:beforeSettle');
		});

		// Handle settle tasks (with delay if requested)
		if (swapSpec.settleDelay > 0) {
			setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
		} else {
			doSettle(settleInfo)();
		}
	}

	/**
	 * doSettle mirrors much of the functionality in htmx that 
	 * settles elements after their content has been swapped.
	 * TODO: this should be published by htmx, and not duplicated here
	 * @param {import("../htmx").HtmxSettleInfo} settleInfo 
	 * @returns () => void
	 */
	function doSettle(settleInfo) {

		return function() {
			settleInfo.tasks.forEach(function(task) {
				task.call();
			});

			settleInfo.elts.forEach(function(elt) {
				if (elt.classList) {
					elt.classList.remove(htmx.config.settlingClass);
				}
				api.triggerEvent(elt, 'htmx:afterSettle');
			});
		}
	}

	function hasEventSource(node) {
		return api.getInternalData(node).sseEventSource != null;
	}

})();