#include timedp.js
#include sockchat/authed.js
#include sockchat/ctx.js
#include sockchat/unauthed.js

const SockChatProtocol = function(dispatch, options) {
    if(typeof dispatch !== 'function')
        throw 'dispatch must be a function';
    if(typeof options !== 'object' || options === null)
        throw 'options must be an object';
    if(typeof options.ping !== 'number')
        throw 'options.ping must be a number';

    let ctx, sock;
    let dumpPackets = false;

    const handlers = {
        unauthed: {
            '1': {
                'y': SockChatS2CAuthSuccess,
                'n': SockChatS2CAuthFail,
            },
            '7': {
                // MOTD gets sent before auth success :D
                '1': SockChatS2CMessagePopulate,
            },
        },
        authed: {
            '0': SockChatS2CPong,
            '1': SockChatS2CUserAdd,
            '2': SockChatS2CMessageAdd,
            '3': SockChatS2CUserRemove,
            '4': {
                '0': SockChatS2CChannelAdd,
                '1': SockChatS2CChannelUpdate,
                '2': SockChatS2CChannelRemove,
            },
            '5': {
                '0': SockChatS2CUserChannelJoin,
                '1': SockChatS2CUserChannelLeave,
                '2': SockChatS2CUserChannelFocus,
            },
            '6': SockChatS2CMessageRemove,
            '7': {
                '0': SockChatS2CUserPopulate,
                '1': SockChatS2CMessagePopulate,
                '2': SockChatS2CChannelPopulate,
            },
            '8': SockChatS2CContextClear,
            '9': SockChatS2CBanKick,
            '10': SockChatS2CUserUpdate,
        },
    };

    const handleOpen = () => {
        if(dumpPackets)
            console.log('[sockchat:open]');

        ctx.isRestarting = false;
        ctx.keepAlive.start();

        const detail = {
            opitons: options,
            wasConnected: ctx.wasConnected,
            wasKicked: ctx.wasKicked,
        };

        dispatch('conn:open', detail);

        // event (and maybe also resolve?) should be delayed until after capability negotiation
        dispatch('conn:ready');

        ctx.openPromise?.resolve(detail);
    };

    const handleClose = ev => {
        if(dumpPackets)
            console.log('[sockchat:close]', ev.code, ev.reason, ev.wasClean);

        ctx.keepAlive.stop();
        ctx.userId = undefined;
        ctx.channelName = undefined;
        ctx.pseudoChannelName = undefined;

        if(ev.code === 1012)
            ctx.isRestarting = true;

        const detail = {
            code: ev.code,
            wasConnected: ctx.wasConnected,
            isRestarting: ctx.isRestarting,
            wasKicked: ctx.wasKicked,
        };

        dispatch('conn:lost', detail);
        ctx.openPromise?.reject(detail);
    };

    const handleMessage = ev => {
        const args = ev.data.split("\t");
        if(dumpPackets)
            console.log('[sockchat:incoming]', args);

        let handler = handlers[ctx.isAuthed ? 'authed' : 'unauthed'];

        for(;;) {
            handler = handler[args.shift()];
            if(handler === undefined)
                break;

            if(typeof handler === 'function') {
                handler(ctx, ...args);
                break;
            }
        }
    };

    const send = (...args) => {
        if(args.length < 1)
            throw 'you must specify at least one argument as an opcode';

        if(ctx?.dumpPackets)
            console.log('[sockchat:outgoing]', args);

        sock?.send(args.join("\t"));
    };

    const sendPing = () => {
        return new Promise((resolve, reject) => {
            if(ctx === undefined)
                throw 'no connection opened';
            if(!ctx.isAuthed)
                throw 'must be authenticated';
            if(ctx.pingPromise !== undefined)
                throw 'already sending a ping';

            ctx.lastPing = Date.now();
            ctx.pingPromise = new TimedPromise(resolve, reject, () => ctx.pingPromise = undefined, 2000);

            send('0', ctx.userId);
        });
    };

    const sendAuth = (...args) => {
        return new Promise((resolve, reject) => {
            if(ctx === undefined)
                throw 'no connection opened';
            if(ctx.isAuthed)
                throw 'already authenticated';
            if(ctx.authPromise !== undefined)
                throw 'already authenticating';

            // HttpClient in C# has its Moments, so lets give this way too long to do its thing
            ctx.authPromise = new TimedPromise(resolve, reject, () => ctx.authPromise = undefined, 10000);

            send('1', ...args);
        });
    };

    const sendMessage = text => {
        return new Promise((resolve, reject) => {
            if(typeof text !== 'string')
                throw 'text must be a string';
            if(ctx === undefined)
                throw 'no connection opened';
            if(!ctx.isAuthed)
                throw 'must be authenticated';

            // there's actually a pretty big bug here lol
            // any unsupported command is gonna fall through to the actual channel you're in
            if(!text.startsWith('/') && ctx.pseudoChannelName !== undefined)
                text = `/msg ${ctx.pseudoChannelName} ${text}`;

            send('2', ctx.userId, text);

            // server doesn't send a direct ACK and we can't tell what message
            // which response messages is actually associated with this
            // an ACK or request/response extension to the protocol will be required
            if(typeof resolve === 'function')
                resolve();
        });
    };

    const createContext = () => {
        ctx = new SockChatContext(dispatch, sendPing, options.ping);
    };

    const closeWebSocket = () => {
        try {
            const localSock = sock;
            sock = undefined;

            if(localSock !== undefined) {
                localSock.removeEventListener('message', handleMessage);
                localSock.removeEventListener('close', handleClose);
                localSock.removeEventListener('open', handleOpen);
                localSock.close();
            }
        } finally {
            ctx?.dispose();
        }
    };

    return {
        setDumpPackets: state => {
            dumpPackets = !!state;
        },
        open: url => {
            return new Promise((resolve, reject) => {
                if(typeof url !== 'string')
                    throw 'url must be a string';
                if(ctx?.openPromise !== undefined)
                    throw 'already opening a connection';

                closeWebSocket();
                createContext();

                ctx.openPromise = new TimedPromise(resolve, reject, () => ctx.openPromise = undefined, 5000);

                sock = new WebSocket(url);
                sock.addEventListener('open', handleOpen);
                sock.addEventListener('close', handleClose);
                sock.addEventListener('message', handleMessage);
            });
        },
        close: () => { closeWebSocket(); },
        sendPing: sendPing,
        sendAuth: sendAuth,
        sendMessage: sendMessage,
        switchChannel: async info => {
            if(!ctx.isAuthed)
                return;

            const name = info.name;

            if(info.isUserChannel) {
                selfPseudoChannelName = name.substring(1);
            } else {
                ctx.pseudoChannelName = undefined;
                if(ctx.channelName === name)
                    return;

                ctx.channelName = name;
                await sendMessage(`/join ${name}`);
            }
        },
    };
};
