// Credit to / copied from https://github.com/Everettss/puppeteer-har/ with some adaptations

import fs from 'fs'
import { promisify } from 'util'
import  { harFromMessages } from 'chrome-har'
import * as networkUtils from './networkUtils.js'

// event types to observe
const page_observe = [
    'Page.loadEventFired',
    'Page.domContentEventFired',
    'Page.frameStartedLoading',
    'Page.frameAttached',
    'Page.frameScheduledNavigation',
];

const network_observe = [
    'Network.requestWillBeSent',
    'Network.requestServedFromCache',
    'Network.dataReceived',
    'Network.responseReceived',
    'Network.resourceChangedPriority',
    'Network.loadingFinished',
    'Network.loadingFailed',
];

export default class PuppeteerHar {

    /**
     * @param {object} page
     */
    constructor(page) {
        this.page = page;
        this.mainFrame = this.page.mainFrame();
        this.inProgress = false;
        this.cleanUp();
    }

    /**
     * @returns {void}
     */
    cleanUp() {
        this.network_events = [];
        this.page_events = [];
        this.response_body_promises = {};
    }

    /**
     * @param {{harpath: string}=} options
     * @return {Promise<void>}
     */
    async start({ harpath, saveResponse, captureMimeTypes } = {}) {
        this.inProgress = true;
        this.saveResponse = saveResponse || false;
        this.captureMimeTypes = captureMimeTypes || ['text/html', 'application/json'];
        this.harpath = harpath;
        this.client = await this.page.target().createCDPSession();
        await this.client.send('Page.enable');
        await this.client.send('Network.enable');
        page_observe.forEach(method => {
            this.client.on(method, params => {
                if (!this.inProgress) {
                    return;
                }
                this.page_events.push({ method, params });
            });
        });
        network_observe.forEach(method => {
            this.client.on(method, params => {
                if (!this.inProgress) {
                    return;
                }
                this.network_events.push({ method, params });
                if (method == 'Network.loadingFinished' && params.requestId in this.response_body_promises) {
                    const { response, resolve } = this.response_body_promises[params.requestId]
                    const { requestId } = params
                    this.client.send(
                        'Network.getResponseBody', { requestId },
                    ).then((responseBody) => {
                        // Set the response so `chrome-har` can add it to the HAR
                        response.body = new Buffer.from(
                            responseBody.body,
                            responseBody.base64Encoded ? 'base64' : undefined,
                        ).toString();
                    }, (reason) => {
                        // Resources (i.e. response bodies) are flushed after page commits
                        // navigation and we are no longer able to retrieve them. In this
                        // case, fail soft so we still add the rest of the response to the
                        // HAR. Possible option would be force wait before navigation...
                    }).then(resolve)
                }
                if (this.saveResponse && method == 'Network.responseReceived') {
                    const response = params.response;
                    const requestId = params.requestId;

                    // Response body is unavailable for redirects, no-content, image, audio and video responses
                    if (response.status !== 204 &&
                        response.headers.location == null &&
                        this.captureMimeTypes.includes(response.mimeType)
                    ) {
                        let resolve
                        let reject
                        const promise = new Promise((res, rej) => {
                            resolve = res
                            reject = rej
                            //Some requests may never "finish". 
                            // TODO This timeout wait should be an option. 
                            // TODO When a response "times out", we should call Network.getResponseBody and at least get the truncated data
                            setTimeout(res, 10000)
                        })
                        this.response_body_promises[requestId] = { response, promise, resolve, reject };
                    }
                }
            });
        });
    }

    /**
     * @returns {Promise<void|object>}
     */
    async stop(networkResolveHosts = false, storeResult = false) {
        if (this.storedResult){
            return this.storedResult;
        }
        this.inProgress = false;
        await Promise.all(Object.values(this.response_body_promises).map(({ promise }) => promise));
        await this.client.detach();

        let networkInfo = { "interactedAdresses": await networkUtils.getNetworkInfo(this.network_events) };
 
        if (networkResolveHosts) {
            let resolveList = networkInfo.interactedAdresses;
            let interactedAdressesWithResponseHosts = networkInfo.interactedAdresses.filter(item => item.responseHost);
            interactedAdressesWithResponseHosts.forEach(interacted => {
                resolveList.push({
                    "serverName": interacted.responseHost,
                    "port": interacted.responsePort
                })
            });
            let resolvedAdresses = {};
            for (let index = 0; index < resolveList.length; index++) {
                let item = networkInfo.interactedAdresses[index];
                if (resolvedAdresses[item.serverName]) {
                    // Server has already been resolved check if the port is there
                    if (!resolvedAdresses[item.serverName].ports.includes(item.port)){
                        resolvedAdresses[item.serverName].ports.push(item.port);
                    }
                } else {
                    let addrs = await networkUtils.resolveAddress(item.serverName);
                    let ips = [];
                    addrs.forEach(addr => {
                        ips.push(addr.address);
                    });
                    resolvedAdresses[item.serverName] = { "ips": ips, "ports": [item.port] };
                }
            }
            networkInfo.resolvedAdresses = resolvedAdresses;
        }

        const har = harFromMessages(
            this.page_events.concat(this.network_events),
            { includeTextFromResponseBody: this.saveResponse }
        );
        this.cleanUp();
        if (this.harpath) {
            await promisify(fs.writeFile)(this.harpath, JSON.stringify(har));
        } else {
            if (storeResult){
                this.storedResult = { "har": har, "networkInfo": networkInfo };
            } else {
                return { "har": har, "networkInfo": networkInfo };
            }
        }
    }
}
