‘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();
                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);
        } 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.emit('disconnect');
        });
        this._ws.on('error', (err) => {
            reject(err);
        });
    });
}

// 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;