/*!
 * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file
 */
import { EventEmitter } from 'events';
import backoff from 'backoff';
import Socket from './socket';
import { BadRequest, Forbidden, NotAuthorized, UnknownResponse } from './errors';
import { MOBIUS_SOCKET_4001_EVENT } from './socket/constants';
const normalReconnectReasons = ['idle', 'done (forced)'];
const MOBIUS_SOCKET_NAMESPACE = 'MobiusSocket';
const TOKEN_REFRESH_INTERVAL_MS = 1 * 60 * 60 * 1000;
function normalizeMobiusAuthToken(token) {
    return token.replace(/^Bearer\s+/i, '');
}
class MobiusSocket extends EventEmitter {
    webex;
    config;
    logger;
    connected;
    connecting;
    hasEverConnected;
    socket;
    backoffCall;
    shutdownSwitchoverBackoffCall;
    seenAsyncEventIds;
    connectPromise;
    socketUrl;
    tokenRefreshTimer;
    tokenRefreshInFlight;
    constructor(webex, config = {}) {
        super();
        if (!webex) {
            throw new Error('A Webex instance is required when initializing MobiusSocket');
        }
        this.webex = webex;
        this.config = config;
        this.logger = webex.logger || console;
        this.connected = false;
        this.connecting = false;
        this.hasEverConnected = false;
        this.socket = undefined;
        this.backoffCall = undefined;
        this.shutdownSwitchoverBackoffCall = undefined;
        this.seenAsyncEventIds = new Map();
        this.connectPromise = undefined;
        this.tokenRefreshTimer = undefined;
        this.tokenRefreshInFlight = undefined;
    }
    off(eventName, listener) {
        if (listener) {
            return super.off(eventName, listener);
        }
        this.removeAllListeners(eventName);
        return this;
    }
    attachSocketEventListeners(socket) {
        socket.on('close', (event) => this.onclose(event, socket));
        socket.on('message', (event) => this.onmessage(event));
    }
    trackAsyncEventAndShouldSuppressDuplicate(envelope) {
        if (envelope?.type !== 'async_event' || !envelope.eventId) {
            return false;
        }
        if (this.seenAsyncEventIds.has(envelope.eventId)) {
            const previousValue = this.seenAsyncEventIds.get(envelope.eventId) || true;
            this.seenAsyncEventIds.delete(envelope.eventId);
            this.seenAsyncEventIds.set(envelope.eventId, previousValue);
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: duplicate async_event suppressed, eventId=${envelope.eventId}`);
            return true;
        }
        this.logger.log(`${MOBIUS_SOCKET_NAMESPACE}: tracking async_event, eventId=${envelope.eventId}`);
        this.seenAsyncEventIds.set(envelope.eventId, true);
        if (this.config.dedupCacheMaxSize &&
            this.seenAsyncEventIds.size > this.config.dedupCacheMaxSize) {
            const oldestEventId = this.seenAsyncEventIds.keys().next().value || '';
            this.seenAsyncEventIds.delete(oldestEventId);
            this.logger.log(`${MOBIUS_SOCKET_NAMESPACE}: evicted oldest async_event from dedup cache, eventId=${oldestEventId}`);
        }
        return false;
    }
    handleImminentShutdown() {
        const oldSocket = this.socket;
        try {
            if (this.shutdownSwitchoverBackoffCall) {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover already in progress`);
                return;
            }
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover start`);
            this.connectWithBackoff(undefined, {
                isShutdownSwitchover: true,
                attemptOptions: {
                    isShutdownSwitchover: true,
                    onSuccess: (newSocket, webSocketUrl) => {
                        this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover connected, url: ${webSocketUrl}`);
                        newSocket.connecting = false;
                        newSocket.connected = true;
                        this.socket = newSocket;
                        this.connected = true;
                        this.emitEvent('event:mobius_shutdown_switchover_complete', {
                            url: webSocketUrl,
                        });
                        if (oldSocket) {
                            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] old socket retained; server will close with 4001`);
                        }
                    },
                },
            })
                .then(() => {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover completed successfully`);
            })
                .catch((err) => {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover exhausted retries; will fall back to normal reconnection: `, err);
                this.emitEvent('event:mobius_shutdown_switchover_failed', { reason: err });
            });
        }
        catch (e) {
            this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] error during switchover`, e);
            this.shutdownSwitchoverBackoffCall = undefined;
            this.emitEvent('event:mobius_shutdown_switchover_failed', { reason: e });
        }
    }
    getConnectedWebSocketUrl() {
        if (!this.socket?.connected) {
            return undefined;
        }
        return this.socket.url;
    }
    sendWssRequest(payload, options = {}) {
        if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
            return Promise.reject(new Error('`payload` is required'));
        }
        if (!this.socket || !this.socket.connected) {
            return Promise.reject(new Error('Mobius socket is not connected'));
        }
        return this.socket.sendRequest(payload, { timeout: options.timeout });
    }
    isConnected() {
        return this.connected;
    }
    connect(webSocketUrl) {
        if (this.connectPromise) {
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: connection already in progress, returning existing promise`);
            return this.connectPromise;
        }
        if (this.socket?.connected || this.socket?.connecting) {
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: already connected, will not connect again`);
            return Promise.resolve();
        }
        if (webSocketUrl && this.socketUrl && webSocketUrl !== this.socketUrl) {
            this.hasEverConnected = false;
        }
        if (webSocketUrl) {
            this.socketUrl = webSocketUrl;
        }
        this.connecting = true;
        this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: starting connection attempt${Number(this.config.initialConnectionMaxRetries) === 0 && !this.hasEverConnected
            ? ' (initial retries disabled)'
            : ''}`);
        const connectPromise = Promise.resolve(this.webex.internal.device.registered || this.webex.internal.device.register?.())
            .then(() => {
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: connecting`);
            return this.connectWithBackoff(this.socketUrl);
        })
            .finally(() => {
            this.connectPromise = undefined;
        });
        this.connectPromise = connectPromise;
        return connectPromise;
    }
    disconnect(options) {
        this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}#disconnect: connecting state: ${this.connecting},
       connected state: ${this.connected}, socket exists: ${!!this.socket},
       options: ${JSON.stringify(options)}`);
        if (this.backoffCall) {
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: aborting connection`);
            this.backoffCall.abort();
            this.backoffCall = undefined;
        }
        if (this.shutdownSwitchoverBackoffCall) {
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: aborting shutdown switchover connection`);
            this.shutdownSwitchoverBackoffCall.abort();
            this.shutdownSwitchoverBackoffCall = undefined;
        }
        this.connectPromise = undefined;
        this.seenAsyncEventIds.clear();
        if (!this.socket) {
            this.connected = false;
            this.stopTokenRefreshTimer();
            return Promise.resolve();
        }
        this.socket.removeAllListeners('message');
        this.socket.connecting = false;
        this.socket.connected = false;
        return Promise.resolve(this.socket.close(options || undefined)).finally(() => {
            this.connected = false;
            this.stopTokenRefreshTimer();
        });
    }
    prepareUrl(webSocketUrl) {
        if (!webSocketUrl) {
            webSocketUrl = this.webex.internal.device.webSocketUrl || '';
        }
        return Promise.resolve(webSocketUrl);
    }
    attemptConnection(socketUrl, callback, options = {}) {
        const { isShutdownSwitchover = false, attemptOptions = {} } = options;
        const { onSuccess = null } = attemptOptions;
        const socket = new Socket();
        socket.connecting = true;
        let newWSUrl;
        this.attachSocketEventListeners(socket);
        const backoffCall = isShutdownSwitchover
            ? this.shutdownSwitchoverBackoffCall
            : this.backoffCall;
        if (!backoffCall) {
            const mode = isShutdownSwitchover ? 'switchover backoff call' : 'backoffCall';
            const msg = `${MOBIUS_SOCKET_NAMESPACE}: prevent socket open when ${mode} no longer defined`;
            const err = new Error(msg);
            this.logger.info(msg);
            callback(err);
            return Promise.reject(err);
        }
        if (!isShutdownSwitchover) {
            this.socket = socket;
        }
        return this.prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
            .then((webSocketUrl) => {
            newWSUrl = webSocketUrl;
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: ${isShutdownSwitchover ? '[shutdown] switchover' : ''} connected to mobius socket, success, url: ${newWSUrl}`);
            if (onSuccess) {
                onSuccess(socket, webSocketUrl);
                callback();
                return Promise.resolve();
            }
            callback();
            return Promise.resolve();
        })
            .catch((reason) => {
            if (isShutdownSwitchover) {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: [shutdown] switchover attempt failed`, reason);
                return callback(reason);
            }
            const backoffCallNormal = this.backoffCall;
            if (reason.code !== 1006 && backoffCallNormal && backoffCallNormal?.getNumRetries() > 0) {
                this.emitEvent('connection_failed', reason, {
                    retries: backoffCallNormal?.getNumRetries(),
                });
            }
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: connection attempt failed`, reason, backoffCallNormal?.getNumRetries() === 0 ? reason.stack : '');
            if (reason instanceof UnknownResponse) {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: received unknown response code, refreshing device registration`);
                return this.webex.internal.device
                    .refresh?.()
                    .then(() => callback(reason));
            }
            if (reason instanceof NotAuthorized) {
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: received authorization error, reauthorizing`);
                return this.webex.credentials
                    .refresh?.({ force: true })
                    .then(() => callback(reason));
            }
            if (reason instanceof BadRequest || reason instanceof Forbidden) {
                this.logger.warn(`${MOBIUS_SOCKET_NAMESPACE}: received unrecoverable response from ${MOBIUS_SOCKET_NAMESPACE}`);
                backoffCallNormal?.abort();
                return callback(reason);
            }
            return callback(reason);
        })
            .catch((reason) => {
            this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: failed to handle connection failure`, reason);
            callback(reason);
        });
    }
    prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
        const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
        return Promise.all([this.prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(([webSocketUrl, token]) => {
            let options = {
                forceCloseDelay: this.config.forceCloseDelay,
                wssResponseTimeout: this.config.wssResponseTimeout,
                token: normalizeMobiusAuthToken(token.toString()),
                refreshToken: () => this.refreshToken(),
                trackingId: `${this.webex.sessionId}_${Date.now()}`,
                logger: this.logger,
            };
            if (this.webex.config.defaultMobiusSocketOptions) {
                const customOptionsMsg = isShutdownSwitchover
                    ? 'setting custom options for switchover'
                    : 'setting custom options';
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: ${customOptionsMsg}`);
                options = { ...options, ...this.webex.config.defaultMobiusSocketOptions };
            }
            if (!isShutdownSwitchover) {
                this.socket = socket;
            }
            this.logger.info(`${MOBIUS_SOCKET_NAMESPACE} ${logPrefix} url: ${webSocketUrl}`);
            return socket.open(webSocketUrl, options).then(() => webSocketUrl);
        });
    }
    connectWithBackoff(webSocketUrl, context = {}) {
        const { isShutdownSwitchover = false, attemptOptions = {} } = context;
        return new Promise((resolve, reject) => {
            let call;
            const isInitialConnect = !isShutdownSwitchover && !this.hasEverConnected;
            const initialRetryLimit = this.config.initialConnectionMaxRetries == null
                ? null
                : Number(this.config.initialConnectionMaxRetries);
            const isInitialConnectWithoutRetries = isInitialConnect && initialRetryLimit === 0;
            const onComplete = (err) => {
                if (isShutdownSwitchover) {
                    this.shutdownSwitchoverBackoffCall = undefined;
                }
                else {
                    this.backoffCall = undefined;
                }
                if (err) {
                    const msg = isShutdownSwitchover
                        ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
                        : `failed to connect after ${call.getNumRetries()} retries`;
                    this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: ${msg}; ${err}`);
                    if (!isShutdownSwitchover && this.socket) {
                        this.socket.connecting = false;
                        this.socket.connected = false;
                    }
                    return reject(err);
                }
                if (!isShutdownSwitchover && this.socket) {
                    this.socket.connecting = false;
                    this.socket.connected = true;
                }
                if (!isShutdownSwitchover) {
                    this.connecting = false;
                    this.connected = true;
                    this.hasEverConnected = true;
                    this.startTokenRefreshTimer();
                    this.emitEvent('online');
                }
                return resolve();
            };
            call = backoff.call((callback) => {
                const attemptNum = call.getNumRetries();
                const attemptLogPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: executing ${attemptLogPrefix} attempt ${attemptNum}`);
                this.attemptConnection(webSocketUrl, callback, attemptOptions);
            }, (err) => onComplete(err));
            call.setStrategy(new backoff.ExponentialStrategy({
                initialDelay: this.config.backoffTimeReset,
                maxDelay: this.config.backoffTimeMax,
            }));
            if (isInitialConnectWithoutRetries) {
                call.retryIf(() => false);
            }
            else if (isInitialConnect && initialRetryLimit !== null && initialRetryLimit > 0) {
                call.failAfter(initialRetryLimit);
            }
            else if (this.config.maxRetries) {
                call.failAfter(this.config.maxRetries);
            }
            if (isShutdownSwitchover) {
                this.shutdownSwitchoverBackoffCall = call;
            }
            else {
                this.backoffCall = call;
            }
            call.on('abort', () => {
                const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: ${msg} aborted`);
                reject(new Error(`MobiusSocket ${msg} Aborted`));
            });
            call.on('callback', (err) => {
                if (err) {
                    if (isInitialConnectWithoutRetries) {
                        this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: initial connect failed; retries already disabled`);
                        return;
                    }
                    const number = call.getNumRetries();
                    const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax || Infinity);
                    const callbackLogPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
                    this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: ${callbackLogPrefix} failed to connect; attempting retry ${number + 1} in ${delay} ms`);
                    if (process.env.NODE_ENV === 'development') {
                        this.logger.debug(`${MOBIUS_SOCKET_NAMESPACE}: `, err, err.stack);
                    }
                    return;
                }
                this.logger.info(`${MOBIUS_SOCKET_NAMESPACE}: connected`);
            });
            call.start();
        });
    }
    emitEvent(eventName, ...args) {
        try {
            if (!eventName) {
                return;
            }
            this.emit(eventName, ...args);
        }
        catch (error) {
            this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: error occurred in event handler:`, error, ' with args: ', [eventName, ...args]);
        }
    }
    startTokenRefreshTimer() {
        if (this.tokenRefreshTimer || !this.connected) {
            return;
        }
        this.tokenRefreshTimer = setInterval(() => {
            this.refreshToken().catch((error) => {
                this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: periodic token refresh failed`, error);
            });
        }, TOKEN_REFRESH_INTERVAL_MS);
    }
    stopTokenRefreshTimer() {
        if (!this.tokenRefreshTimer) {
            return;
        }
        clearInterval(this.tokenRefreshTimer);
        this.tokenRefreshTimer = undefined;
    }
    refreshToken() {
        if (this.tokenRefreshInFlight) {
            return this.tokenRefreshInFlight;
        }
        if (!this.connected) {
            this.stopTokenRefreshTimer();
            return Promise.resolve();
        }
        const tokenPromise = this.webex.credentials.canRefresh
            ? this.webex.credentials
                .refresh?.({ force: true })
                ?.then(() => this.webex.credentials.getUserToken())
            : this.webex.credentials.getUserToken();
        this.tokenRefreshInFlight = tokenPromise
            .then((token) => {
            if (!token) {
                throw new Error('Mobius token refresh did not return a token');
            }
            const refreshedToken = normalizeMobiusAuthToken(token.toString());
            if (!this.socket?.connected) {
                this.logger.warn(`${MOBIUS_SOCKET_NAMESPACE}: socket is not connected, skipping token refresh`);
                return undefined;
            }
            return this.socket.refresh(refreshedToken);
        })
            .catch((error) => {
            this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: failed to refresh/re-auth Mobius socket`, error);
            throw error;
        })
            .finally(() => {
            this.tokenRefreshInFlight = undefined;
        });
        return this.tokenRefreshInFlight;
    }
    async onclose(event, sourceSocket) {
        const loggerContext = {
            file: 'mobius-socket.ts',
            method: 'onclose',
        };
        try {
            const reason = event.reason && event.reason.toLowerCase();
            let socketUrl;
            const isActiveSocket = sourceSocket === this.socket;
            if (sourceSocket) {
                socketUrl = sourceSocket.url;
            }
            if (isActiveSocket) {
                if (this.socket) {
                    this.socket.removeAllListeners();
                    this.socket = undefined;
                    this.emitEvent('offline', event);
                }
                this.connecting = false;
                this.connected = false;
                this.stopTokenRefreshTimer();
            }
            else {
                this.logger.info(`[shutdown] non-active socket closed, code=${event.code}`, loggerContext);
                if (sourceSocket) {
                    sourceSocket.removeAllListeners();
                }
            }
            switch (event.code) {
                case 1003:
                    this.logger.info(`service rejected last message; will not reconnect: ${event.reason}`, loggerContext);
                    if (isActiveSocket)
                        this.emitEvent('offline.permanent', event);
                    break;
                case 4000:
                    this.logger.info(`socket replaced; will not reconnect`, loggerContext);
                    if (isActiveSocket)
                        this.emitEvent('offline.replaced', event);
                    break;
                case 4001:
                    this.logger.info(`socket closed with 4001; will not reconnect`, loggerContext);
                    this.emitEvent('event:async_event', MOBIUS_SOCKET_4001_EVENT);
                    break;
                case 1000:
                case 1001:
                    this.logger.info(`socket disconnected; ${event.reason}`, loggerContext);
                    if (isActiveSocket)
                        this.emitEvent('offline.permanent', event);
                    break;
                case 1005:
                case 1006:
                case 1011:
                case 1012:
                    this.logger.info(`socket disconnected; reconnecting`, loggerContext);
                    if (isActiveSocket) {
                        this.emitEvent('offline.transient', event);
                        this.reconnect(socketUrl);
                    }
                    break;
                case 3050:
                    if (reason && normalReconnectReasons.includes(reason)) {
                        this.logger.info(`socket disconnected; reconnecting`, loggerContext);
                        if (isActiveSocket) {
                            this.emitEvent('offline.transient', event);
                            this.reconnect(socketUrl);
                        }
                    }
                    else {
                        this.logger.info(`socket disconnected; will not reconnect: ${event.reason}`, loggerContext);
                        if (isActiveSocket)
                            this.emitEvent('offline.permanent', event);
                    }
                    break;
                case 4401:
                case 4403:
                case 4404:
                    this.logger.error(`onclose, statusCode=${event.code}`, loggerContext);
                    await this.refreshToken().catch((error) => {
                        this.logger.error(`${MOBIUS_SOCKET_NAMESPACE}: periodic token refresh failed`, error);
                    });
                    await this.reconnect(this.socket?.url);
                    break;
                case 4429:
                    this.logger.error(`too many requests, statusCode=${event.code}`, loggerContext);
                    break;
                default:
                    this.logger.info(`socket disconnected unexpectedly; will not reconnect`, loggerContext);
                    if (isActiveSocket)
                        this.emitEvent('offline.permanent', event);
            }
        }
        catch (error) {
            this.logger.error(`error occurred in close handler`, error, loggerContext);
        }
    }
    onmessage(event) {
        const loggerContext = {
            file: 'mobius-socket.ts',
            method: 'onmessage',
        };
        const envelope = event.data;
        this.logger.debug(`message envelope: `, envelope, loggerContext);
        if (envelope && envelope.type === 'shutdown') {
            this.logger.info(`[shutdown] imminent shutdown message received`, loggerContext);
            this.emitEvent('event:mobius_shutdown_imminent', envelope);
            this.handleImminentShutdown();
            return Promise.resolve();
        }
        if (this.trackAsyncEventAndShouldSuppressDuplicate(envelope)) {
            return Promise.resolve();
        }
        if (envelope.type) {
            this.emitEvent(`event:${envelope.type}`, envelope);
        }
        const data = envelope.data || envelope;
        const eventType = data?.eventType || envelope.eventType;
        if (!eventType) {
            this.emitEvent('event', envelope);
            return Promise.resolve();
        }
        try {
            this.emitEvent('event', envelope);
            const [namespace] = eventType.split('.');
            this.emitEvent(`event:${namespace}`, envelope);
            if (namespace !== eventType) {
                this.emitEvent(`event:${eventType}`, envelope);
            }
        }
        catch (reason) {
            this.logger.error(`error occurred processing socket message`, reason, loggerContext);
        }
        return Promise.resolve();
    }
    reconnect(webSocketUrl) {
        this.logger.info(`reconnecting`, { file: 'mobius-socket.ts', method: 'reconnect' });
        return this.connect(webSocketUrl || this.socketUrl);
    }
}
export default MobiusSocket;
