# Source: data/service/http-service.js

    var RawDataService = require("data/service/raw-data-service").RawDataService,
        DataQuery = require("data/model/data-query").DataQuery,
        Enumeration = require("data/model/enumeration").Enumeration,
        Map = require("collections/map"),
        Montage = require("montage").Montage,
        parse = require("frb/parse"),
        compile = require("frb/compile-evaluator"),
        evaluate = require("frb/evaluate"),
        Scope = require("frb/scope"),
        Promise = require("core/promise").Promise;

    var HttpError = exports.HttpError = Montage.specialize({

        constructor: {
            value: function HttpError() {
                this.stack = (new Error()).stack;
            }
        },

        isAuthorizationError: {
            get: function () {
                return this._isAuthorizationError || (this.statusCode === 401 || this.statusCode === 403);
            },
            set: function (value) {
                this._isAuthorizationError = value;
            }
        },

        message: {
            get: function () {
                if (!this._message) {
                    this._message = "Status " + this.statusCode + " received for url: " + this.url;
                }
                return this._message;
            }
        },

        name: {
            value: "HttpError"
        },

        url: {
            value: undefined
        },

        statusCode: {
            value: undefined
        }

    }, {

        withMessage: {
            value: function (message) {
                var error = new this();
                error._message = message;
                return error;
            }
        },

        withRequestAndURL: {
            value: function (request, url) {
                var error = new this();
                error.statusCode = request.status;
                error.url = url;
                return error;
            }
        }

    });

    /**
     * Superclass for services communicating using HTTP, usually REST services.
     *
     * @class
     */
    /*
     * TODO: Restore @extends when parent class has been cleaned up to not provide
     * so many unnecessary properties and methods.
     *
     * @extends RawDataService
     */
    var HttpService = exports.HttpService = RawDataService.specialize(/** @lends HttpService.prototype */ {

        /***************************************************************************
         * Constants
         */

        /**
         * The Content-Type header corresponding to
         * [application/x-www-form-urlencoded]{@link https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1},
         * the default format of form data.
         *
         * @type {Object<string, string>}
         */
        FORM_URL_ENCODED: {
            value: {"Content-Type": "application/x-www-form-urlencoded"}
        },

        /**
         * @type {Object<string, string>}
         * @deprecated in favor of
         *             [FORM_URL_ENCODED]{@link HttpService#FORM_URL_ENCODED}.
         */
        FORM_URL_ENCODED_CONTENT_TYPE_HEADER: {
            get: function () {
                console.warn("HttpService.FORM_URL_ENCODED_CONTENT_TYPE_HEADER is deprecated - use HttpService.FORM_URL_ENCODED instead");
                return this.FORM_URL_ENCODED;
            }
        },

        /***************************************************************************
         * Authorization
         */

        setHeadersForQuery: {
            value: function (headers, query) {
                var authorization = query && query.authorization && query.authorization[0],
                    evaluate, scope;

                if (authorization && authorization.headerValueExpression && this.authorizationHeaderName) {
                    scope = new Scope(authorization);
                    evaluate = compile(parse(authorization.headerValueExpression));
                    headers[this.authorizationHeaderName] = evaluate(scope);
                } else if (query && this.authorizationHeaderName && this.authorizationHeaderValueExpression) {
                    scope = new Scope(query);
                    evaluate = compile(parse(this.authorizationHeaderValueExpression));
                    headers[this.authorizationHeaderName] = evaluate(scope);
                } else if (this.authorizationHeaderName && this.authorizationHeaderValue) {
                    headers[this.authorizationHeaderName] = this.authorizationHeaderValue;
                }
            }
        },

        /**
         * @type {string}
         * @description Name of header to be passed to all requests along with authorizationHeaderValue
         *
         */
        authorizationHeaderName: {
            value: "Authorization"
        },

        /**
         * @type {string}
         * @description Value of header with name authorizationHeaderName to include with all requests from this service
         *
         */
        authorizationHeaderValue: {
            value: undefined
        },

        /**
         * @type {string}
         * @description FRB Expression defining the authorizationHeaderValue when evaluated against a DataQuery
         *              passed to this service.
         *
         */
        authorizationHeaderValueExpression: {
            value: undefined
        },

        /***************************************************************************
         * Getting property data
         */

        fetchHttpObjectProperty: {
            value: function (type, object, propertyName, prerequisitePropertyNames, criteria) {
                var self, selector, prerequisites, stream;
                // Create and cache a new fetch promise if necessary.
                if (!this._getCachedFetchPromise(object, propertyName)) {
                    // Parse arguments.
                    if (arguments.length >= 4) {
                        selector = DataQuery.withTypeAndCriteria(type, arguments[arguments.length - 1]);//RDW unclear if there's any special change required here for formal Criteria

                    } else {
                        selector = DataQuery.withTypeAndCriteria(type);
                    }
                    if (arguments.length < 5 || !prerequisitePropertyNames) {
                        prerequisites = [];
                    } else if (!Array.isArray(prerequisitePropertyNames)) {
                        prerequisites = Array.prototype.slice.call(arguments, 3, -1);
                    } else {
                        prerequisites = prerequisitePropertyNames;
                    }
                    // Create and cache a new fetch promise
                    self = this;
                    this._setCachedFetchPromise(object, propertyName, this.nullPromise.then(function () {
                        // First get prerequisite data if necessary...
                        return self.rootService.getObjectProperties(object, prerequisites);
                    }).then(function () {
                        // Then fetch the requested data...
                        stream = self.rootService.fetchData(selector);
                        return stream;
                    }).then(function () {
                        // Then wait until the next event loop to ensure only one
                        // fetch is dispatched per event loop (caching ensures all
                        // subsequent requests for the same fetch promise within the
                        // same event loop will return the same promise)...
                        return self.eventLoopPromise;
                    }).then(function () {
                        // Then removes the promise from the cache so subsequent
                        // requests for this fetch promise generate new fetches.
                        self._setCachedFetchPromise(object, propertyName, null);
                        return stream.data;
                    }));
                }
                // Return the created or cached fetch promise.
                return this._getCachedFetchPromise(object, propertyName);
            }
        },

        /**
         * @private
         * @method
         */
        _getCachedFetchPromise: {
            value: function (object, propertyName) {
                this._cachedFetchPromises = this._cachedFetchPromises || {};
                this._cachedFetchPromises[propertyName] = this._cachedFetchPromises[propertyName] || new Map();
                return this._cachedFetchPromises[propertyName].get(object);
            }
        },

        /**
         * @private
         * @method
         */
        _setCachedFetchPromise: {
            value: function (object, propertyName, promise) {
                this._cachedFetchPromises = this._cachedFetchPromises || {};
                this._cachedFetchPromises[propertyName] = this._cachedFetchPromises[propertyName] || new Map();
                this._cachedFetchPromises[propertyName].set(object, promise);
            }
        },

        /***************************************************************************
         * Getting raw data
         */

        /**
         * Fetches raw data from an HTTP REST endpoint.
         *
         * @method
         * @argument {String} url                        - The URL of the endpoint.
         * @argument {Object<string, string>}
         *           [headers={}]                        - HTTP header names and
         *                                                 values. Optional except
         *                                                 if a body or types are to
         *                                                 be specified. Pass in an
         *                                                 empty, null, or undefined
         *                                                 header to specify a body
         *                                                 or types but no header.
         * @argument [body]                              - The body to send with the
         *                                                 XMLHttpRequest. Optional
         *                                                 except if types are to be
         *                                                 specified. Pass in a null
         *                                                 or undefined body to
         *                                                 specify types but no
         *                                                 body.
         * @argument {Array<HttpService.DataType>}
         *           [types=[HttpService.DataType.JSON]] - The possible types of
         *                                                 the data expected in
         *                                                 responses. These will
         *                                                 be used to parse the
         *                                                 response data. Currently
         *                                                 only the first type is
         *                                                 taken into account. The
         *                                                 types can be specified as
         *                                                 an array or as a sequence
         *                                                 of
         *                                                 [DataType]{@link HttpService.DataType}
         *                                                 arguments.
         * @argument {boolean} [sendCredentials=true]    - Determines whether
         *                                                 credentials are sent with
         *                                                 the request.
         * @returns {external:Promise} - A promise settled when the fetch is
         * complete. On success the promise will be fulfilled with the data returned
         * from the fetch, parsed according to the specified or detaul types. On
         * error the promise will be rejected with the error.
         */

        _authRegexp: {
            value: new RegExp(/error=\"([^&]*)\"/)
        },

        _fetchHttpRawDataWithParsedArguments: {
            value: function (parsed) {
                var self = this,
                    error, request;

                if (!parsed) {
                    error = new Error("Invalid arguments to fetchHttpRawData()");
                } else if (!parsed.url) {
                    error = new Error("No URL provided to fetchHttpRawData()");
                }

                return new Promise(function (resolve, reject) {
                    var i, keys, key,
                        startTime = new Date().getTime();

                    // Report errors or fetch the requested raw data.
                    if (error) {
                        console.warn(error);
                        reject(error);
                    } else {
                        request = new XMLHttpRequest();
                        request.onreadystatechange = function () {
                            if (request.readyState === 4) {
                                resolve(request);
                                // console.log("Completed request for (", parsed.url, ") in (", ((new Date().getTime() - startTime)), ") ms");
                            }
                        };
                        request.onerror = function () {
                            error = HttpError.withRequestAndURL(request, parsed.url);
                            reject(error);
                        };
                        request.open(parsed.body ? "POST" : "GET", parsed.url, true);

                        self.setHeadersForQuery(parsed.headers, parsed.query, parsed.url);

                        keys = Object.keys(parsed.headers);
                        for (i = 0; (key = keys[i]); ++i) {
                            request.setRequestHeader(key, parsed.headers[key]);
                        }
                        request.withCredentials = parsed.credentials;
                        request.send(parsed.body);
                    }
                }).then(function () {
                    // The response status can be 0 initially even for successful
                    // requests, so defer the processing of this response until the
                    // next event loop to give the status time to be set correctly.
                    return self.eventLoopPromise;
                }).then(function () {
                    // Log a warning for error status responses.
                    // TODO: Reject the promise for error statuses.
                    if (self._isRequestUnauthorized(request) && typeof self.authorize === "function") {
                        return self.authorize().then(function () {
                            return self._fetchHttpRawDataWithParsedArguments(parsed);
                        });
                    } else if (!error && (request.status >= 300 || request.status === 0)) {
                        // error = new Error("Status " + request.status + " received for REST URL " + parsed.url);
                        // console.warn(error);
                        throw HttpError.withRequestAndURL(request, parsed.url);
                    }
                    // Return null for errors or return the results of parsing the
                    // request response according to the specified types.
                    // TODO: Support multiple alternate types.

                    return parsed.types[0].parseResponse(request, parsed.url);
                });
            }
        },

        _isRequestUnauthorized: {
            value: function (request) {
                return request.status === 401 || (typeof this.didAuthorizationFail === "function" && this.didAuthorizationFail(request));
            }
        },

        fetchHttpRawData: {
            value: function (url, headers, body, types, query, sendCredentials) {
                var parsed = this._parseFetchHttpRawDataArguments.apply(this, arguments);

                // Create and return a promise for the fetch results.
                return this._fetchHttpRawDataWithParsedArguments(parsed);

            }
        },

        /**
         * @private
         * @method
         */
        _parseFetchHttpRawDataArguments: {
            value: function (/* url [, headers [, body [, types]]][, sendCredentials] */) {
                var parsed, last, i, n;
                // Parse the url argument, setting the "last" argument index to -1
                // if the URL is invalid.
                parsed = {url: arguments[0]};
                last = typeof parsed.url === "string" ? arguments.length - 1 : -1;
                if (last < 0) {
                    console.warn(new Error("Invalid URL for fetchHttpRawData()"));
                }
                // Parse the sendCredentials argument, which must be the last
                // argument if it is provided, and set the "last" argument index to
                // point just past the last non-sendCredentials argument.
                parsed.credentials = last < 1 || arguments[last];
                if (parsed.credentials instanceof Boolean) {
                    parsed.credentials = parsed.credentials.valueOf();
                } else if (typeof parsed.credentials !== "boolean") {
                    parsed.credentials = true;
                    last += 1;
                }
                // Parse the headers argument, which cannot be a boolean.
                var headers = last > 1 && arguments[1] || {};
                parsed.headers = {};
                if (typeof headers === "object") {
                    Object.assign(parsed.headers, headers);
                }
                if (this._isBoolean(headers)) {
                    console.warn(new Error("Invalid headers for fetchHttpRawData()"));
                    last = -1;
                }
                // Parse the body argument, which cannot be a boolean.
                if (last > 2 && arguments[2]) {
                    parsed.body = arguments[2];
                    if (this._isBoolean(parsed.body)) {
                        console.warn(new Error("Invalid body for fetchHttpRawData()"));
                        last = -1;
                    }
                }

                // Parse the types, which can be provided as an array or as a
                // sequence of DataType arguments.
                if (last === 4 && Array.isArray(arguments[3])) {
                    parsed.types = arguments[3];
                } else if (last < 4 || !(arguments[3] instanceof exports.HttpService.DataType)) {
                    parsed.types = [exports.HttpService.DataType.JSON];
                } else {
                    i = 3;
                    n = last;
                    while (i < n && arguments[i] instanceof exports.HttpService.DataType) {
                        ++i;
                    }
                    parsed.types = Array.prototype.slice.call(arguments, 3, i);
                    if (i < n) {
                        console.warn(new Error("Invalid types for fetchHttpRawData()"));
                        last = -1;
                    }
                }

                if (last === 5 && arguments[4] instanceof DataQuery) {
                    parsed.query = arguments[4];
                } else if (last === 4 && arguments[3] instanceof DataQuery) {
                    parsed.query = arguments[3];
                }
                // Return the parsed arguments.
                return last >= 0 ? parsed : undefined;
            }
        },

        /**
         * @private
         * @method
         */
        _isBoolean: {
            value: function (value) {
                return typeof value === "boolean" || value instanceof Boolean;
            }
        },

        /***************************************************************************
         * Utilities
         */

        formUrlEncode: {
            value: function (string) {
                return encodeURIComponent(string).replace(/ /g, "+").replace(/[!'()*]/g, function(c) {
                    return '%' + c.charCodeAt(0).toString(16);
                });
            }
        }

    }, /** @lends HttpService */ {

        /***************************************************************************
         * Types
         */

        /**
         * @class
         */
        DataType: {
            get: Enumeration.getterFor("_DataType", /** @lends HttpService.DataType */ {

                /**
                 * @type {DataType}
                 */
                BINARY: [{
                    // TO DO.
                }],

                /**
                 * @type {DataType}
                 */
                JSON: [{
                    parseResponse: {
                        value: function (request, url) {
                            var text = request && request.responseText,
                                data = null;
                            if (text) {
                                try {
                                    data = JSON.parse(text);
                                } catch (error) {
                                    console.warn(new Error("Can't parse JSON received from " + url));
                                }
                            } else if (request) {
                                console.warn(new Error("No JSON response received from " + url));
                            }
                            return data;
                        }
                    }
                }],

                /**
                 * @type {DataType}
                 */
                JSONP: [{
                    parseResponse: {
                        value: function (request, url) {
                            var text = request && request.responseText,
                                start = text && text.indexOf("(") + 1,
                                end = text && Math.max(text.lastIndexOf(")"), 0),
                                data = null;
                            if (start && end) {
                                try {
                                    data = text && JSON.parse(text.slice(start, end));
                                } catch (error) {
                                    console.warn(new Error("Can't parse JSONP received from " + url));
                                    console.warn("Response text:", text);
                                }
                            } else if (text) {
                                console.warn(new Error("Can't parse JSONP received from " + url));
                                console.warn("Response text:", text);
                            } else if (request) {
                                console.warn(new Error("No JSONP response received from " + url));
                            }
                            return data;
                        }
                    }
                }],

                /**
                 * @type {DataType}
                 */
                TEXT: [{
                    parseResponse: {
                        value: function (request, url) {
                            var text = request && request.responseText;
                            if (!text && request) {
                                console.warn(new Error("No text response received from " + url));
                            }
                            return text;
                        }
                    }
                }],

                /**
                 * @type {DataType}
                 */
                XML: [{
                    // TO DO.
                }]

            })
        }

    });