import * as ws from 'ws';
import getLogger from '../../../logger.js';
import {socketKey} from '../../../utils.ts';
import {IOGateway} from '@interopio/gateway';
import GatewayEncoders = IOGateway.Encoding;

const logger = getLogger('mesh.ws.broker');

type Command =
    // client to broker
    { type: 'hello', 'node-id': string }
    | { type: 'bye', 'node-id': string }
    | { type: 'data', from: string, to: 'all' | string}
    // broker to client
    | { type: 'node-added', 'node-id': string, 'new-node': string }
    | { type: 'node-removed', 'node-id': string, 'removed-node': string }

    ;


function broadcastNodeAdded(nodes: NodeSockets, newSocket: ws.WebSocket, newNodeId: string) {
    Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {
        if (nodeId !== newNodeId) {
            newSocket.send(codec.encode({type: 'node-added', 'node-id': newNodeId, "new-node": nodeId}));
            socket.send(codec.encode({type: 'node-added', 'node-id': nodeId, "new-node": newNodeId}));
        }
    });
}

function broadcastNodeRemoved(nodes: NodeSockets, removedNodeId: string) {
    Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {
        if (nodeId !== removedNodeId) {
            socket.send(codec.encode({type: 'node-removed', 'node-id': nodeId, "removed-node": removedNodeId}));
        }
    });
}

function onOpen(connectedNodes: NodeSockets, key: string) {
    logger.info(`[${key}] connection accepted`);
}

function onClose(connectedNodes: NodeSockets, key: string, code: number, reason: string): void {
    logger.info(`[${key}] connected closed [${code}](${reason})`);
    const nodeIds = connectedNodes.sockets[key];
    if (nodeIds) {
        delete connectedNodes.sockets[key];
        for (const nodeId of nodeIds) {
            delete connectedNodes.nodes[nodeId];
        }
        for (const nodeId of nodeIds) {
            broadcastNodeRemoved(connectedNodes, nodeId);
        }
    }
}

function processMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: Command) {
    switch (msg.type) {
        case 'hello': {
            const nodeId = msg['node-id'];
            connectedNodes.nodes[nodeId] = socket;
            connectedNodes.sockets[key] = connectedNodes.sockets[key] ?? [];
            connectedNodes.sockets[key].push(nodeId);
            logger.info(`[${key}] node ${nodeId} added.`);
            broadcastNodeAdded(connectedNodes, socket, nodeId);
            break;
        }
        case 'bye': {
            const nodeId = msg["node-id"];
            delete connectedNodes[nodeId];
            logger.info(`[${key}] node ${nodeId} removed.`);
            broadcastNodeRemoved(connectedNodes, nodeId);
            break;
        }
        case 'data': {
            const sourceNodeId = msg.from;
            const targetNodeId = msg.to;
            if ('all' === targetNodeId) {
                Object.entries(connectedNodes.nodes).forEach(([nodeId, socket]) => {
                    if (nodeId !== sourceNodeId) {
                        socket.send(codec.encode(msg));
                    }
                });
            }
            else {
                const socket = connectedNodes.nodes[targetNodeId];
                if (socket) {
                    socket.send(codec.encode(msg));
                }
                else {
                    logger.warn(`unable to send to node ${targetNodeId} message ${JSON.stringify(msg)}`);
                }
            }
            break;
        }
        default: {
            logger.warn(`[${key}] ignoring unknown message ${JSON.stringify(msg)}`);
            break;
        }
    }
}

const codec = GatewayEncoders.transit<Command>({
    keywordize: new Map<string, GatewayEncoders.KeywordizeCommand>([
        ['/type', '*'],
        ['/message/body/type', '*'],
        ['/message/origin', '*'],
        ['/message/receiver/type', '*'],
        ['/message/source/type', '*'],
        ['/message/body/type', '*'],
    ])
});

function onMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: string): void {
    try {
        const decoded = codec.decode(msg);
        if (logger.enabledFor('debug')) {
            logger.debug(`[${key}] processing msg ${JSON.stringify(decoded)}`);
        }
        processMessage(connectedNodes, socket, key, decoded);
    } catch (ex) {
        logger.error(`[${key}] unable to process message`, ex);
    }
}

class WebsocketBroker {
    constructor(private readonly server: ws.WebSocketServer) {
    }

    async close() {
        this.server.close();
    }
}
type NodeSockets = {nodes: {[nodeId: string]: ws.WebSocket}, sockets: {[socket: string]: string[]}};

async function create(server: {wss: ws.WebSocketServer}): Promise<{close: () => Promise<void>}> {
    const connectedNodes: NodeSockets = {nodes: {}, sockets: {}};
    logger.info(`mesh server is listening`);

    server.wss
        .on('error', () => {
            logger.error(`error starting mesh server`);
        })
        .on('connection', (socket, request) => {
            const key = socketKey(request.socket);
            onOpen(connectedNodes, key);
            socket.on('error', (err: Error) => {
                logger.error(`[${key}] websocket error: ${err}`, err);
            });
            socket.on('message', (data, isBinary) => {
                if (Array.isArray(data)) {
                    data = Buffer.concat(data);
                }
                onMessage(connectedNodes, socket, key, data as unknown as string);
            });
            socket.on('close', (code, reason) => {
                onClose(connectedNodes, key, code, reason as unknown as string);
            });
        });

    return new WebsocketBroker(server.wss);
}

export default create;
