/**
 * Copyright 2017 Google Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

var await = require('./utilities').await;
var EventEmitter = require('events');
var fs = require('fs');
var path = require('path');
var PageEvents = require('../lib/Page').Events;

var noop = function() { };

class WebPage {
    /**
     * @param {!Browser} browser
     * @param {string} scriptPath
     * @param {!Object=} options
     */
    constructor(browser, scriptPath, options) {
        this._page = await(browser.newPage());
        this.settings = new WebPageSettings(this._page);

        options = options || {};
        options.settings = options.settings || {};
        if (options.settings.userAgent)
            this.settings.userAgent = options.settings.userAgent;
        if (options.viewportSize)
            await(this._page.setViewportSize(options.viewportSize));

        await(this._page.setInPageCallback('callPhantom', (...args) => this.onCallback.apply(null, args)));

        this.clipRect = options.clipRect || {left: 0, top: 0, width: 0, height: 0};
        this.onCallback = null;
        this.onConsoleMessage = null;
        this.onLoadFinished = null;
        this.onResourceError = null;
        this.onResourceReceived = null;
        this.onInitialized = null;
        this._deferEvaluate = false;

        this.libraryPath = path.dirname(scriptPath);

        this._onResourceRequestedCallback = undefined;
        this._onConfirmCallback = undefined;
        this._onAlertCallback = undefined;
        this._onError = noop;

        this._pageEvents = new AsyncEmitter(this._page);
        this._pageEvents.on(PageEvents.ResponseReceived, response => this._onResponseReceived(response));
        this._pageEvents.on(PageEvents.ResourceLoadingFailed, event => (this.onResourceError || noop).call(null, event));
        this._pageEvents.on(PageEvents.ConsoleMessage, msg => (this.onConsoleMessage || noop).call(null, msg));
        this._pageEvents.on(PageEvents.Confirm, message => this._onConfirm(message));
        this._pageEvents.on(PageEvents.Alert, message => this._onAlert(message));
        this._pageEvents.on(PageEvents.Dialog, dialog => this._onDialog(dialog));
        this._pageEvents.on(PageEvents.Error, error => (this._onError || noop).call(null, error.message, error.stack));
    }

    /**
     * @return {?function(!Object, !Request)}
     */
    get onResourceRequested() {
        return this._onResourceRequestedCallback;
    }

    /**
     * @return {?function(!Object, !Request)} callback
     */
    set onResourceRequested(callback) {
        this._onResourceRequestedCallback = callback;
        this._page.setRequestInterceptor(callback ? resourceInterceptor : null);

        /**
         * @param {!Request} request
         */
        function resourceInterceptor(request) {
            var requestData = {
                url: request.url(),
                headers: request.headers()
            };
            callback(requestData, request);
            if (!request.handled())
                request.continue();
        }
    }

    _onResponseReceived(response) {
        if (!this.onResourceReceived)
            return;
        var headers = [];
        for (var key in response.headers) {
            headers.push({
                name: key,
                value: response.headers[key]
            });
        }
        response.headers = headers;
        this.onResourceReceived.call(null, response);
    }

    /**
     * @param {string} url
     * @param {function()} callback
     */
    includeJs(url, callback) {
        this._page.addScriptTag(url).then(callback);
    }

    /**
     * @return {!{width: number, height: number}}
     */
    get viewportSize() {
        return this._page.viewportSize();
    }

    /**
     * @return {!Object}
     */
    get customHeaders() {
        return this._page.extraHTTPHeaders();
    }

    /**
     * @param {!Object} value
     */
    set customHeaders(value) {
        await(this._page.setExtraHTTPHeaders(value));
    }

    /**
     * @param {string} filePath
     */
    injectJs(filePath) {
        if (!fs.existsSync(filePath))
            filePath = path.resolve(this.libraryPath, filePath);
        if (!fs.existsSync(filePath))
            return false;
        await(this._page.injectFile(filePath));
        return true;
    }

    /**
     * @return {string}
     */
    get plainText() {
        return await(this._page.plainText());
    }

    /**
     * @return {string}
     */
    get title() {
        return await(this._page.title());
    }

    /**
     * @return {(function()|undefined)}
     */
    get onError() {
        return this._onError;
    }

    /**
     * @param {(function()|undefined)} handler
     */
    set onError(handler) {
        if (typeof handler !== 'function')
            handler = undefined;
        this._onError = handler;
    }

    /**
     * @return {(function()|undefined)}
     */
    get onConfirm() {
        return this._onConfirmCallback;
    }

    /**
     * @param {function()} handler
     */
    set onConfirm(handler) {
        if (typeof handler !== 'function')
            handler = undefined;
        this._onConfirmCallback = handler;
    }

    /**
     * @return {(function()|undefined)}
     */
    get onAlert() {
        return this._onAlertCallback;
    }

    /**
     * @param {function()} handler
     */
    set onAlert(handler) {
        if (typeof handler !== 'function')
            handler = undefined;
        this._onAlertCallback = handler;
    }

    /**
     * @param {!Dialog} dialog
     */
    _onDialog(dialog) {
        if (dialog.type === 'alert' && this._onAlertCallback) {
            this._onAlertCallback.call(null, dialog.message());
            await(dialog.accept());
        } else if (dialog.type === 'confirm' && this._onConfirmCallback) {
            var result = this._onConfirmCallback.call(null, dialog.message());
            await(result ? dialog.accept() : dialog.dismiss());
        }
    }

    /**
     * @return {string}
     */
    get url() {
        return await(this._page.url());
    }

    /**
     * @param {string} html
     */
    set content(html) {
        await(this._page.setContent(html));
    }

    /**
     * @param {string} html
     * @param {function()=} callback
     */
    open(url, callback) {
        console.assert(arguments.length <= 2, 'WebPage.open does not support METHOD and DATA arguments');
        this._deferEvaluate = true;
        if (typeof this.onInitialized === 'function')
            this.onInitialized();
        this._deferEvaluate = false;
        this._page.navigate(url).then(result => {
            var status = result ? 'success' : 'fail';
            if (!result) {
                this.onResourceError.call(null, {
                    url,
                    errorString: 'SSL handshake failed'
                });
            }
            if (this.onLoadFinished)
                this.onLoadFinished.call(null, status);
            if (callback)
                callback.call(null, status);
        });
    }

    /**
     * @param {!{width: number, height: number}} options
     */
    set viewportSize(options) {
        await(this._page.setViewportSize(options));
    }

    /**
     * @param {function()} fun
     * @param {!Array<!Object>} args
     */
    evaluate(fun, ...args) {
        if (this._deferEvaluate)
            return await(this._page.evaluateOnInitialized(fun, ...args));
        return await(this._page.evaluate(fun, ...args));
    }

    /**
     * {string} fileName
     */
    render(fileName) {
        if (fileName.endsWith('pdf')) {
            var options = {};
            var paperSize = this.paperSize || {};
            options.margin = paperSize.margin;
            options.format = paperSize.format;
            options.landscape = paperSize.orientation === 'landscape';
            options.width = paperSize.width;
            options.height = paperSize.height;
            await(this._page.printToPDF(fileName, options));
        } else {
            var options = {};
            if (this.clipRect && (this.clipRect.left || this.clipRect.top || this.clipRect.width || this.clipRect.height)) {
                options.clipRect = {
                    x: this.clipRect.left,
                    y: this.clipRect.top,
                    width: this.clipRect.width,
                    height: this.clipRect.height
                };
            }
            options.path = fileName;
            await(this._page.saveScreenshot(fileName, clipRect));
        }
    }

    release() {
        this._page.close();
    }

    close() {
        this._page.close();
    }
}

class WebPageSettings {
    /**
     * @param {!Page} page
     */
    constructor(page) {
        this._page = page;
    }

    /**
     * @param {string} value
     */
    set userAgent(value) {
        await(this._page.setUserAgentOverride(value));
    }

    /**
     * @return {string}
     */
    get userAgent() {
        return this._page.userAgentOverride();
    }
}

// To prevent reenterability, eventemitters should emit events
// only being in a consistent state.
// This is not the case for 'ws' npm module: https://goo.gl/sy3dJY
//
// Since out phantomjs environment uses nested event loops, we
// exploit this condition in 'ws', which probably never happens
// in case of regular I/O.
//
// This class is a wrapper around EventEmitter which re-emits events asynchronously,
// helping to overcome the issue.
class AsyncEmitter extends EventEmitter {
    /**
     * @param {!Page} page
     */
    constructor(page) {
        super();
        this._page = page;
        this._symbol = Symbol('AsyncEmitter');
        this.on('newListener', this._onListenerAdded);
        this.on('removeListener', this._onListenerRemoved);
    }

    _onListenerAdded(event, listener) {
        // Async listener calls original listener on next tick.
        var asyncListener = (...args) => {
            process.nextTick(() => listener.apply(null, args));
        };
        listener[this._symbol] = asyncListener;
        this._page.on(event, asyncListener);
    }

    _onListenerRemoved(event, listener) {
        this._page.removeListener(event, listener[this._symbol]);
    }
}

module.exports = WebPage;