import {
    devAssert
} from '#core/assert';
import {
    isArray,
    isObject
} from '#core/types';
import {
    hasOwn,
    map
} from '#core/types/object';
import {
    parseJson
} from '#core/types/object/json';
import {
    utf8Encode
} from '#core/types/string/bytes';

import {
    dev,
    user
} from '#utils/log';

/** @const {!Array<string>} */
const allowedMethods = ['GET', 'POST'];

/**
 * A record version of `XMLHttpRequest` that has all the necessary properties
 * and methods of `XMLHttpRequest` to construct a `FetchResponse` from a
 * serialized response returned by the viewer.
 * @typedef {{
 *   status: number,
 *   statusText: string,
 *   responseText: string,
 *   getResponseHeader: function(this:XMLHttpRequestDef, string): string,
 * }}
 */
let XMLHttpRequestDef;

/**
 * A minimal polyfill of Fetch API. It only polyfills what we currently use.
 *
 * See https://developer.mozilla.org/en-US/docs/Web/API/GlobalFetch/fetch
 *
 * Notice that the "fetch" method itself is not exported as that would require
 * us to immediately support a much wide API.
 *
 * @param {string} input
 * @param {!Object|RequestInit=} init
 * @return {!Promise<!FetchResponse>}
 */
export function fetchPolyfill(input, init = {}) {
    return new Promise(function(resolve, reject) {
        const requestMethod = normalizeMethod(init.method || 'GET');
        const xhr = createXhrRequest(requestMethod, input);

        if (init.credentials == 'include') {
            xhr.withCredentials = true;
        }

        if (init.responseType === 'document' || init.responseType === 'text') {
            xhr.responseType = init.responseType;
        }

        if (init.headers) {
            Object.keys(init.headers).forEach(function(header) {
                xhr.setRequestHeader(header, init.headers[header]);
            });
        }

        xhr.onreadystatechange = () => {
            if (xhr.readyState < /* STATUS_RECEIVED */ 2) {
                return;
            }
            if (xhr.status < 100 || xhr.status > 599) {
                xhr.onreadystatechange = null;
                reject(user().createExpectedError(`Unknown HTTP status ${xhr.status}`));
                return;
            }

            // TODO(dvoytenko): This is currently simplified: we will wait for the
            // whole document loading to complete. This is fine for the use cases
            // we have now, but may need to be reimplemented later.
            if (xhr.readyState == /* COMPLETE */ 4) {
                resolve(new FetchResponse(xhr));
            }
        };
        xhr.onerror = () => {
            reject(user().createExpectedError('Network failure'));
        };
        xhr.onabort = () => {
            reject(user().createExpectedError('Request aborted'));
        };

        if (requestMethod == 'POST') {
            xhr.send(init.body);
        } else {
            xhr.send();
        }
    });
}

/**
 * @param {string} method
 * @param {string} url
 * @return {!XMLHttpRequest}
 */
function createXhrRequest(method, url) {
    const xhr = new XMLHttpRequest();
    if ('withCredentials' in xhr) {
        xhr.open(method, url, true);
    } else {
        throw dev().createExpectedError('CORS is not supported');
    }
    return xhr;
}

/**
 * Response object in the Fetch API.
 *
 * See https://developer.mozilla.org/en-US/docs/Web/API/GlobalFetch/fetch
 */
class FetchResponse {
    /**
     * @param {!XMLHttpRequest|!XMLHttpRequestDef} xhr
     */
    constructor(xhr) {
        /** @private @const {!XMLHttpRequest|!XMLHttpRequestDef} */
        this.xhr_ = xhr;

        /** @const {number} */
        this.status = this.xhr_.status;

        /** @const {string} */
        this.statusText = this.xhr_.statusText;

        /** @const {boolean} */
        this.ok = this.status >= 200 && this.status < 300;

        /** @const {!FetchResponseHeaders} */
        this.headers = new FetchResponseHeaders(xhr);

        /** @type {boolean} */
        this.bodyUsed = false;

        /** @type {?ReadableStream} */
        this.body = null;

        /** @type {?string} */
        this.url = xhr.responseURL;
    }

    /**
     * Create a copy of the response and return it.
     * @return {!FetchResponse}
     */
    clone() {
        devAssert(!this.bodyUsed, 'Body already used');
        return new FetchResponse(this.xhr_);
    }

    /**
     * Drains the response and returns the text.
     * @return {!Promise<string>}
     * @private
     */
    drainText_() {
        devAssert(!this.bodyUsed, 'Body already used');
        this.bodyUsed = true;
        return Promise.resolve(this.xhr_.responseText);
    }

    /**
     * Drains the response and returns a promise that resolves with the response
     * text.
     * @return {!Promise<string>}
     */
    text() {
        return this.drainText_();
    }

    /**
     * Drains the response and returns the JSON object.
     * @return {!Promise<!JsonObject>}
     */
    json() {
        return /** @type {!Promise<!JsonObject>} */ (
            this.drainText_().then(parseJson)
        );
    }

    /**
     * Drains the response and returns a promise that resolves with the response
     * ArrayBuffer.
     * @return {!Promise<!ArrayBuffer>}
     */
    arrayBuffer() {
        return /** @type {!Promise<!ArrayBuffer>} */ (
            this.drainText_().then(utf8Encode)
        );
    }
}

/**
 * Normalized method name by uppercasing.
 * @param {string|undefined} method
 * @return {string}
 * @private
 */
function normalizeMethod(method) {
    if (method === undefined) {
        return 'GET';
    }
    method = method.toUpperCase();
    devAssert(
        allowedMethods.includes(method),
        'Only one of %s is currently allowed. Got %s',
        allowedMethods.join(', '),
        method
    );
    return method;
}

/**
 * Provides access to the response headers as defined in the Fetch API.
 * @private Visible for testing.
 */
class FetchResponseHeaders {
    /**
     * @param {!XMLHttpRequest|!XMLHttpRequestDef} xhr
     */
    constructor(xhr) {
        /** @private @const {!XMLHttpRequest|!XMLHttpRequestDef} */
        this.xhr_ = xhr;
    }

    /**
     * @param {string} name
     * @return {string}
     */
    get(name) {
        return this.xhr_.getResponseHeader(name);
    }

    /**
     * @param {string} name
     * @return {boolean}
     */
    has(name) {
        return this.xhr_.getResponseHeader(name) != null;
    }
}

export class Response extends FetchResponse {
    /**
     * Returns instance of Response.
     * @param {?ResponseBodyInit=} body
     * @param {!ResponseInit=} init
     */
    constructor(body, init = {}) {
        const lowercasedHeaders = map();
        const data = {
            status: 200,
            statusText: 'OK',
            responseText: body ? String(body) : '',
            getResponseHeader(name) {
                const headerName = String(name).toLowerCase();
                return hasOwn(lowercasedHeaders, headerName) ?
                    lowercasedHeaders[headerName] :
                    null;
            },
            ...init,
        };

        data.status = init.status === undefined ? 200 : parseInt(init.status, 10);

        if (isArray(init.headers)) {
            /** @type {!Array} */
            (init.headers).forEach((entry) => {
                const headerName = entry[0];
                const headerValue = entry[1];
                lowercasedHeaders[String(headerName).toLowerCase()] =
                    String(headerValue);
            });
        } else if (isObject(init.headers)) {
            for (const key in init.headers) {
                lowercasedHeaders[String(key).toLowerCase()] = String(
                    init.headers[key]
                );
            }
        }

        if (init.statusText) {
            data.statusText = String(init.statusText);
        }

        super( /** @type {XMLHttpRequestDef} */ (data));
    }
}

/**
 * Installs fetch and Response polyfill
 * @param {Window} win
 */
export function install(win) {
    if (!win.fetch) {
        Object.defineProperty(win, 'fetch', {
            value: fetchPolyfill,
            writable: true,
            enumerable: true,
            configurable: true,
        });
        Object.defineProperty(win, 'Response', {
            value: Response,
            writable: true,
            enumerable: false,
            configurable: true,
        });
    }
}