const fs = require('fs')
const path = require('path')

const v = require('../../package.json').version
const datastore = require('../store/data.js')
const statestore = require('../store/state.js')
const { appendTopic, addConnectionCredentials } = require('../utils/index.js')

// from: https://stackoverflow.com/a/28592528/3016654
function join (...paths) {
    return paths.map(function (element) {
        return element.replace(/^\/|\/$/g, '')
    }).join('/')
}

/**
 * Check if an object has a property
 * TODO: move to test-able utility lib
 * @param {Object} obj - Object to check for property
 * @param {String} prop - Property to check for
 * @returns {boolean}
 */
function hasProperty (obj, prop) {
    return Object.prototype.hasOwnProperty.call(obj, prop)
}

module.exports = function (RED) {
    const express = require('express')
    const { Server } = require('socket.io')

    datastore.setConfig(RED)
    statestore.setConfig(RED)

    /**
     * @typedef {import('socket.io/dist').Socket} Socket
     * @typedef {import('socket.io/dist').Server} Server
     */

    // store state that can maintain cross re-deployments
    const uiShared = {
        app: null,
        httpServer: null,
        /** @type { Server } */
        ioServer: null,
        /** @type {Object.<string, Socket>} */
        connections: {},
        settings: {},
        contribs: {}
    }

    /**
     * Initialise the Express Server and SocketIO Server in Singleton Pattern
     * @param {Object} node - Node-RED Node
     * @param {Object} config - Node-RED Node Config
     */
    function init (node, config) {
        node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly)

        if (!config.acceptsClientConfig) {
            // for those upgrading, we need this for backwards compatibility
            config.acceptsClientConfig = ['ui-control', 'ui-notification']
        }

        if (!('includeClientData' in config)) {
            // for those upgrading, we need this for backwards compatibility
            config.includeClientData = true
        }

        // expose these properties at runtime
        node.acceptsClientConfig = config.acceptsClientConfig // which node types can be scoped to a specific client
        node.includeClientData = config.includeClientData // whether to include client data in msg payloads

        // eventually check if we have routes used, so we can support multiple base UIs
        if (!uiShared.app) {
            uiShared.app = RED.httpNode || RED.httpAdmin
            uiShared.httpServer = RED.server

            // Use the 'dashboard' settings if present, otherwise fallback
            // to node-red-dashboard 'ui' settings object.
            uiShared.settings = RED.settings.dashboard || RED.settings.ui || {}

            // Default no-op middleware
            uiShared.httpMiddleware = function (req, res, next) { next() }
            if (uiShared.settings.middleware) {
                if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) {
                    uiShared.httpMiddleware = uiShared.settings.middleware
                }
            }

            /**
             * Load in third party widgets
             */
            let packagePath, packageJson
            if (RED.settings?.userDir) {
                packagePath = path.join(RED.settings.userDir, 'package.json')
                packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
            } else {
                node.log('Cannot import third party widgets. No access to Node-RED package.json')
            }

            if (packageJson && packageJson.dependencies) {
                Object.entries(packageJson.dependencies)?.filter(([packageName, _packageVersion]) => {
                    return packageName.includes('node-red-dashboard-2-')
                }).map(([packageName, _packageVersion]) => {
                    const modulePath = path.join(RED.settings.userDir, 'node_modules', packageName)
                    const packagePath = path.join(modulePath, 'package.json')
                    // get third party package.json
                    const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
                    if (packageJson?.['node-red-dashboard-2']) {
                        // loop over object of widgets
                        Object.entries(packageJson['node-red-dashboard-2'].widgets).forEach(([widgetName, widgetConfig]) => {
                            uiShared.contribs[widgetName] = {
                                package: packageName,
                                name: widgetName,
                                src: widgetConfig.output,
                                component: widgetConfig.component
                            }
                        })
                    }
                    return packageJson
                })
            }

            /**
             * Configure Web Server to handle UI traffic
             */

            uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist')))

            uiShared.app.get(config.path + '/_setup', uiShared.httpMiddleware, (req, res) => {
                let socketPath = join(RED.settings.httpNodeRoot, config.path, 'socket.io')
                // if no leading /, add one (happens sometimes depending on httpNodeRoot in settings.js)
                if (socketPath[0] !== '/') {
                    socketPath = '/' + socketPath
                }
                let resp = {
                    RED: {
                        httpAdminRoot: RED.settings.httpAdminRoot,
                        httpNodeRoot: RED.settings.httpNodeRoot
                    },
                    socketio: {
                        path: socketPath
                    }
                }
                // Hook API - onSetup(RED, config, req, res)
                RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                    if (plugin.hooks?.onSetup) {
                        const _resp = plugin.hooks.onSetup(config, req, res)
                        resp = { ...resp, ..._resp }
                    }
                })
                return res.json(resp)
            })

            // debugging endpoints
            uiShared.app.get(config.path + '/_debug/datastore/:itemid', uiShared.httpMiddleware, (req, res) => {
                return res.json(datastore.get(req.params.itemid))
            })

            uiShared.app.get(config.path + '/_debug/statestore/:itemid', uiShared.httpMiddleware, (req, res) => {
                return res.json(statestore.getAll(req.params.itemid))
            })

            // serve dashboard
            uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => {
                res.sendFile(path.join(__dirname, '../../dist/index.html'))
            })

            uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => {
                res.sendFile(path.join(__dirname, '../../dist/index.html'))
            })

            node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`)

            /**
             * Create IO Server for comms between Node-RED and UI
             */
            if (RED.settings.httpNodeRoot !== false) {
                const root = RED.settings.httpNodeRoot || '/'
                const fullPath = join(root, config.path)
                const socketIoPath = join('/', fullPath, 'socket.io')
                /** @type {import('socket.io/dist').ServerOptions} */
                const serverOptions = {
                    path: socketIoPath,
                    maxHttpBufferSize: uiShared.settings.maxHttpBufferSize || 1e6 // SocketIO default size
                }
                // console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests
                // store reference to the SocketIO Server
                uiShared.ioServer = new Server(uiShared.httpServer, serverOptions)
                uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this!

                if (typeof uiShared.settings.ioMiddleware === 'function') {
                    uiShared.ioServer.use(uiShared.settings.ioMiddleware)
                } else if (Array.isArray(uiShared.settings.ioMiddleware)) {
                    uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) {
                        uiShared.ioServer.use(ioMiddleware)
                    })
                } else {
                    uiShared.ioServer.use(function (socket, next) {
                        if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) {
                            // Reject direct websocket requests
                            socket.client.conn.close()
                            return
                        }
                        if (socket.handshake.xdomain === false) {
                            return next()
                        } else {
                            socket.disconnect(true)
                        }
                    })
                }
                const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port
                node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath)
            } else {
                node.warn('Cannot create UI Base node when httpNodeRoot set to false')
            }
        }
    }

    /**
     * Close the SocketIO Server
     */
    function close (node, done) {
        if (!uiShared.ioServer) {
            done()
            return
        }

        // determine if any ui-pages are left, if so, don't close the server
        const baseNodes = []
        const pageNodes = []
        const themes = []
        RED.nodes.eachNode(n => {
            if (n.type === 'ui-page' || n.type === 'ui-link') {
                pageNodes.push(n)
            } else if (n.type === 'ui-base' && n.id !== node.id) {
                baseNodes.push(n)
            } else if (n.type === 'ui-theme') {
                themes.push(n)
            }
        })

        if (pageNodes.length > 0) {
            // there are still ui-pages, so don't close the server
            done()
            return
        }
        node.ui.pages.clear()// ensure we clear out any pages that may have been left over
        // since there are no pages, we can assume widgets and groups are also gone
        node.ui.widgets.clear()
        node.ui.groups.clear()

        if (baseNodes.length > 0) {
            // there are still other ui-base nodes, don't close the server
            done()
            return
        }

        // as there are no more instances of ui-page and this is the last ui-base, close the server
        uiShared.ioServer.removeAllListeners()
        uiShared.ioServer.disconnectSockets(true)
        // tidy up
        if (themes.length === 0) {
            node.ui.themes.clear()
        }
        node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over
        node.uiShared = null // remove reference to ui object
        done && done()
    }

    /**
     * UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node.
     * * _whether this constructor is called depends on if there are any changes to THIS node_
     * * _A full Deploy will always call this function as every node is destroyed and re-created_
     * @param {Object} n - Node-RED node configuration as entered in the nodes editor
     */
    function UIBaseNode (n) {
        RED.nodes.createNode(this, n)
        const node = this

        node._created = Date.now()

        n.root = RED.settings.httpNodeRoot || '/'

        /** @type {Object.<string, Socket>} */
        // node.connections = {} // store socket.io connections for this node
        // // re-map existing connections for this base node
        for (const id in uiShared.connections) {
            const socket = uiShared.connections[id]
            if (uiShared.connections[id]._baseId === node.id) {
                // re establish event handlers
                socket.on('widget-send', onSend.bind(null, socket))
                socket.on('widget-action', onAction.bind(null, socket))
                socket.on('widget-change', onChange.bind(null, socket))
                socket.on('widget-load', onLoad.bind(null, socket))
            }
        }
        /** @type {NodeJS.Timeout} */
        node.emitConfigRequested = null // used to debounce requests to emitConfig

        // Configure & Run Express Server
        init(node, n)

        /**
         * Emit an event to all connected UIs
         * @param {String} event
         * @param {Object} msg
         * @param {Object} wNode - the Node-RED node that is emitting the event
         */
        function emit (event, msg, wNode) {
            Object.values(uiShared.connections).forEach(conn => {
                if (canSendTo(conn, wNode, msg)) {
                    conn.emit(event, msg)
                }
            })
        }

        // surface this so that other nodes can emit messages directly
        node.emit = emit

        /**
         * Checks, given a received msg, and the associated SocketIO connection
         * whether the msg has been configured to only be sent to particular connections
         * @param {*} conn   - SocketIO Connection Object
         * @param {*} wNode  - The Node-RED node we are sending this to
         * @param {*} msg    - The msg to be sent
         * @returns {Boolean} - Whether the msg can be sent to this connection
         */
        function canSendTo (conn, wNode, msg) {
            const nodeAllowsConstraints = wNode ? n.acceptsClientConfig?.includes(wNode.type) : true
            return (nodeAllowsConstraints && isValidConnection(conn, msg)) || !nodeAllowsConstraints
        }

        /**
         * Checks, given a received msg, and the associated SocketIO connection
         * whether the msg has been configured to only be sent to particular connections
         * @param {*} conn - SocketIO Connection Object
         * @param {*} msg  -
         */
        function isValidConnection (conn, msg) {
            const checks = []
            // loop over plugins and check if any have defined a custom isValidConnection function
            // if so, use that to determine if the connection is valid
            for (const plugin of RED.plugins.getByType('node-red-dashboard-2')) {
                if (plugin.hooks?.onIsValidConnection) {
                    checks.push(plugin.hooks.onIsValidConnection(conn, msg))
                }
            }
            // conduct the core check too
            if (msg._client?.socketId) {
                // if a particular socketid has been defined,
                // we only send comms on the connection that matches that id
                checks.push(msg._client?.socketId === conn.id)
            }
            // ensure all checks validate sending this
            return !checks.length || !checks.includes(false)
        }

        /**
         * Emit UI Config to all connected UIs
         * @param {Socket} socket - socket.io socket connecting to the server
         */
        function emitConfig (socket) {
            // loop over widgets - check statestore if we've had any dynamic properties set
            for (const [id, widget] of node.ui.widgets) {
                const state = statestore.getAll(id)
                if (state) {
                    // merge the statestore with our props to account for dynamically set properties:
                    widget.props = { ...widget.props, ...state }
                    widget.state = { ...widget.state, ...state }
                }
            }

            // loop over pages - check statestore if we've had any dynamic properties set
            for (const [id, page] of node.ui.pages) {
                const state = statestore.getAll(id)
                if (state) {
                    // merge the statestore with our props to account for dynamically set properties:
                    node.ui.pages.set(id, { ...page, ...state })
                }
            }

            // loop over groups - check statestore if we've had any dynamic properties set
            for (const [id, group] of node.ui.groups) {
                const state = statestore.getAll(id)
                if (state) {
                    // merge the statestore with our props to account for dynamically set properties:
                    node.ui.groups.set(id, { ...group, ...state })
                }
            }

            // pass the connected UI the UI config
            socket.emit('ui-config', node.id, {
                dashboards: Object.fromEntries(node.ui.dashboards),
                heads: Object.fromEntries(node.ui.heads),
                pages: Object.fromEntries(node.ui.pages),
                themes: Object.fromEntries(node.ui.themes),
                groups: Object.fromEntries(node.ui.groups),
                widgets: Object.fromEntries(node.ui.widgets)
            })
        }

        // remove event handler socket listeners for a given socket connection
        function cleanupEventHandlers (socket) {
            try {
                socket.removeAllListeners('widget-action')
            } catch (_error) { /* do nothing */ }
            try {
                socket.removeAllListeners('widget-change')
            } catch (_error) { /* do nothing */ }
            try {
                socket.removeAllListeners('widget-load')
            } catch (_error) { /* do nothing */ }
            try {
                socket.removeAllListeners('widget-send')
            } catch (_error) { /* do nothing */ }
            try {
                socket.removeAllListeners('disconnect')
            } catch (_error) { /* do nothing */ }

            // check if any widgets have defined custom socket events
            // remove their listeners to make sure we clean up properly
            node.ui?.widgets?.forEach((widget) => {
                if (widget.hooks?.onSocket) {
                    for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
                        try {
                            socket.removeAllListeners(eventName)
                        } catch (_error) { /* do nothing */ }
                    }
                }
            })
        }

        function setupEventHandlers (socket, onConnection) {
            socket.on('widget-send', onSend.bind(null, socket))
            socket.on('widget-action', onAction.bind(null, socket))
            socket.on('widget-change', onChange.bind(null, socket))
            socket.on('widget-load', onLoad.bind(null, socket))

            // check if any widgets have defined custom socket events
            // most common with third-party widgets that are not part of core Dashboard 2.0
            const registered = [] // track which widget types we've already subscribed for
            node.ui?.widgets?.forEach((widget) => {
                if (widget.hooks?.onSocket) {
                    for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) {
                        // we only need add the listener for a given event type the once
                        if (eventName === 'connection') {
                            if (onConnection) {
                                // these handlers are setup as part of an onConnection event, so trigegr these now
                                handler(socket)
                            }
                        } else {
                            widget._onSocketHandlers = widget._onSocketHandlers || {}
                            widget._onSocketHandlers[eventName] = handler.bind(null, socket)
                            socket.on(eventName, widget._onSocketHandlers[eventName])
                        }
                    }
                    registered.push(widget.type)
                }
            })

            // handle disconnection
            socket.on('disconnect', reason => {
                cleanupEventHandlers(socket)
                delete uiShared.connections[socket.id]
                node.log(`Disconnected ${socket.id} due to ${reason}`)
            })
        }

        /**
         * on connection handler for SocketIO
         * @param {Socket} socket socket.io socket connecting to the server
         */
        function onConnection (socket) {
            // record mapping from connection to he ui-base node
            socket._baseId = node.id

            // node.connections[socket.id] = socket // store the connection for later use
            uiShared.connections[socket.id] = socket // store the connection for later use

            emitConfig(socket)

            // clean up then re-register listeners
            // cleanupEventHandlers(socket)
            // setup connections, and fire any 'on('connection')' events
            setupEventHandlers(socket, true)
        }
        /**
         * Handles a widget-action event from the UI
         * @param {Socket} conn - socket.io socket connecting to the server
         * @param {String} id - widget id sending the action
         * @param {*} msg - The node-red msg object to forward
         * @returns void
         */
        async function onAction (conn, id, msg) {
            // Hooks API - onAction(conn, id, msg)
            RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                if (plugin.hooks?.onAction && msg) {
                    msg = plugin.hooks.onAction(conn, id, msg)
                }
            })

            if (!msg) {
                // a plugin has made msg blank - meaning that we don't want to send it on
                return
            }

            msg = addConnectionCredentials(RED, msg, conn, n)

            // ensure msg is an object. Assume the incoming data is the payload if not
            if (!msg || typeof msg !== 'object') {
                msg = { payload: msg }
            }

            // get widget node and configuration
            const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)

            // ensure we can get the requested widget from the runtime & that this widget has an onAction handler
            if (!wNode || !widgetEvents.onAction) {
                return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
            }

            // Wrap execution in a try/catch to ensure we don't crash Node-RED
            try {
                msg = await appendTopic(RED, widgetConfig, wNode, msg)

                // pre-process the msg before send on the msg (if beforeSend is defined)
                if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') {
                    msg = await widgetEvents.beforeSend(msg)
                }

                // send the msg onwards
                wNode.send(msg)
            } catch (error) {
                let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
                errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
                errorHandler && errorHandler(error)
            }
        }

        /**
         * Handles a widget-change event from the UI
         * @param {Socket} conn - socket.io socket connecting to the server
         * @param {String} id - widget id sending the action
         * @param {*} value - The value to send to node-red. Typically this is the payload
         * @returns void
         */
        async function onChange (conn, id, value) {
            // console.log('conn:' + conn.id, 'on:widget-change:' + id, value)

            // get widget node and configuration
            const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)

            if (!wNode) {
                return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
            }
            let msg = datastore.get(id) || {}

            RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                if (plugin.hooks?.onChange) {
                    msg = plugin.hooks.onChange(conn, id, msg)
                }
            })

            if (!msg) {
                // a plugin has made msg blank - meaning that we don't want to send it on
                return
            }

            msg = addConnectionCredentials(RED, msg, conn, n)

            async function defaultHandler (msg, value) {
                if (typeof (value) === 'object' && value !== null && hasProperty(value, 'payload')) {
                    msg.payload = value.payload
                } else {
                    msg.payload = value
                }

                msg = await appendTopic(RED, widgetConfig, wNode, msg)

                if (widgetEvents?.beforeSend) {
                    msg = await widgetEvents.beforeSend(msg)
                }
                datastore.save(n, wNode, msg)
                wNode.send(msg) // send the msg onwards
            }

            // wrap execution in a try/catch to ensure we don't crash Node-RED
            try {
                // Most of the time, we can just use this default handler,
                // but sometimes a node needs to do something specific (e.g. ui-switch)
                const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler
                await handler(msg, value)
            } catch (error) {
                console.log(error)
                let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
                errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
                errorHandler && errorHandler(error)
            }
        }

        /**
         * Handles a widget-send event from the UI
         * This takes a msg input, and emits it from the relevant node (normally a template node)
         * also stores in the data store, and does not consider any previously stored messages (unlike widget-change)
         * @param {Socket} conn - socket.io socket connecting to the server
         * @param {String} id - widget id sending the action
         * @param {*} msg - The value to send to node-red. Typically this is the payload
         * @returns void
         */
        async function onSend (conn, id, msg) {
            // console.log('conn:' + conn.id, 'on:widget-send:' + id, msg)

            // get widget node and configuration
            const { wNode, widgetEvents } = getWidgetAndConfig(id)

            if (!wNode) {
                return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
            }

            RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                if (plugin.hooks?.onSend) {
                    msg = plugin.hooks.onSend(conn, id, msg)
                }
            })

            if (!msg) {
                // a plugin has made msg blank - meaning that we don't want to send it on
                return
            }

            msg = addConnectionCredentials(RED, msg, conn, n)

            async function defaultHandler (value) {
                if (widgetEvents?.beforeSend) {
                    msg = await widgetEvents.beforeSend(msg)
                }
                datastore.save(n, wNode, msg)
                wNode.send(msg) // send the msg onwards
            }

            // wrap execution in a try/catch to ensure we don't crash Node-RED
            try {
                // Most of the time, we can just use this default handler,
                // but sometimes a node needs to do something specific (e.g. ui-switch)
                const handler = typeof (widgetEvents.onSend) === 'function' ? widgetEvents.onSend : defaultHandler
                await handler(msg)
            } catch (error) {
                console.log(error)
                let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
                errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
                errorHandler && errorHandler(error)
            }
        }

        async function onLoad (conn, id, msg) {
            // console.log('conn:' + conn.id, 'on:widget-load:' + id, msg)

            if (!id) {
                console.error('No widget id provided for widget-load event')
                return
            }

            const { wNode, widgetEvents } = getWidgetAndConfig(id)
            // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui-
            // Node-RED built nodes will be a random UUID
            if (!wNode && !id.startsWith('ui-')) {
                console.log('widget does not exist any more')
                return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
            }
            async function handler () {
                let msg = datastore.get(id)
                const state = statestore.getAll(id)
                RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                    if (plugin.hooks?.onLoad) {
                        msg = plugin.hooks.onLoad(conn, id, msg, state)
                    }
                })

                if (!msg && !state) {
                    // a plugin has made msg blank - meaning that we do anything else
                    return
                }

                conn.emit('widget-load:' + id, msg, state)
            }
            // wrap execution in a try/catch to ensure we don't crash Node-RED
            try {
                handler()
            } catch (error) {
                let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
                errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
                errorHandler && errorHandler(error)
            }
        }

        /**
         * Get the widget node and associated configuration/event hooks
         * @param {String} id - ID of the widget
         * @returns {Object} - { wNode, widgetConfig, widgetEvents, widget }
         */
        function getWidgetAndConfig (id) {
            // node.ui?.widgets is empty?
            // themes, groups, etc. are not empty?
            const wNode = RED.nodes.getNode(id)
            const widget = node.ui?.widgets?.get(id)
            const widgetConfig = widget?.props || {}
            const widgetEvents = widget?.hooks || {}
            return { wNode, widgetConfig, widgetEvents, widget }
        }

        // When a UI connects - send the UI Config from Node-RED to the UI
        uiShared.ioServer.on('connection', onConnection)

        // Make sure we clean up after ourselves
        node.on('close', (removed, done) => {
            uiShared.ioServer?.off('connection', onConnection)
            for (const conn of Object.values(uiShared.connections)) {
                cleanupEventHandlers(conn)
            }
            close(node, function (err) {
                if (err) {
                    node.error(`Error closing socket.io server for ${node.id}`, err)
                }
                done()
            })
        })

        /**
         * External Functions for managing UI Components
         */
        // store ui config to be sent to UI
        node.ui = {
            heads: new Map(),
            dashboards: new Map(),
            pages: new Map(),
            themes: new Map(),
            groups: new Map(),
            widgets: new Map()
        }

        node.stores = {
            data: datastore,
            state: statestore
        }

        /**
         * Queue up a config emit to the UI. This is a debounced function
         * NOTES:
         * * only sockets connected to this node will receive the config
         * * each ui-node will have it's own connections and will emit it's own config
         * @returns {void}
         */
        node.requestEmitConfig = function () {
            if (node.emitConfigRequested) {
                return
            }
            node.emitConfigRequested = setTimeout(() => {
                try {
                    // emit config to all connected UI for this ui-base
                    Object.values(uiShared.connections).forEach(socket => {
                        emitConfig(socket)
                    })
                } finally {
                    node.emitConfigRequested = null
                }
            }, 300)
        }

        /**
         * Allow for any child node to emit to all connected UIs
         */
        node.emit = emit

        node.getBaseURL = function () {
            // get the endpoint for the ui-base
            const path = n.path || ''
            // get our HTTP root, defined by NR Settings
            const base = RED.settings.httpNodeRoot || '/'
            const basePart = base.endsWith('/') ? base : `${base}/`
            const dashPart = path.startsWith('/') ? path.slice(1) : path
            const fullPath = `${basePart}${dashPart}`
            return fullPath
        }

        node.registerTheme = function (theme) {
            const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme
            node.ui.themes.set(t.id, t)
        }

        /**
         * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node
         * @param {*} page
         * @param {*} widget
         */
        node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) {
            // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents)
            /**
             * Build UI Config
             */

            // strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates)
            // and leave us just with the properties set inside the Node-RED Editor, store as "props"
            // store our UI state properties under the .state key too

            let widget = null

            if (widgetNode && widgetConfig) {
                // default states
                if (statestore.getProperty(widgetConfig.id, 'enabled') === undefined) {
                    statestore.set(n, widgetConfig, null, 'enabled', true)
                }
                if (statestore.getProperty(widgetConfig.id, 'visible') === undefined) {
                    statestore.set(n, widgetConfig, null, 'visible', true)
                }
                if (statestore.getProperty(widgetConfig.id, 'class') === undefined) {
                    statestore.set(n, widgetConfig, null, 'class', '')
                }

                // build widget object
                widget = {
                    id: widgetConfig.id,
                    type: widgetConfig.type,
                    props: widgetConfig,
                    layout: {
                        width: widgetConfig.width || 3,
                        height: widgetConfig.height || 1,
                        order: widgetConfig.order || 0
                    },
                    state: statestore.getAll(widgetConfig.id),
                    hooks: widgetEvents,
                    src: uiShared.contribs[widgetConfig.type]
                }
                const parent = RED.nodes.getNode(widgetConfig.z)
                if (parent && parent.TYPE === 'subflow') {
                    const orderEnv = parent.subflowInstance.env?.find(e => e.key === 'DB2_SF_ORDER')
                    let order = parseInt(orderEnv?.value)
                    if (isNaN(order)) {
                        order = 0
                    }
                    widget.props.subflow = {
                        id: widgetConfig.z,
                        name: parent.subflowInstance?.name || parent.subflowDef.name,
                        order
                    }
                }

                delete widget.props.id
                delete widget.props.type
                delete widget.props.x
                delete widget.props.y
                delete widget.props.wires

                if (widget.props.width === '0') {
                    widget.props.width = null
                }
                if (widget.props.height === '0') {
                    widget.props.height = null
                }

                // merge the statestore with our props toa ccount for dynamically set properties:

                // loop over props and check if we have any function definitions (e.g. onMounted, onInput)
                // and stringify them for transport over SocketIO
                for (const [key, value] of Object.entries(widget.props)) {
                    // supported functions
                    const supported = ['onMounted', 'onInput']
                    if (supported.includes(key) && typeof value === 'function') {
                        widget.props[key] = value.toString()
                    } else if (key === 'methods') {
                        for (const [method, fcn] of Object.entries(widget.props.methods)) {
                            if (typeof fcn === 'function') {
                                widget.props.methods[method] = fcn.toString()
                            }
                        }
                    }
                }
            }

            // map dashboards by their ID
            if (!node.ui.dashboards.has(n.id)) {
                node.ui.dashboards.set(n.id, n)
            }

            // map themes by their ID
            if (page && page.type === 'ui-page' && !node.ui.themes.has(page.theme)) {
                const theme = RED.nodes.getNode(page.theme)
                if (theme) {
                    node.registerTheme(theme)
                } else {
                    node.warn(`Theme '${page.theme}' specified  in page '${page.id}' does not exist`)
                }
            }

            // map pages by their ID
            if (page) {
                // ensure we have the latest instance of the page's node
                const { _users, ...p } = page
                node.ui.pages.set(page.id, p)
            }

            // map groups on a page-by-page basis
            if (group) {
                const { _user, type, ...g } = group
                node.ui.groups.set(group.id, g)
            }

            // map widgets on a group-by-group basis
            if (widgetNode && widgetConfig && !node.ui.widgets.has(widget.id)) {
                node.ui.widgets.set(widget.id, widget)
            }

            /**
             * Helper Function for testing
             */

            if (widgetNode) {
                widgetNode.getState = function () {
                    return datastore.get(widgetNode.id)
                }

                /**
                 * Event Handlers
                 */

                // add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED
                widgetNode?.on('input', async function (msg, send, done) {
                    // clean msg - #668
                    delete msg.res
                    delete msg.req

                    // ensure we have latest instance of the widget's node
                    const wNode = RED.nodes.getNode(widgetNode.id)
                    if (!wNode) {
                        return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
                    }

                    // Hooks API - onInput(msg)
                    RED.plugins.getByType('node-red-dashboard-2').forEach(plugin => {
                        if (plugin.hooks?.onInput) {
                            msg = plugin.hooks.onInput(msg)
                        }
                    })

                    if (!msg) {
                        // a plugin has made msg blank - meaning that we do anything else
                        return
                    }

                    try {
                        // pre-process the msg before running our onInput function
                        if (widgetEvents?.beforeSend) {
                            msg = await widgetEvents.beforeSend(msg)
                        }

                        // standard dynamic property handlers
                        if (hasProperty(msg, 'enabled')) {
                            statestore.set(n, widgetNode, msg, 'enabled', msg.enabled)
                        }
                        if (hasProperty(msg, 'visible')) {
                            statestore.set(n, widgetNode, msg, 'visible', msg.visible)
                        }
                        if (hasProperty(msg, 'class') || (hasProperty(msg, 'ui_update') && hasProperty(msg.ui_update, 'class'))) {
                            const cls = msg.class || msg.ui_update?.class
                            statestore.set(n, widgetNode, msg, 'class', cls)
                        }

                        // run any node-specific handler defined in the Widget's component
                        if (widgetEvents?.onInput) {
                            await widgetEvents?.onInput(msg, send)
                        } else {
                            // msg could be null if the beforeSend errors and returns null
                            if (msg) {
                                // store the latest msg passed to node
                                datastore.save(n, widgetNode, msg)

                                if (widgetConfig.topic || widgetConfig.topicType) {
                                    msg = await appendTopic(RED, widgetConfig, wNode, msg)
                                }
                                if (hasProperty(widgetConfig, 'passthru')) {
                                    if (widgetConfig.passthru) {
                                        send(msg)
                                    }
                                } else {
                                    send(msg)
                                }
                            }
                        }

                        // emit to all connected UIs
                        emit('msg-input:' + widget.id, msg, wNode)

                        done()
                    } catch (err) {
                        if (err.type === 'warn') {
                            wNode.warn(err.message)
                            done()
                        } else {
                            done(err)
                        }
                    }
                })

                // when a widget is "closed" remove it from this Base Node's knowledge
                widgetNode?.on('close', function (removed, done) {
                    if (removed) {
                        // widget has been removed from the Editor
                        // clear any data from datastore
                        datastore.clear(widgetNode.id)
                    }
                    node.deregister(null, null, widgetNode)
                    done()
                })
            }
            node.requestEmitConfig() // queue up a config emit to the UI
        }

        node.deregister = function (page, group, widgetNode) {
            let changes = false
            // remove widget from our UI config
            if (widgetNode) {
                const widget = node.ui.widgets.get(widgetNode.id)
                if (widget.hooks?.onSocket) {
                    // We have some custom socketIO hooks to remove

                    // loop over SocketIO connections
                    for (const socket of Object.values(uiShared.connections)) {
                        // loop over events
                        for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
                            // remove the listener for this event
                            if (widget._onSocketHandlers) {
                                socket.off(eventName, widget._onSocketHandlers[eventName])
                            }
                        }
                    }
                }
                node.ui.widgets.delete(widgetNode.id)
                changes = true
            }

            // if there are no more widgets on this group, remove the group from our UI config
            if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
                node.ui.groups.delete(group.id)
                changes = true
            }

            // if there are no more groups on this page, remove the page from our UI config
            if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
                node.ui.pages.delete(page.id)
                changes = true
            }
            if (changes) {
                node.requestEmitConfig()
            }
        }

        // Finally, queue up a config emit to the UI.
        // NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit
        // but in cases where the dashboard has no widgets registered, we still need to emit a config
        node.requestEmitConfig()
    }

    RED.nodes.registerType('ui-base', UIBaseNode)
}
