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

*/

/**
 * @typedef {Object} HtmxInternalApi
 * @property {function(HTMLElement, string): string} getAttributeValue
 * @property {function(HTMLElement): Object} getInternalData
 * @property {function(HTMLElement, function): boolean} hasEventSource
 * @property {function(HTMLElement): boolean} bodyContains
 * @property {function(HTMLElement, string, Object): boolean} triggerEvent
 * @property {function(HTMLElement, string, Object): void} triggerErrorEvent
 * @property {function(HTMLElement, function): HTMLElement} getClosestMatch
 * @property {function(HTMLElement): Array} getTriggerSpecs
 * @property {function(HTMLElement): Object} getSwapSpecification
 * @property {function(HTMLElement): HTMLElement} getTarget
 * @property {function(HTMLElement, string, Object, Object): void} swap
 * @property {function(HTMLElement, function): void} withExtensions
 */
(function() {
    /** @type {HtmxInternalApi} */
    let api

    htmx.defineExtension('sse', {
        /**
         * Init saves the provided reference to the internal HTMX API.
         *
         * @param {*} apiRef
         * @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
            }

            // Close all EventSources on page unload to prevent connection errors
            window.addEventListener('beforeunload', function() {
                const elements = document.querySelectorAll('[sse-connect], [data-sse-connect]')
                elements.forEach(function(elt) {
                    const internalData = api.getInternalData(elt)
                    const source = internalData.sseEventSource
                    if(source) {
                        source.close()
                        internalData.sseEventSource = null
                    }
                })
            })
        },

        getSelectors: function() {
            return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
        },

        /**
         * onEvent handles all events passed to this extension.
         *
         * @param {string} name
         * @param {CustomEvent} evt
         * @returns boolean
         */
        onEvent: function(name, evt) {
            /** @type EventTarget */
            const parent = evt.target || evt.detail.elt
            switch(name) {
                case 'htmx:beforeCleanupElement':
                    const internalData = api.getInternalData(parent)
                    // Try to remove remove an EventSource when elements are removed
                    const source = internalData.sseEventSource
                    if(source) {
                        source.close()
                        api.triggerEvent(parent, 'htmx:sseClose', {
                            source,
                            type: 'nodeReplaced',
                        })
                        internalData.sseEventSource = null
                    }
                    break

                // Try to create EventSources when elements are processed
                case 'htmx:afterProcessNode':
                    ensureEventSourceOnElement(parent, undefined)
                    break
            }
        }
    })

    /// ////////////////////////////////////////////
    // 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})
    }

    /**
     * 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) {
        // Add message handlers for every `sse-swap` attribute
        if(api.getAttributeValue(elt, 'sse-swap')) {
            // Find closest existing event source
            const 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
            const internalData = api.getInternalData(sourceElement)
            const source = internalData.sseEventSource

            const sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
            const sseEventNames = sseSwapAttr.split(',')

            for(let i = 0; i < sseEventNames.length; i++) {
                const sseEventName = sseEventNames[i].trim()
                const 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(elt)) {
                        source.removeEventListener(sseEventName, listener)
                        return
                    }

                    // swap the response into the DOM and trigger a notification
                    if(!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
                        return
                    }
                    swap(elt, event.data)
                    api.triggerEvent(elt, 'htmx:sseMessage', event)
                }

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

        // Add message handlers for every `hx-trigger="sse:*"` attribute
        if(api.getAttributeValue(elt, 'hx-trigger')) {
            // Find closest existing event source
            const 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
            const internalData = api.getInternalData(sourceElement)
            const source = internalData.sseEventSource

            const triggerSpecs = api.getTriggerSpecs(elt)
            triggerSpecs.forEach(function(ts) {
                if(ts.trigger.slice(0, 4) !== 'sse:') {
                    return
                }

                const listener = function(event) {
                    if(maybeCloseSSESource(sourceElement)) {
                        return
                    }
                    if(!api.bodyContains(elt)) {
                        source.removeEventListener(ts.trigger.slice(4), listener)
                    }
                    // Trigger events to be handled by the rest of htmx
                    htmx.trigger(elt, ts.trigger, event)
                    htmx.trigger(elt, 'htmx:sseMessage', event)
                }

                // Register the new listener
                api.getInternalData(elt).sseEventListener = listener
                source.addEventListener(ts.trigger.slice(4), 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
        if(api.getAttributeValue(elt, 'sse-connect')) {
            const sseURL = api.getAttributeValue(elt, 'sse-connect')
            if(sseURL === null) {
                return null
            }

            ensureEventSource(elt, sseURL, retryCount)
        }

        registerSSE(elt)
    }

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

        source.onerror = function(err) {
            // Log an error event
            api.triggerErrorEvent(elt, 'htmx:sseError', {error: err, 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
                retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
                const timeout = retryCount * 500
                window.setTimeout(function() {
                    ensureEventSourceOnElement(elt, retryCount)
                }, timeout)
            }
        }

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

            if(retryCount && retryCount > 0) {
                const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
                for(let i = 0; i < childrenToFix.length; i++) {
                    registerSSE(childrenToFix[i])
                }
                // We want to increase the reconnection delay for consecutive failed attempts only
                retryCount = 0
            }
        }

        api.getInternalData(elt).sseEventSource = source


        const closeAttribute = api.getAttributeValue(elt, "sse-close");
        if(closeAttribute) {
            // close eventsource when this message is received
            source.addEventListener(closeAttribute, function() {
                api.triggerEvent(elt, 'htmx:sseClose', {
                    source,
                    type: 'message',
                })
                source.close()
            });
        }
    }

    /**
     * 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)) {
            const source = api.getInternalData(elt).sseEventSource
            if(source !== undefined) {
                api.triggerEvent(elt, 'htmx:sseClose', {
                    source,
                    type: 'nodeMissing',
                })
                source.close()
                // source = null
                return true
            }
        }
        return false
    }


    /**
     * @param {HTMLElement} elt
     * @param {string} content
     */
    function swap(elt, content) {
        api.withExtensions(elt, function(extension) {
            content = extension.transformResponse(content, null, elt)
        })

        const swapSpec = api.getSwapSpecification(elt)
        const target = api.getTarget(elt)
        api.swap(target, content, swapSpec, {contextElement: elt})
    }


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