'use strict';

const EventEmitter = require('events');
const util = require('util');
const formatUrl = require('url').format;
const parseUrl = require('url').parse;

const WebSocket = require('ws');

const api = require('./api.js');
const defaults = require('./defaults.js');
const devtools = require('./devtools.js');

class ProtocolError extends Error {
    constructor(request, response) {
        let {message} = response;
        if (response.data) {
            message += ` (${response.data})`;
        }
        super(message);
        // attach the original response as well
        this.request = request;
        this.response = response;
    }
}

class Chrome extends EventEmitter {
    constructor(options, notifier) {
        super();
        // options
        const defaultTarget = (targets) => {
            // prefer type = 'page' inspectable targets as they represents
            // browser tabs (fall back to the first inspectable target
            // otherwise)
            let backup;
            let target = targets.find((target) => {
                if (target.webSocketDebuggerUrl) {
                    backup = backup || target;
                    return target.type === 'page';
                } else {
                    return false;
                }
            });
            target = target || backup;
            if (target) {
                return target;
            } else {
                throw new Error('No inspectable targets');
            }
        };
        options = options || {};
        this.host = options.host || defaults.HOST;
        this.port = options.port || defaults.PORT;
        this.secure = !!(options.secure);
        this.useHostName = !!(options.useHostName);
        this.alterPath = options.alterPath || ((path) => path);
        this.protocol = options.protocol;
        this.local = !!(options.local);
        this.target = options.target || defaultTarget;
        // locals
        this._notifier = notifier;
        this._callbacks = {};
        this._nextCommandId = 1;
        // properties
        this.webSocketUrl = undefined;
        // operations
        this._start();
    }

    // avoid misinterpreting protocol's members as custom util.inspect functions
    inspect(depth, options) {
        options.customInspect = false;
        return util.inspect(this, options);
    }

    send(method, params, sessionId, callback) {
        // handle optional arguments
        const optionals = Array.from(arguments).slice(1);
        params = optionals.find(x => typeof x === 'object');
        sessionId = optionals.find(x => typeof x === 'string');
        callback = optionals.find(x => typeof x === 'function');
        // return a promise when a callback is not provided
        if (typeof callback === 'function') {
            this._enqueueCommand(method, params, sessionId, callback);
            return undefined;
        } else {
            return new Promise((fulfill, reject) => {
                this._enqueueCommand(method, params, sessionId, (error, response) => {
                    if (error) {
                        const request = {method, params, sessionId};
                        reject(
                            error instanceof Error
                                ? error // low-level WebSocket error
                                : new ProtocolError(request, response)
                        );
                    } else {
                        fulfill(response);
                    }
                });
            });
        }
    }

    close(callback) {
        const closeWebSocket = (callback) => {
            // don't close if it's already closed
            if (this._ws.readyState === 3) {
                callback();
            } else {
                // don't notify on user-initiated shutdown ('disconnect' event)
                this._ws.removeAllListeners('close');
                this._ws.once('close', () => {
                    this._ws.removeAllListeners();
                    this._handleConnectionClose();
                    callback();
                });
                this._ws.close();
            }
        };
        if (typeof callback === 'function') {
            closeWebSocket(callback);
            return undefined;
        } else {
            return new Promise((fulfill, reject) => {
                closeWebSocket(fulfill);
            });
        }
    }

    // initiate the connection process
    async _start() {
        const options = {
            host: this.host,
            port: this.port,
            secure: this.secure,
            useHostName: this.useHostName,
            alterPath: this.alterPath
        };
        try {
            // fetch the WebSocket debugger URL
            const url = await this._fetchDebuggerURL(options);
            // allow the user to alter the URL
            const urlObject = parseUrl(url);
            urlObject.pathname = options.alterPath(urlObject.pathname);
            this.webSocketUrl = formatUrl(urlObject);
            // update the connection parameters using the debugging URL
            options.host = urlObject.hostname;
            options.port = urlObject.port || options.port;
            // fetch the protocol and prepare the API
            const protocol = await this._fetchProtocol(options);
            api.prepare(this, protocol);
            // finally connect to the WebSocket
            await this._connectToWebSocket();
            // since the handler is executed synchronously, the emit() must be
            // performed in the next tick so that uncaught errors in the client code
            // are not intercepted by the Promise mechanism and therefore reported
            // via the 'error' event
            process.nextTick(() => {
                this._notifier.emit('connect', this);
            });
        } catch (err) {
            this._notifier.emit('error', err);
        }
    }

    // fetch the WebSocket URL according to 'target'
    async _fetchDebuggerURL(options) {
        const userTarget = this.target;
        switch (typeof userTarget) {
        case 'string': {
            let idOrUrl = userTarget;
            // use default host and port if omitted (and a relative URL is specified)
            if (idOrUrl.startsWith('/')) {
                idOrUrl = `ws://${this.host}:${this.port}${idOrUrl}`;
            }
            // a WebSocket URL is specified by the user (e.g., node-inspector)
            if (idOrUrl.match(/^wss?:/i)) {
                return idOrUrl; // done!
            }
            // a target id is specified by the user
            else {
                const targets = await devtools.List(options);
                const object = targets.find((target) => target.id === idOrUrl);
                return object.webSocketDebuggerUrl;
            }
        }
        case 'object': {
            const object = userTarget;
            return object.webSocketDebuggerUrl;
        }
        case 'function': {
            const func = userTarget;
            const targets = await devtools.List(options);
            const result = func(targets);
            const object = typeof result === 'number' ? targets[result] : result;
            return object.webSocketDebuggerUrl;
        }
        default:
            throw new Error(`Invalid target argument "${this.target}"`);
        }
    }

    // fetch the protocol according to 'protocol' and 'local'
    async _fetchProtocol(options) {
        // if a protocol has been provided then use it
        if (this.protocol) {
            return this.protocol;
        }
        // otherwise user either the local or the remote version
        else {
            options.local = this.local;
            return await devtools.Protocol(options);
        }
    }

    // establish the WebSocket connection and start processing user commands
    _connectToWebSocket() {
        return new Promise((fulfill, reject) => {
            // create the WebSocket
            try {
                if (this.secure) {
                    this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:');
                }
                this._ws = new WebSocket(this.webSocketUrl, [], {
                    maxPayload: 256 * 1024 * 1024,
                    perMessageDeflate: false,
                    followRedirects: true,
                });
            } catch (err) {
                // handles bad URLs
                reject(err);
                return;
            }
            // set up event handlers
            this._ws.on('open', () => {
                fulfill();
            });
            this._ws.on('message', (data) => {
                const message = JSON.parse(data);
                this._handleMessage(message);
            });
            this._ws.on('close', (code) => {
                this._handleConnectionClose();
                this.emit('disconnect');
            });
            this._ws.on('error', (err) => {
                reject(err);
            });
        });
    }

    _handleConnectionClose() {
        // make sure to complete all the unresolved callbacks
        const err = new Error('WebSocket connection closed');
        for (const callback of Object.values(this._callbacks)) {
            callback(true, err);
        }
        this._callbacks = {};
    }

    // handle the messages read from the WebSocket
    _handleMessage(message) {
        // command response
        if (message.id) {
            const callback = this._callbacks[message.id];
            if (!callback) {
                return;
            }
            // interpret the lack of both 'error' and 'result' as success
            // (this may happen with node-inspector)
            if (message.error) {
                callback(true, message.error);
            } else {
                callback(false, message.result || {});
            }
            // unregister command response callback
            delete this._callbacks[message.id];
            // notify when there are no more pending commands
            if (Object.keys(this._callbacks).length === 0) {
                this.emit('ready');
            }
        }
        // event
        else if (message.method) {
            const {method, params, sessionId} = message;
            this.emit('event', message);
            this.emit(method, params, sessionId);
            this.emit(`${method}.${sessionId}`, params, sessionId);
        }
    }

    // send a command to the remote endpoint and register a callback for the reply
    _enqueueCommand(method, params, sessionId, callback) {
        const id = this._nextCommandId++;
        const message = {
            id,
            method,
            sessionId,
            params: params || {}
        };
        this._ws.send(JSON.stringify(message), (err) => {
            if (err) {
                // handle low-level WebSocket errors
                if (typeof callback === 'function') {
                    callback(err);
                }
            } else {
                this._callbacks[id] = callback;
            }
        });
    }
}

module.exports = Chrome;
