Source: server.ws.js

'use strict';

import { Buffer } from 'buffer';
import { errorLogger} from './utils/requestLogger.js';
import { isObjectVisible } from './utils/authChecks.js';
import { HEX2uuid, UUID2hex } from '@commtool/sql-query';
import { normalizeUserSession } from '@commtool/shared-auth';


// @ts-check
/**
 * @import {ExpressRequest, ExpressResponse} from './types.js'
 */

/**
 * @typedef {Object} SocketIOServer
 * Socket.IO server instance used to register connection handlers and emit to rooms.
 */

/**
 * @typedef {Object} SocketIOSocket
 * Represents a single connected Socket.IO client.
 * @property {Object}   request          - Underlying HTTP upgrade request (contains session, headers)
 * @property {Object}   data             - Per-socket mutable storage set by connection handler
 * @property {string}   data.connectID   - Base64-encoded socketID used as key in clientDataStore
 * @property {string}   data.user        - userUID of the authenticated user
 * @property {Object}   data.userInfo    - Full decoded token payload
 * @property {string}   data.root        - Current organisation UID (may be updated via setRoot)
 * @property {Buffer}   data.rootHex     - Hex-encoded root for fast comparisons
 * @property {boolean}  data.transaction - Whether a bulk-transaction is in progress
 * @property {string}   id               - Internal Socket.IO connection ID
 * @property {boolean}  connected        - Whether the socket is currently connected
 * @property {function(string, any): void}  emit       - Emit an event to this client
 * @property {function(string): void}       join       - Join a Socket.IO room
 * @property {function(boolean): void}      disconnect - Forcefully close the connection
 * @property {Object}   handshake        - Handshake information (headers, query, auth)
 */

/**
 * @typedef {Object} ClientData
 * Per-connection state stored in {@link clientDataStore}.
 * @property {Object|null}   updateBuffer  - Pending list-update message to flush on reconnect
 * @property {Set<Buffer>}   updateAbo     - Set of hex-encoded list UIDs this client monitors (via `monitor` events)
 * @property {Set<Buffer>}   objectAbo     - Set of hex-encoded object UIDs this client monitors (via `monitorObject` events)
 * @property {Array}         messageBuffer - General message buffer (reserved for future use)
 * @property {string}        UIDuser       - userUID of the connected user
 * @property {string}        UIDroot       - Organisation UID active for this connection
 * @property {string}        orgSource     - How orgUID was determined: 'header' | 'session' | 'user-default' | 'dynamic'
 * @property {number}        connectedAt   - Unix ms timestamp of initial connect
 * @property {any}           [queueStatus] - Last known queue status (cached for reference)
 */



/** @type {SocketIOServer|null} */
let ioInstance




/** @type {Map<string, any>} */
export const clientDataStore = new Map();

// Delayed update timestamps for batching updates
/** @type {Map<string, number>} */
export const delayedUpdateTimestamps = new Map();

// Debounce delay for updates (2.5 seconds)
/** @type {number} */
const debounceDelay = 7000;

/**
 * Registers all Socket.IO connection and event handlers.
 *
 * Called once at server startup with the Socket.IO `io` instance.
 * Stores `io` in the module-level `ioInstance` so that other exported
 * functions (e.g. `broadcastUpdate`) can reach connected sockets.
 *
 * **Connection lifecycle:**
 * 1. Validates JWT-authenticated `socket.data.user` (set upstream by auth middleware).
 * 2. Requires a `?id=<uuid>` query parameter to derive a stable `socketID`.
 * 3. Requires organisation context from `x-organization` header or token claims.
 * 4. Creates an entry in {@link clientDataStore} keyed by `socketID`.
 * 5. Joins the socket to a private room named `socketID` for targeted emits.
 * 6. Registers per-socket event handlers (see below).
 * 7. Removes the `clientDataStore` entry on disconnect.
 *
 * **Socket events handled per connection:**
 * | Event           | Payload                        | Effect |
 * |-----------------|-------------------------------|--------|
 * | `transaction`   | `{ start: boolean }`           | Marks bulk-transaction mode on `socket.data.transaction` |
 * | `monitor`       | `{ UID: string\|null }`        | Adds list UID to `updateAbo` Set; `null` clears the Set |
 * | `monitorObject` | `{ UID: string\|null }`        | Adds object UID to `objectAbo` Set; `null` clears the Set |
 * | `setRoot`       | `{ UIDroot: string }`          | Updates active organisation for the connection |
 * | `disconnect`    | reason string                  | Removes entry from `clientDataStore` |
 * | `error`         | Error                          | Logs the socket error |
 *
 * @param {SocketIOServer} io - Socket.IO server instance
 * @returns {void}
 */
export const setupSocketHandlers = (io) => {
    ioInstance = io;
    io.on('connection', (socket) => {
        try {
            // User is set by createSocketAuthMiddleware from Bearer token
            const userInfo = socket.data.user;
            
            if (!userInfo) {
                socket.disconnect(true);
                console.error('Socket connection without valid authentication');
                return;
            }
            
            const user = userInfo.userUID || userInfo.id;
            
            // Get client ID from query parameters
            const id = socket.handshake.query.id;
            if (!id) {
                socket.disconnect(true);
                console.error('Socket connection without client ID');
                return;
            }
            
            // Get organization from header or user token
            const orgFromHeader = socket.handshake.headers['x-organization'];
            const orgUID = orgFromHeader || userInfo.organization || userInfo.loginOrgaUID;
            
            if (!orgUID || orgUID === 'undefined') {
                socket.disconnect(true);
                console.error('Socket connection without organization context');
                return;
            }

            // Create a unique ID for this client
            const UID = 'UUID-' + id;
            const socketID = Buffer.from(UID).toString('base64');
            
            console.log(`Client connected: ${user} (${userInfo.source}), org: ${orgUID}, socketID: ${socketID}`);
    
            // Store connection info
            socket.data.connectID = socketID;
            socket.data.user = user;
            socket.data.userInfo = userInfo;
            socket.data.root = orgUID;
            socket.data.userHex = UUID2hex(user);
      
            // Create a session object for compatibility with authChecks functions
            // Using normalizeUserSession from auth-lib for consistency
            const normalizedUser = normalizeUserSession(userInfo, userInfo.source || 'websocket');
            socket.request.session = {
                user: user,
                baseUser: userInfo.baseUserUID || user,
                root: orgUID,
                loginOrgaUID: userInfo.loginOrgaUID || orgUID,
                authUser: normalizedUser,
            };
            
            // Also set organization in headers for other middleware
            if (!socket.request.headers) {
                socket.request.headers = {};
            }
            socket.request.headers['x-organization'] = orgUID;
            
            // Join a room for this specific client
            socket.join(socketID);
            
            // Create client data entry
            clientDataStore.set(socketID, { 
                updateBuffer: null, 
                updateAbo: new Set(), 
                objectAbo: new Set(), 
                messageBuffer: [], 
                UIDuser: user, 
                UIDroot: orgUID,
                orgSource: orgFromHeader ? 'header' : (socket.request.session.root ? 'session' : 'user-default'),
                connectedAt: Date.now()
            });
            
            
            // Handle transactions
            socket.on('transaction', (data) => {
                socket.data.transaction = data.start;
                socket.emit('transactionAck', { received: true });
            });
        
            // Handle list monitoring
            socket.on('monitor', async (data) => {
                if (!data) return;
                if (data.UID === null) {
                    // Clear all list monitors (sent by frontend before re-syncing)
                    const clientData = clientDataStore.get(socketID);
                    if (clientData) clientData.updateAbo = new Set();
                    socket.emit('monitorAck', { success: true });
                } else if (data.UID) {
                    await addListAbo(socketID, data.UID, socket.request);
                    socket.emit('monitorAck', { success: true });
                }
            });
            
            // Handle object monitoring
            socket.on('monitorObject', async (data) => {
                if (!data) return;
                if (data.UID === null) {
                    // Clear all object monitors (sent by frontend before re-syncing)
                    const clientData = clientDataStore.get(socketID);
                    if (clientData) clientData.objectAbo = new Set();
                    socket.emit('monitorObjectAck', { success: true });
                } else if (data.UID) {
                    await addObjectAbo(socketID, data.UID, socket.request);
                    socket.emit('monitorObjectAck', { success: true });
                }
            });
        
            // Replace your ping/pong with a custom event
            socket.on('setRoot', (data) => {
                if (data && data.UIDroot) {
                    socket.data.root = data.UIDroot;
                    socket.data.rootHex = UUID2hex(data.UIDroot);
                    
                    // Update client data store
                    const clientData = clientDataStore.get(socketID);
                    if (clientData) {
                        clientData.UIDroot = data.UIDroot;
                        clientData.orgSource = 'dynamic';
                    }
                }
                socket.emit('rootSet', { success: true, root: socket.data.root });
                
                // Send any buffered messages
                sendMessageBuffered(socket, null);
            });
            
            // Handle client disconnect
            socket.on('disconnect', (reason) => {
                console.log(`Client disconnected: ${socket.data.user}, reason: ${reason}`);
                clientDataStore.delete(socketID);
            });
            
            // Handle connection errors
            socket.on('error', (error) => {
                console.error(`Socket error for client ${socketID}:`, error);
            });
            
        } catch (e) {
            console.error('Error in Socket.IO connection handler:', e);
            socket.disconnect(true);
        }
    });
};

/**
 * Sends a message to a client, buffering update messages when the socket is
 * temporarily disconnected.
 *
 * **Behaviour:**
 * - If `message.update` is truthy, the message is treated as a list-update
 *   notification. Only the latest such notification per client is kept
 *   (`clientData.updateBuffer`). If the socket is connected it is sent
 *   immediately and the buffer is cleared; otherwise it waits.
 * - Any other message payload is forwarded directly via `socket.emit`
 *   (Socket.IO itself buffers these internally when disconnected).
 * - When called with `message = null` the function flushes any buffered
 *   update notification. This is triggered automatically after a successful
 *   `setRoot` event.
 *
 * @param {SocketIOSocket} socket  - The target Socket.IO socket
 * @param {Object|null}    message - Message to send, or `null` to flush the buffer
 * @returns {void}
 */
export const sendMessageBuffered = (socket, message) => {
    try {
        // Make sure we have a valid socket and connection ID
        if (!socket || !socket.data || !socket.data.connectID) {
            return;
        }
        
        const clientData = clientDataStore.get(socket.data.connectID);
        if (!clientData) {
            return;
        }
        
        // If socket is not connected, Socket.IO will automatically buffer the message
        // for future delivery when connection is restored
        
        if (message) {
            if (message.update) {
                // For updates, we only care about the latest one, so store it
                clientData.updateBuffer = { UID: message.UID, timestamp: message.timestamp };
                // Only send if connected
                if (socket.connected) {
                    socket.emit('update', { 
                        update: true, 
                        UID: message.UID, 
                        timestamp: message.timestamp 
                    });
                    clientData.updateBuffer = null;
                }
            } else {
                // Regular messages can be sent directly - Socket.IO will buffer if needed
                socket.emit('message', message);
            }
        } else if (socket.connected) {
            // This is a request to flush any pending messages
            
            // Send update buffer if it exists
            if (clientData.updateBuffer) {
                socket.emit('update', { 
                    update: true, 
                    UID: clientData.updateBuffer.UID, 
                    timestamp: clientData.updateBuffer.timestamp 
                });
                clientData.updateBuffer = null;
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};


/**
 * Adds one or more object UIDs to the monitoring set of a connected client.
 *
 * Each UID is authorisation-checked via {@link isObjectVisible} before being
 * added. Unauthorised UIDs are silently skipped.
 *
 * Passing `'quit'` or `null` clears the entire `objectAbo` Set, which is the
 * same effect as the frontend sending `monitorObject { UID: null }` during a
 * subscription re-sync.
 *
 * Once registered, the backend will push `{ object: true, value }` messages
 * via {@link addUpdateEntry} whenever the object changes.
 *
 * @param {string}                socketID - Base64-encoded socket identifier (key in clientDataStore)
 * @param {string|string[]|null|'quit'} UIDs - Object UUID(s) to subscribe to, or `null`/`'quit'` to clear
 * @param {Object}                req      - HTTP request object used for authorisation checks
 * @returns {Promise<void>}
 */
export const addObjectAbo = async (socketID, UIDs, req) => {
    try {
        const clientData = clientDataStore.get(socketID);
        if (!clientData) return;
        
        if (UIDs === 'quit' || UIDs === null) {
            clientData.objectAbo = new Set();
        } else {
            const UIDobjects = Array.isArray(UIDs) ? UIDs.map(U => UUID2hex(U)) : [UUID2hex(UIDs)];
            
            for (const myUID of UIDobjects) {
                if (await isObjectVisible(req, myUID)) {
                    clientData.objectAbo.add(myUID);
                }
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Adds a list UID to the monitoring set of a connected client.
 *
 * The UID is authorisation-checked via {@link isObjectVisible} before being
 * added. An unauthorised UID is silently skipped.
 *
 * Passing `'quit'` or `null` clears the entire `updateAbo` Set, which is the
 * same effect as the frontend sending `monitor { UID: null }` during a
 * subscription re-sync.
 *
 * Once registered, the backend will push `{ update: true, UID, timestamp }`
 * messages via {@link addUpdateList} / {@link broadcastUpdate} whenever the
 * list changes.
 *
 * @param {string}           socketID - Base64-encoded socket identifier (key in clientDataStore)
 * @param {string|null|'quit'} UID    - List UUID to subscribe to, or `null`/`'quit'` to clear
 * @param {Object}           req      - HTTP request object used for authorisation checks
 * @returns {Promise<void>}
 */
export const addListAbo = async (socketID, UID, req) => {
    try {
        const clientData = clientDataStore.get(socketID);
        if (!clientData) return;
        
        if (UID === 'quit' || UID === null) {
            clientData.updateAbo = new Set();
        } else {
            const UIDlist = UUID2hex(UID);
            if (await isObjectVisible(req, UIDlist)) {
                clientData.updateAbo.add(UIDlist);
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};




/**
 * Broadcasts the current job-queue status to all clients of an organisation.
 *
 * This is an organisation-wide broadcast — every connected client whose
 * `UIDroot` matches `root` receives the message, regardless of individual
 * subscriptions. The status is also cached in `clientData.queueStatus` for
 * reference.
 *
 * The frontend receives `{ root, queueStatus }` and calls any registered
 * `setQueueStatus` callbacks across all active subscriptions.
 *
 * @param {string|Buffer} root   - Organisation UID to address
 * @param {any}           status - Queue status payload to forward to clients
 * @returns {void}
 */
export const updateQueueStatus = (root, status) => {
    try {
        if (!ioInstance) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDroot && 
                UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { root: root, queueStatus: status });
                
                // Also update the client data
                clientData.queueStatus = status;
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Notifies all clients of an organisation that their UI template has changed.
 *
 * This is an organisation-wide broadcast — every connected client whose
 * `UIDroot` matches `root` receives `{ root, updateTemplate: <timestamp> }`.
 * The frontend reacts by re-fetching the template configuration.
 *
 * @param {string|Buffer} root - Organisation UID whose clients should refresh their template
 * @returns {void}
 */
export const updateTemplate = (root) => {
    try {
        if (!ioInstance) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDroot && 
                UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { 
                    root: root, 
                    updateTemplate: Date.now() 
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Notifies all clients of an organisation that the app configuration has changed.
 *
 * This is an organisation-wide broadcast — every connected client whose
 * `UIDroot` matches `root` receives `{ updateConfig: <timestamp>, app }`.
 * The frontend re-fetches config for the specified app.
 *
 * @param {string|Object} app  - App identifier or config object forwarded to the client
 * @param {string|Buffer} root - Organisation UID whose clients should refresh their config
 * @returns {void}
 */
export const updateConfig = (app, root) => {
    try {
        if (!ioInstance) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDroot && 
                UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { 
                    updateConfig: Date.now(), 
                    app 
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};


/**
 * Pushes a fake-login (impersonation) state update to all sockets of a user.
 *
 * When an admin impersonates another user the session's `fakeLogin` flag
 * changes. This broadcasts `{ baseUser, user, fakeLogin }` to every socket
 * belonging to that user so the frontend can update the displayed identity
 * without a page reload.
 *
 * @param {Object}      session            - Session data for the affected user
 * @param {string}      session.user       - userUID of the connected user
 * @param {string}      session.baseUser   - userUID of the real (base) user
 * @param {string|null} session.fakeLogin  - Display name during impersonation, or null to clear
 * @returns {void}
 */
export const wsFakeUser = (session) => {
    try {
        if (!ioInstance || !session || !session.user) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDuser && 
                clientData.UIDuser===session.user) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { 
                    baseUser: session.baseUser, 
                    user: session.user, 
                    fakeLogin: session.fakeLogin
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Notifies all sockets of a specific user that their own settings have changed.
 *
 * Only targets sockets belonging to the given Keycloak userUID, so other users
 * in the same organisation are not affected.
 *
 * @param {string} userUID   - Keycloak userUID of the user whose settings changed
 * @param {Object} settings  - The new settings object to push to the client
 * @returns {void}
 */
export const updateUserSettings = (userUID, settings) => {
    try {
        if (!ioInstance || !userUID) return;

        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDuser && clientData.UIDuser === userUID) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', {
                    updateUserSettings: settings
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};


/**
 * Like {@link updateTemplate} but for the event application specifically.
 * Emits `{ root, updateTemplateEvent: <timestamp> }` to all matching clients.
 *
 * @param {string|Buffer} root - Organisation UID whose clients should refresh the event template
 * @returns {void}
 */
export const updateTemplateEvent = (root) => {
    try {
        if (!ioInstance || !root) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDroot && 
                UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { 
                    root: root, 
                    updateTemplateEvent: Date.now() 
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Broadcasts a long-running operation's progress status to all clients of an organisation.
 *
 * This is an organisation-wide broadcast — every connected client whose
 * `UIDroot` matches `root` receives `{ progressStatus: status }`.
 * The frontend forwards the payload to any registered `cbProgressData` callbacks.
 *
 * Typical callers: import jobs, bulk operations, async queue workers.
 *
 * @param {string|Buffer} root   - Organisation UID to address
 * @param {Object}        status - Progress payload (structure is caller-defined)
 * @returns {void}
 */
export const sendProgressStatus = (root, status) => {
    try {
        if (!ioInstance || !root) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.UIDroot && 
                UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', {progressStatus: status });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Pushes an object-change payload to all clients that have registered the
 * object via `monitorObject` / {@link addObjectAbo}.
 *
 * Iterates `clientData.objectAbo` (a `Set<Buffer>`) for each connected client
 * and emits `{ object: true, value }` to matching sockets.
 *
 * Called by API routes whenever an object's data changes (e.g. after a PATCH).
 *
 * @param {string|Buffer} UID   - UUID of the changed object
 * @param {*}             value - Full or partial object payload to push to clients
 * @returns {void}
 */
export const addUpdateEntry = (UID, value) => {
    try {
        if (!ioInstance) return;
        
        const myUID = UUID2hex(UID);
        if (!myUID) return;
        
        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.objectAbo instanceof Set &&
                [...clientData.objectAbo].some(oa => oa && oa.equals(myUID))) {
                const socket = ioInstance.in(socketID);
                socket.emit('message', { object: true, value: value });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Notifies clients that one or more lists have changed, with leading-edge
 * debouncing to prevent update storms.
 *
 * **Debounce strategy (per list UID):**
 * - First change: sends `{ update: true, UID, timestamp }` immediately to all
 *   subscribed clients and records the UID in `delayedUpdateTimestamps`.
 * - Subsequent changes within the debounce window: refreshes the timestamp
 *   but does NOT send again immediately.
 * - {@link monitorDelayedUpdates} (called by a periodic interval) flushes any
 *   entry that has been quiet for the configured debounce period.
 *
 * Clients subscribed via `monitor` / {@link addListAbo} have the matching
 * UID in their `updateAbo` Set and receive the notification.
 *
 * @param {string|Buffer|string[]|Buffer[]} values - List UUID(s) that have changed
 * @returns {boolean} `true` if at least one update was processed, `false` on error
 */
export const addUpdateList = (values) => {
    try {
        const updates = Array.isArray(values) 
            ? values.filter(v => v).map(v => UUID2hex(v)) 
            : values ? [UUID2hex(values)] : [];
            
        if (updates.length > 0) {
            const timestamp = Date.now();
            
            for (const update of updates) {
                const updateKey = update.toString('base64');
                
                // Check if this update is already being debounced
                if (delayedUpdateTimestamps.has(updateKey)) {
                    // Update already queued, just refresh its timestamp
                    delayedUpdateTimestamps.set(updateKey, timestamp);
                } else {
                    // First update for this list - send immediately and start debouncing
                    delayedUpdateTimestamps.set(updateKey, timestamp);
                    
                    // Send immediately to subscribed clients
                    for (const [socketID, clientData] of clientDataStore) {
                        if (clientData.updateAbo instanceof Set &&
                            [...clientData.updateAbo].some(abo => abo && abo.equals(update))) {
                            const socket = ioInstance.in(socketID);
                            socket.emit('message', {
                                update: true,
                                UID: HEX2uuid(update),
                                timestamp: timestamp
                            });
                        }
                    }
                }
            }
            
            return true;
        }
        
        return false;
    } catch (e) {
        errorLogger(e);
        return false;
    }
};


/**
 * Processes the `delayedUpdateTimestamps` map and flushes any entries that
 * have been quiet for longer than the debounce threshold (2.5 s).
 *
 * Should be invoked on a periodic interval (e.g. `setInterval`) by the
 * server startup code. For each overdue entry it:
 * 1. Finds all clients with a matching UID in their `updateAbo` Set.
 * 2. Emits `{ update: true, UID, timestamp }` to those clients.
 * 3. Removes the entry from `delayedUpdateTimestamps`.
 *
 * This is the trailing-edge part of the two-phase debounce implemented
 * together with {@link addUpdateList}.
 *
 * @returns {void}
 */
export const monitorDelayedUpdates = () => {
    try {
        if (!ioInstance) return;
        
        const debounceDelay = 2500; // 2.5 seconds debounce
        const now = Date.now();

        // Check all delayed updates
        for (const [UIDlistB64, timestamp] of delayedUpdateTimestamps) {
            // If update has been delayed long enough, process it
            if (now - timestamp > debounceDelay) {
                try {
                    const UIDlist = Buffer.from(UIDlistB64, 'base64');
                    
                    // Find all clients that have subscribed to this list
                    for (const [socketID, clientData] of clientDataStore) {
                        if (clientData.updateAbo instanceof Set &&
                            [...clientData.updateAbo].some(abo => abo && abo.equals(UIDlist))) {
                            
                            const socket = ioInstance.in(socketID);
                            
                            // Send update notification
                            socket.emit('message', {
                                update: true,
                                UID: HEX2uuid(UIDlist),
                                timestamp: now
                            });
                        }
                    }
                    
                    // Remove this update from the delayed updates map
                    delayedUpdateTimestamps.delete(UIDlistB64);
                } catch (innerError) {
                    console.error('Error processing delayed update:', innerError);
                }
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Immediately emits a list-update notification to all clients monitoring `UID`,
 * bypassing the debounce logic of {@link addUpdateList}.
 *
 * Use this when the update must arrive without delay (e.g. after a server-side
 * transaction completes and the client needs to refresh right away).
 * Emits `{ update: true, UID, timestamp }` via the `update` event.
 *
 * @param {string|Buffer} UID                  - UUID of the changed list
 * @param {number}        [timestamp=Date.now()] - Timestamp to include in the message
 * @returns {void}
 */
export const broadcastUpdate = (UID, timestamp = Date.now()) => {
    try {
        if (!ioInstance) return;
        const myUID = UUID2hex(UID);
        if (!myUID) return;

        for (const [socketID, clientData] of clientDataStore) {
            if (clientData.updateAbo instanceof Set &&
                [...clientData.updateAbo].some(abo => abo && abo.equals(myUID))) {
                const socket = ioInstance.in(socketID);
                socket.emit('update', {
                    update: true,
                    UID: HEX2uuid(myUID),
                    timestamp
                });
            }
        }
    } catch (e) {
        errorLogger(e);
    }
};


/**
 * Sends a transaction-completion message to a single specific client.
 *
 * Unlike the organisation-wide broadcast functions, this targets exactly one
 * socket by its `socketID` room. Emits a `transactionComplete` event with
 * the provided data payload.
 *
 * Called after a bulk database transaction finishes, so the initiating client
 * can refresh its state.
 *
 * @param {string} socketID - Base64-encoded socket identifier (key in clientDataStore)
 * @param {Object} data     - Transaction result payload to forward to the client
 * @returns {void}
 */
export const sendTransaction = (socketID, data) => {
    try {
        if (!ioInstance) return;
        const socket = ioInstance.in(socketID);
        socket.emit('transactionComplete', data);
    } catch (e) {
        errorLogger(e);
    }
};

/**
 * Clears all in-memory state managed by this module.
 *
 * Empties both {@link clientDataStore} and {@link delayedUpdateTimestamps}.
 * Intended for use during a graceful server shutdown or in test teardown to
 * prevent state leaking between test cases.
 *
 * Does NOT close existing socket connections — call `io.close()` separately
 * if a full shutdown is required.
 *
 * @returns {void}
 */
export const cleanupSocketData = () => {
    try {
        clientDataStore.clear();
        delayedUpdateTimestamps.clear();
    } catch (e) {
        errorLogger(e);
    }
};

// Update the default export to include all functions
export default {
    clientDataStore,
    delayedUpdateTimestamps,
    setupSocketHandlers,
    sendMessageBuffered,
    broadcastUpdate,        // <-- added
    sendTransaction,        // <-- added
    monitorDelayedUpdates,
    cleanupSocketData,      // <-- added
    updateQueueStatus,
    updateTemplate,
    updateConfig,
    wsFakeUser,
    updateUserSettings,
    updateTemplateEvent,
    sendProgressStatus,
    addUpdateEntry,
    addUpdateList,

};