RED.hooks = (function () {
    // At the time of writing this PR, VALID_HOOKS were not enforced. There may be a good reason for this
    // so the below flag has been added to permit this behaviour. If desired, this can be set to false to
    // enforce that only known hooks can be added/triggered.
    const knownHooksOnly = false

    const VALID_HOOKS = Object.freeze({
        viewRemoveNode: true,
        viewAddNode: true,
        viewRemovePort: true,
        viewAddPort: true,
        viewRedrawNode: true,
        debugPreProcessMessage: true,
        debugPostProcessMessage: true
    })

    /**
     * @typedef {keyof typeof VALID_HOOKS} HookId - A string literal type representing a hook identifier (sans label).
     * 
     * @typedef {Object} HookItem - An item in the linked list of hooks for a given HookId
     * @property {function} cb - The callback function to be called when the hook is triggered
     * @property {HookItem|null} previousHook - The previous hook in the linked list
     * @property {HookItem|null} nextHook - The next hook in the linked list
     * @property {boolean} removed - Flag indicating if the hook has been removed
     * 
     * @typedef {Record<HookId, HookItem|null>} Hooks - A mapping of HookIds to the head of their linked list of HookItems
     */


    /** @type {Hooks} - A mapping of HookIds to the head of their linked list of HookItems */
    let hooks = {}

    /** @type {Record<string, Record<HookId, HookItem>>} - A mapping of labels to their hooks */
    let labelledHooks = {}

    function add(hookId, callback) {
        const { label, id } = parseLabelledHook(hookId)

        if (knownHooksOnly && !isKnownHook(id)) {
            throw new Error("Invalid hook '" + id + "'")
        }
        if (label && labelledHooks[label] && labelledHooks[label][id]) {
            throw new Error("Hook " + hookId + " already registered")
        }
        if (typeof callback !== "function") {
            throw new Error("Invalid hook '" + hookId + "'. Callback must be a function")
        }

        /** @type {HookItem} */
        const hookItem = { cb: callback, previousHook: null, nextHook: null }

        let tailItem = hooks[id]
        if (tailItem === undefined) {
            hooks[id] = hookItem
        } else {
            while (tailItem.nextHook !== null) {
                tailItem = tailItem.nextHook
            }
            tailItem.nextHook = hookItem
            hookItem.previousHook = tailItem
        }

        if (label) {
            labelledHooks[label] = labelledHooks[label] || {}
            labelledHooks[label][id] = hookItem
        }
    }

    function remove(hookId) {
        const { label, id } = parseLabelledHook(hookId)
        if (!label) {
            throw new Error("Cannot remove hook without label: " + hookId)
        }
        if (labelledHooks[label]) {
            if (id === "*") {
                // Remove all hooks for this label
                const hookList = Object.keys(labelledHooks[label])
                for (let i = 0; i < hookList.length; i++) {
                    removeHook(hookList[i], labelledHooks[label][hookList[i]])
                }
                delete labelledHooks[label]
            } else if (labelledHooks[label][id]) {
                removeHook(id, labelledHooks[label][id])
                delete labelledHooks[label][id]
                if (Object.keys(labelledHooks[label]).length === 0) {
                    delete labelledHooks[label]
                }
            }
        }
    }

    /**
     * Remove a hook from the linked list of hooks for a given id
     * @param {HookId} id 
     * @param {HookItem} hookItem 
     * @private
     */
    function removeHook(id, hookItem) {
        let previousHook = hookItem.previousHook
        let nextHook = hookItem.nextHook

        if (previousHook) {
            previousHook.nextHook = nextHook
        } else {
            hooks[id] = nextHook
        }
        if (nextHook) {
            nextHook.previousHook = previousHook
        }
        hookItem.removed = true
        if (!previousHook && !nextHook) {
            delete hooks[id]
        }
    }

    /**
     * Trigger a hook, calling all registered callbacks in sequence.
     * If any callback returns false, the flow is halted and no further hooks are called.
     * @param {HookId} id The id of the hook to trigger (should not include a label - e.g. "viewAddNode", not "viewAddNode.myLabel")
     * @param {*} payload The payload to be passed to each hook callback
     * @param {function(?Error=):void} [done] Optional callback. If not provided, a Promise will be returned.
     * @return {Promise|undefined} Returns a Promise if the done callback is not provided, otherwise undefined
     */
    function trigger(id, payload, done) {
        let hookItem = hooks[id]
        if (!hookItem) {
            if (done) {
                done()
                return
            } else {
                return Promise.resolve()
            }
        }
        if (!done) {
            return new Promise((resolve, reject) => {
                invokeStack(hookItem, payload, function (err) {
                    if (err !== undefined && err !== false) {
                        if (!(err instanceof Error)) {
                            err = new Error(err)
                        }
                        err.hook = id
                        reject(err)
                    } else {
                        resolve(err)
                    }
                })
            })
        } else {
            invokeStack(hookItem, payload, done)
        }
    }

    /**
     * @private
     */
    function invokeStack(hookItem, payload, done) {
        function callNextHook(err) {
            if (!hookItem || err) {
                done(err)
                return
            }
            if (hookItem.removed) {
                hookItem = hookItem.nextHook
                callNextHook()
                return
            }
            const callback = hookItem.cb
            if (callback.length === 1) {
                try {
                    let result = callback(payload)
                    if (result === false) {
                        // Halting the flow
                        done(false)
                        return
                    }
                    if (result && typeof result.then === 'function') {
                        result.then(handleResolve, callNextHook)
                        return
                    }
                    hookItem = hookItem.nextHook
                    callNextHook()
                } catch (e) {
                    done(e)
                    return
                }
            } else {
                try {
                    callback(payload, handleResolve)
                } catch (e) {
                    done(e)
                    return
                }
            }
        }
        function handleResolve(result) {
            if (result === undefined) {
                hookItem = hookItem.nextHook
                callNextHook()
            } else {
                done(result)
            }
        }
        callNextHook()
    }

    function clear() {
        hooks = {}
        labelledHooks = {}
    }

    /**
     * Check if a hook with the given id exists
     * @param {string} hookId The hook identifier, which may include a label (e.g. "viewAddNode.myLabel")
     * @returns {boolean}
     */
    function has(hookId) {
        const { label, id } = parseLabelledHook(hookId)
        if (label) {
            return !!(labelledHooks[label] && labelledHooks[label][id])
        }
        return !!hooks[id]
    }

    function isKnownHook(hookId) {
        const { id } = parseLabelledHook(hookId)
        return !!VALID_HOOKS[id]
    }

    /**
     * Split a hook identifier into its id and label components.
     * @param {*} hookId A hook identifier, which may include a label (e.g. "viewAddNode.myLabel")
     * @returns {{label: string, id: HookId}}
     * @private
     */
    function parseLabelledHook(hookId) {
        if (typeof hookId !== "string") {
            return { label: '', id: '' }
        }
        const parts = hookId.split(".")
        const id = parts[0]
        const label = parts[1]
        return { label, id }
    }

    VALID_HOOKS['all'] = true // Special wildcard to allow hooks to indicate they should be triggered for all ids

    return {
        has,
        clear,
        add,
        remove,
        trigger,
        isKnownHook
    }
})()
