/*!
 * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
 */
import { EventEmitter } from 'events';
import { checkRequired } from '@webex/common';
import { safeSetTimeout } from '@webex/common-timers';
import { defaults, has, isObject } from 'lodash';
import { BadRequest, ConnectionError, createTimeoutError, createWssResponseError, Forbidden, NotAuthorized, UnknownResponse, } from '../errors';
import { MESSAGE_TYPES, SOCKET_READY_STATE } from './constants';
const sockets = new WeakMap();
function createTrackingId() {
    return `webex-js-sdk_${crypto.randomUUID()}`;
}
export default class Socket extends EventEmitter {
    domain;
    pendingResponses;
    forceCloseDelay;
    logger;
    refreshToken;
    token;
    trackingId;
    wssResponseTimeout;
    constructor() {
        super();
        this.domain = 'unknown-domain';
        this.pendingResponses = new Map();
        this.onmessage = this.onmessage.bind(this);
        this.onclose = this.onclose.bind(this);
        this.setMaxListeners(10);
    }
    get binaryType() {
        return sockets.get(this)?.binaryType || '';
    }
    get bufferedAmount() {
        return sockets.get(this)?.bufferedAmount || 0;
    }
    get extensions() {
        return sockets.get(this)?.extensions || '';
    }
    get protocol() {
        return sockets.get(this)?.protocol || '';
    }
    get readyState() {
        return sockets.get(this)?.readyState || 0;
    }
    get url() {
        return sockets.get(this)?.url || '';
    }
    static getWebSocketConstructor() {
        throw new Error('Socket.getWebSocketConstructor() must be implemented in an environmentally appropriate way');
    }
    close(options) {
        return new Promise((resolve, reject) => {
            const socket = sockets.get(this);
            if (!socket) {
                resolve();
                return;
            }
            this.logger.info(`socket,${this.domain}: closing`);
            if (socket.readyState === SOCKET_READY_STATE.CLOSING ||
                socket.readyState === SOCKET_READY_STATE.CLOSED) {
                this.logger.info(`socket,${this.domain}: already closed`);
                resolve();
                return;
            }
            const originalCode = options?.code;
            const originalReason = options?.reason;
            const resolvedOptions = defaults(options || {}, {
                code: 1000,
                reason: 'Done',
            });
            if (resolvedOptions.code &&
                resolvedOptions.code !== 1000 &&
                (resolvedOptions.code < 3000 || resolvedOptions.code > 4999)) {
                reject(new Error('`options.code` must be 1000 or between 3000 and 4999 (inclusive)'));
                return;
            }
            const closeTimer = safeSetTimeout(() => {
                try {
                    this.logger.info(`socket,${this.domain}: no close event received, forcing closure`);
                    resolve(this.onclose(originalCode
                        ? { code: originalCode, reason: originalReason || 'Done (unknown)' }
                        : {
                            code: 1000,
                            reason: 'Done (forced)',
                        }));
                }
                catch (error) {
                    this.logger.warn(`socket,${this.domain}: force-close failed`, error);
                }
            }, this.forceCloseDelay);
            socket.onclose = (event) => {
                this.logger.info(`socket,${this.domain}: close event fired`, event.code, event.reason);
                clearTimeout(closeTimer);
                this.onclose(event);
                resolve(event);
            };
            if (socket.readyState === SOCKET_READY_STATE.CONNECTING) {
                this.logger.info(`socket,${this.domain}: socket still connecting, triggering close manually`);
                clearTimeout(closeTimer);
                const closeEvent = {
                    code: resolvedOptions.code,
                    reason: resolvedOptions.reason,
                };
                this.onclose(closeEvent);
                resolve(closeEvent);
                try {
                    socket.close(resolvedOptions.code, resolvedOptions.reason);
                }
                catch (error) {
                    this.logger.info(`socket,${this.domain}: error while closing CONNECTING socket, likely due to browser incompatibility with custom close codes`, error);
                }
            }
            else {
                socket.close(resolvedOptions.code, resolvedOptions.reason);
            }
        });
    }
    open(url, options) {
        try {
            this.domain = new URL(url).hostname;
        }
        catch {
            this.domain = url;
        }
        return new Promise((resolve, reject) => {
            if (!url) {
                reject(new Error('`url` is required'));
                return;
            }
            if (sockets.get(this)) {
                reject(new Error('Socket#open() can only be called once per instance'));
                return;
            }
            const resolvedOptions = (options || {});
            checkRequired(['forceCloseDelay', 'token', 'trackingId', 'logger'], resolvedOptions);
            Object.keys(resolvedOptions).forEach((key) => {
                Reflect.defineProperty(this, key, {
                    enumerable: false,
                    value: resolvedOptions[key],
                });
            });
            const WebSocket = Socket.getWebSocketConstructor();
            this.logger.info(`socket,${this.domain}: creating WebSocket`);
            const socket = new WebSocket(url, [], resolvedOptions);
            socket.binaryType = 'arraybuffer';
            socket.onmessage = this.onmessage;
            socket.onclose = (event) => {
                event = this.fixCloseCode(event);
                this.logger.info(`socket,${this.domain}: closed before open`, event.code, event.reason);
                switch (event.code) {
                    case 1005:
                        return reject(new UnknownResponse(event));
                    case 4400:
                        return reject(new BadRequest(event));
                    case 4401:
                        return reject(new NotAuthorized(event));
                    case 4403:
                        return reject(new Forbidden(event));
                    default:
                        return reject(new ConnectionError(event));
                }
            };
            socket.onopen = () => {
                this.logger.info(`socket,${this.domain}: connected`);
                this.authorize(this.token)
                    .then(() => {
                    this.logger.info(`socket,${this.domain}: authorized`);
                    socket.onclose = this.onclose;
                    resolve();
                })
                    .catch(reject);
            };
            socket.onerror = (event) => {
                this.logger.warn(`socket,${this.domain}: error event fired`, event);
            };
            sockets.set(this, socket);
            this.logger.info(`socket,${this.domain}: waiting for server`);
        });
    }
    onclose(event) {
        this.logger.info(`socket,${this.domain}: closed`, event.code, event.reason);
        event = this.fixCloseCode(event);
        this.rejectPendingResponses(new ConnectionError(event));
        this.emit('close', event);
        this.removeAllListeners();
    }
    onmessage(event) {
        try {
            const data = JSON.parse(event.data);
            const processedEvent = { data };
            if (data.type === 'async_event') {
                this.acknowledge(processedEvent).catch((error) => {
                    this.logger.warn(`socket,${this.domain}: failed to acknowledge async event`, error);
                });
            }
            this.handlePendingResponse(data);
            this.emit('message', processedEvent);
        }
        catch (error) {
            this.logger.warn(`socket,${this.domain}: error while receiving WebSocket message`, error);
        }
    }
    send(data) {
        return new Promise((resolve, reject) => {
            if (this.readyState !== SOCKET_READY_STATE.OPEN) {
                reject(new Error('INVALID_STATE_ERROR'));
                return;
            }
            if (isObject(data)) {
                data = JSON.stringify(data);
            }
            const socket = sockets.get(this);
            if (!socket) {
                reject(new Error('INVALID_STATE_ERROR'));
                return;
            }
            socket.send(data);
            resolve();
        });
    }
    sendRequest(data, options = {}) {
        if (!isObject(data)) {
            return Promise.reject(new Error('`data` is required'));
        }
        const request = { ...data };
        const trackingId = request.trackingId || createTrackingId();
        const timeout = options.timeout || this.wssResponseTimeout || 10000;
        if (this.pendingResponses.has(trackingId)) {
            return Promise.reject(new Error(`socket request already sent for trackingId ${trackingId}`));
        }
        request.trackingId = trackingId;
        return new Promise((resolve, reject) => {
            const timeoutId = safeSetTimeout(() => {
                this.clearPendingResponse(trackingId);
                reject(createTimeoutError(request));
            }, timeout);
            const pendingResponse = {
                request,
                resolve: (response) => {
                    this.clearPendingResponse(trackingId);
                    resolve(response);
                },
                reject: (error) => {
                    this.clearPendingResponse(trackingId);
                    reject(error);
                },
                timeoutId,
            };
            this.pendingResponses.set(trackingId, pendingResponse);
            this.send(request).catch((error) => {
                this.clearPendingResponse(trackingId);
                reject(error);
            });
        });
    }
    acknowledge(event) {
        if (!event) {
            return Promise.reject(new Error('`event` is required'));
        }
        if (!has(event, 'data.eventId')) {
            return Promise.reject(new Error('`event.data.eventId` is required'));
        }
        if (this.readyState !== SOCKET_READY_STATE.OPEN) {
            return Promise.resolve();
        }
        return this.send({
            type: MESSAGE_TYPES.EVENT_ACK,
            trackingId: event.data.trackingId || createTrackingId(),
            eventId: event.data.eventId,
        }).catch((error) => {
            if (error.message === 'INVALID_STATE_ERROR') {
                return Promise.resolve();
            }
            throw error;
        });
    }
    refresh(token) {
        if (!token) {
            return Promise.reject(new Error('`token` is required for Socket#refresh()'));
        }
        let refreshedToken;
        if (typeof token === 'string') {
            refreshedToken = token;
        }
        else if (token && typeof token.toString === 'function') {
            refreshedToken = token.toString();
        }
        else {
            refreshedToken = String(token);
        }
        return this.authorize(refreshedToken);
    }
    authorize(token) {
        this.logger.info(`socket,${this.domain}: authorizing`);
        return this.sendRequest({
            type: MESSAGE_TYPES.AUTH,
            data: { token },
        });
    }
    clearPendingResponse(trackingId) {
        const pendingResponse = this.pendingResponses.get(trackingId);
        if (pendingResponse?.timeoutId) {
            clearTimeout(pendingResponse.timeoutId);
        }
        this.pendingResponses.delete(trackingId);
    }
    rejectPendingResponses(error) {
        if (!this.pendingResponses.size) {
            return;
        }
        for (const pendingResponse of this.pendingResponses.values()) {
            pendingResponse.reject(error);
        }
    }
    handlePendingResponse(response) {
        if (!response) {
            return false;
        }
        const pendingResponse = this.pendingResponses.get(response.trackingId || '');
        if (!pendingResponse) {
            return false;
        }
        if (response.subtype !== pendingResponse.request.type) {
            return false;
        }
        const statusCode = response?.statusCode;
        const statusMessage = response?.statusMessage;
        if (statusCode === 440 && response?.subtype !== MESSAGE_TYPES.AUTH) {
            if (typeof this.refreshToken === 'function') {
                Promise.resolve(this.refreshToken(response)).catch((error) => {
                    this.logger.warn(`socket,${this.domain}: failed token-expiry re-auth`, error);
                });
            }
            else {
                this.logger.warn(`socket,${this.domain}: refreshToken callback is unavailable for statusCode 440`);
            }
        }
        if (statusCode === undefined) {
            const error = createWssResponseError(response, statusCode, statusMessage || 'Socket response missing status code');
            pendingResponse.reject(error);
        }
        else if (statusCode >= 200 && statusCode < 300) {
            pendingResponse.resolve(response);
        }
        else {
            pendingResponse.reject(createWssResponseError(response, statusCode, statusMessage));
        }
        return true;
    }
    fixCloseCode(event) {
        if (event.code === 1005 && event.reason) {
            switch (event.reason.toLowerCase()) {
                case 'replaced':
                    this.logger.info(`socket,${this.domain}: fixing CloseEvent code for reason: `, event.reason);
                    event.code = 4000;
                    break;
                case 'authentication failed':
                case 'authentication did not happen within the timeout window of 30000 seconds.':
                    this.logger.info(`socket,${this.domain}: fixing CloseEvent code for reason: `, event.reason);
                    event.code = 1008;
                    break;
                default:
            }
        }
        return event;
    }
}
