<commit_before>/*
 * Copyright (c) 2017 SugarCRM Inc. Licensed by SugarCRM under the Apache 2.0 license.
 */

/**
 * Module that facilitates interaction with a SugarCRM REST API.
 * @module @sugarcrm/ventana
 */

var _instance;
var _methodsToRequest = {
        'read': 'GET',
        'update': 'PUT',
        'create': 'POST',
        'delete': 'DELETE'
    };
var _baseActions = ['read', 'update', 'create', 'delete'];
var _refreshTokenSuccess = function(c) {c();};
var _bulkQueues = { };
var _state = { };

var HttpError = function(request, textStatus, errorThrown) {

    request = request || {};
    request.xhr = request.xhr || {};

    /**
     * AJAX request that caused the error.
     * @property {HttpRequest}
     * @member HttpError
     */
    this.request = request;

    /**
     * XHR status code.
     * @property {string|number}
     * @member HttpError
     */
    this.status = request.xhr.status;

    /**
     * XHR response text.
     * @property {String}
     * @member HttpError
     */
    this.responseText = request.xhr.responseText;

    /**
     * String describing the type of error that occurred.
     *
     * Possible values (besides null) are `"timeout"`, `"error"`, `"abort"`, and `"parsererror"`.
     * @property {String}
     * @member HttpError
     */
    this.textStatus = textStatus;

    /**
     * Textual portion of the HTTP status when HTTP error occurs.
     *
     * For example, `"Not Found"` or `"Internal Server Error"`.
     * @property {String}
     * @member HttpError
     */
    this.errorThrown = errorThrown;

    // The response will not be always a JSON string

    if (typeof(this.responseText) === 'string' && this.responseText.length > 0) {
        try {
            var contentType = this.request.xhr.getResponseHeader('Content-Type');
            if (contentType && (contentType.indexOf('application/json') === 0)) {
                this.payload = JSON.parse(this.responseText);
                /**
                 * Error code.
                 *
                 * Additional failure information. See SugarCRM REST API
                 * documentation for a full list of error codes.
                 * @property {String}
                 * @member HttpError
                 */
                this.code = this.payload.error;

                /**
                 * Error message.
                 *
                 * Localized message appropriate for display to end user.
                 * @property {String}
                 * @member HttpError
                 */
                this.message = this.payload.error_message;
            }
        }
        catch (e) {
            // Ignore this error
        }
    }
};

/**
 * Represents AJAX error.
 *
 * See Jquery/Zepto documentation for details.
 *
 * - http://api.jquery.com/jQuery.ajax/
 * - http://zeptojs.com/#$.ajax
 *
 * @class HttpError
 */
_.extend(HttpError.prototype, {

    /**
     * Returns string representation of HTTP error.
     * @return {String} HTTP error as a string.
     * @member HttpError
     */
    toString: function() {
        return 'HTTP error: ' + this.status +
            '\ntype: ' + this.textStatus +
            '\nerror: ' + this.errorThrown +
            '\nresponse: ' + this.responseText +
            '\ncode: ' + this.code +
            '\nmessage: ' + this.message;
    }

});

var HttpRequest = function(params, debug) {
    /**
     * Request parameters.
     *
     * See Jquery/Zepto documentation for details.
     *
     * - http://api.jquery.com/jQuery.ajax/
     * - http://zeptojs.com/#$.ajax
     *
     * @property {Object}
     * @member HttpRequest
     */
    this.params = params; // TODO: Consider cloning

    /**
     * Flag indicating that a request must output debug information.
     * @property {Boolean}
     * @member HttpRequest
     */
    this.debug = debug;

    /**
     * The state object is used to store global enviroment conditions that may be relevent
     * when processing this request but would not be passed to the server.
     *
     * @property {Object}
     * @member HttpRequest
     */
    this.state = {};
};

/**
 * Represents AJAX request.
 *
 * Encapsulates XHR object and AJAX parameters.
 * @class HttpRequest
 *
 */
_.extend(HttpRequest.prototype, {

    /**
     * Executes AJAX request.
     * @param {string} token OAuth token. Must not be supplied for login
     *   type requests.
     * @param {string} mdHash current private metadata hash, used to
     *   validate the client metadata against the server.
     * @param {string} upHash Current User preferences hash, used to
     *   validate the client user pref data against the server.
     */
    execute: function(token, mdHash, upHash) {
        if (token) {
            this.params.headers = this.params.headers || {};
            this.params.headers['OAuth-Token'] = token;
        }
        if (mdHash) {
            this.params.headers = this.params.headers || {};
            this.params.headers['X-Metadata-Hash'] = mdHash;
        }
        if (upHash) {
            this.params.headers = this.params.headers || {};
            this.params.headers['X-Userpref-Hash'] = upHash;
        }

        //The state object is used to store global enviroment conditions that may be relevent
        //when processing this request but would not be passed to the server.
        this.state = _.extend(this.state, _state);

        if (this.debug) {
            console.log('====== Ajax Request Begin ======');
            console.log(this.params.type + ' ' + this.params.url);
            var parsedData = this.params.data ? JSON.parse(this.params.data) : null;
            if (parsedData && parsedData.password) parsedData.password = '***';
            console.log('Payload: ', this.params.data ? parsedData : 'N/A');
            var p = $.extend({}, this.params);
            delete p.data;
            console.log('params: ', p);
            console.log('====== Request End ======');
        }

        /**
         * XmlHttpRequest object.
         * @property {Object}
         * @member HttpRequest
         */
        this.xhr = $.ajax(this.params);
    }

});

/**
 * Fake jqXHR object because bulk calls do not have their own individual XHR requests
 * @param {object} result object returned from the BulkAPI
 * @param {string|number} [result.status=200] The response status.
 * @param {Object} [result.status=200] The response headers.
 * @param {Object} [result.contents={}] The response contents.
 * @param {HttpRequest} parentReq bulk API request that this request is a part of.
 */
var bulkXHR = function(result, parentReq) {
    var contents = result.contents || {};

    this.status = result.status || 200;
    this.statusText = result.status_text || '';
    this.responseText = _.isObject(contents) ? JSON.stringify(contents) : contents;
    this.readyState = 4;
    this._parent = parentReq.xhr;
    this.headers = result.headers;
};

_.extend(bulkXHR.prototype, {
    isResolved: function() {
        return this._parent.isResolved();
    },
    getResponseHeader: function(h) {
        return this.headers[h];
    },
    getAllResponseHeaders: function() {
        return _.reduce(this.headers, function(str, value, key) {
            return str.concat(key + ': ' + value + '\n');
        }, '');
    }
});

/**
 * SugarCRM Javascript API allows users to interact with SugarCRM instance via its REST interface.
 *
 * Use {@link Api#getInstance} method to create instances of Sugar API.
 * This method accepts arguments object with the following properties:
 * <pre>
 * {
 *   serverUrl: Sugar REST URL end-point
 *   platform: platform name ("portal", "mobile", etc.)
 *   keyValueStore: reference to key/value store provider used to read/save auth token from/to
 *   timeout: request timeout in seconds
 * }
 * </pre>
 *
 * The key/value store provider must implement three methods:
 * <pre><code>
 *   set: void function(String key, String value)
 *   get: String function(key)
 *   cut: void function(String key)
 * </code></pre>
 * The authentication tokens are kept in memory if the key/value store is not specified.
 *
 * Most of Sugar API methods accept `callbacks` object:
 * <pre>
 * {
 *   success: function(data) { },
 *   error: function(error) { },
 *   complete: function() { },
 * }
 * </pre>
 *
 * @class Api
 * @singleton
 */
function SugarApi(args) {
    var _serverUrl,
        _platform,
        _keyValueStore,
        _clientID,
        _timeout,
        _refreshingToken,
        _defaultErrorHandler,
        _externalLogin,
        _allowBulk,
        _externalLoginUICallback = null,
        _accessToken = null,
        _refreshToken = null,
        _downloadToken = null,
        // request queue
        // used to support multiple request while in refresh token loop
        _rqueue = [],
        // dictionary of currently executed requests (keys are IDs)
        _requests = {},
        // request unique ID (counter)
        _ruid = 0;

    // if no key/value store is provided, the auth token is kept in memory
    _keyValueStore = args && args.keyValueStore;
    _serverUrl = (args && args.serverUrl) || '/rest/v10';
    // there will only be a fallback default error handler if provided here
    _defaultErrorHandler = (args && args.defaultErrorHandler) || null;
    _platform = (args && args.platform) || '';
    _clientID = (args && args.clientID) || 'sugar';
    _timeout = ((args && args.timeout) || 30) * 1000;
    _externalLogin = false;
    _allowBulk = !(args && args.disableBulkApi);

    if (args && args.externalLoginUICallback && _.isFunction(args.externalLoginUICallback)) {
        _externalLoginUICallback = args.externalLoginUICallback;
    }

    if (_keyValueStore) {
        if (!$.isFunction(_keyValueStore.set) ||
            !$.isFunction(_keyValueStore.get) ||
            !$.isFunction(_keyValueStore.cut))
        {
            throw new Error('Failed to initialize Sugar API: key/value store provider is invalid');
        }

        var init = function() {
            _accessToken = _keyValueStore.get('AuthAccessToken');
            _refreshToken = _keyValueStore.get('AuthRefreshToken');
            _downloadToken = _keyValueStore.get('DownloadToken');
        };

        if ($.isFunction(_keyValueStore.initAsync)) {
            _keyValueStore.initAsync(init);
        } else {
            init();
        }

        if ($.isFunction(_keyValueStore.on)) {
            _keyValueStore.on('cache:clean', function(callback) {
                callback(['AuthAccessToken', 'AuthRefreshToken', 'DownloadToken']);
            });
        }
    }

    _refreshingToken = false;

    var _resetAuth = function(data) {
        // data is the response from the server
        if (data) {
            _accessToken = data.access_token;
            _refreshToken = data.refresh_token;
            _downloadToken = data.download_token;
            if (_keyValueStore) {
                _keyValueStore.set('AuthAccessToken', _accessToken);
                _keyValueStore.set('AuthRefreshToken', _refreshToken);
                _keyValueStore.set('DownloadToken', _downloadToken);
            }
        } else {
            _accessToken = null;
            _refreshToken = null;
            _downloadToken = null;
            if (_keyValueStore) {
                _keyValueStore.cut('AuthAccessToken');
                _keyValueStore.cut('AuthRefreshToken');
                _keyValueStore.cut('DownloadToken');
            }
        }
    };

    var _handleErrorAndRefreshToken = function(self, request, callbacks) {
        return function(xhr, textStatus, errorThrown) {
            var error = new HttpError(request, textStatus, errorThrown);
            var onError = function() {
                // Either regular request failed or token refresh failed
                // Call original error callback
                if (!_rqueue.length || self.refreshingToken(request.params.url)) {
                    if (callbacks.error) {
                        callbacks.error(error);
                    }
                    else if ($.isFunction(self.defaultErrorHandler)) {
                        self.defaultErrorHandler(error);
                    }
                } else {
                    // Token refresh failed
                    // Call original error callback for all queued requests
                    for (var i = 0; i < _rqueue.length; ++i) {
                        if (_rqueue[i]._oerror) _rqueue[i]._oerror(error);
                    }
                }
                // duplicated due to other types of authentication
                self.setRefreshingToken(false);
            };

            var r, refreshFailed = true;
            if (self.needRefreshAuthToken(request.params.url, error.code)) {
                //If we were unloading the queue when we got another 401, we should stop before we get into a loop
                if (request._dequeuing) {
                    if (request._oerror) request._oerror(error);
                    //The tokens we have are bad, nuke them.
                    self.resetAuth();
                    return;
                }
                _rqueue.push(request);

                self.setRefreshingToken(true);

                var refreshCallbacks = {
                    complete: function() {

                        // Call original complete callback for all queued requests
                        // only if token refresh failed
                        // In case of requests succeed, complete callback is called by ajax lib
                        if (refreshFailed) {
                            r = _rqueue.shift();
                            while (r) {
                                if (r._ocomplete) {
                                    r._ocomplete.call(this, r);
                                }
                                r = _rqueue.shift();
                            }
                        }
                    },
                    success: function() {
                        self.setRefreshingToken(false);
                        refreshFailed = false;
                        _refreshTokenSuccess(function() {
                            // Repeat original requests
                            r = _rqueue.shift();
                            while (r) {
                                r._dequeuing = true;
                                r.execute(self.getOAuthToken());
                                r = _rqueue.shift();
                            }
                        });
                    },
                    error: onError
                };

                if (!('crosstab' in window) || !crosstab.supported) {
                    self.login(null, {refresh: true}, {
                        complete: refreshCallbacks.complete,
                        success: refreshCallbacks.success,
                        error: refreshCallbacks.error
                    });
                    return;
                }

                crosstab(function() {
                    crosstab.on('auth:refresh:complete', function(event) {
                        var status = event.data;
                        switch (status) {
                            case 'success':
                                refreshCallbacks.success();
                                break;
                            case 'error':
                                refreshCallbacks.error();
                                break;
                            case 'complete':
                                refreshCallbacks.complete();
                                crosstab.off('auth:refresh:complete');
                                break;
                        }
                    });

                    crosstab.broadcastMaster('auth:refresh');
                });

            } else if (self.needQueue(request.params.url)) {
                // Queue subsequent request to execute it after token refresh completes
                _rqueue.push(request);
            } else if (_externalLogin && error.status == 401 && error.payload &&
                error.payload.url && error.payload.platform == _platform
            ) {
                self.handleExternalLogin(request, error, onError);
            } else {
                onError();
            }
        };
    };


    return {
        /**
         * Client Id for oAuth
         * @property {String}
         * @member Api
         */
        clientID: _clientID,

        /**
         * URL of Sugar REST end-point.
         * @property {String}
         * @member Api
         */
        serverUrl: _serverUrl,

        /**
         * Default fallback HTTP error handler. Used when api.call
         * is not supplied with an error: function in callbacks parameter.
         * @property {Function}
         * @member Api
         */
        defaultErrorHandler: _defaultErrorHandler,

        /**
         * Request timeout (in milliseconds).
         * @property {Number}
         * @member Api
         */
        timeout: _timeout,

        /**
         * Flag indicating if API should run in debug mode (console debugging of API calls).
         * @property {Boolean}
         * @member Api
         */
        debug: false,

        /**
         * Aborts a request by ID
         * @param {String} id Request ID
         */
        abortRequest: function(id) {
            var request = _requests[id];

            if (request) {
                request.aborted = true;
                if (request.xhr) {
                    request.xhr.aborted = true;
                    request.xhr.abort();
                }
            }
        },

        /**
         * Gets request by ID
         * @param {String} id Request ID
         */
        getRequest: function(id) {
            return _requests[id];
        },

        /**
         * Sets the callback to be triggered after a token refresh occurs
         * @param callback function to be called
         */
        setRefreshTokenSuccessCallback: function(callback) {
            if (_.isFunction(callback))
                _refreshTokenSuccess = callback;
        },

        /**
         * Makes AJAX call via jquery/zepto AJAX API.
         *
         * @param {string} method CRUD action to make (read, create, update,
         *   delete) are mapped to corresponding HTTP verb: GET, POST, PUT, DELETE.
         * @param {String} url resource URL.
         * @param {Object} [data] data will be stringified into JSON and
         *   set to request body.
         * @param {Object} [callbacks] callbacks object.
         * @param {Object} [options] options for request that map
         *   directly to the jquery/zepto Ajax options.
         * @return {HttpRequest} AJAX request.
         * @private
         * @member Api
         */
        call: function(method, url, data, callbacks, options) {
            var request,
                args,
                type = _methodsToRequest[method],
                self = this,
                token = this.getOAuthToken();

            options = options || {};
            callbacks = callbacks || {};

            // by default use json headers
            var params = {
                type: type,
                dataType: 'json',
                headers: {},
                timeout: options.timeout || this.timeout,
                contentType: 'application/json'
            };

            // if we dont have a url from options take arg url
            if (!options.url) {
                params.url = url;
            }

            if (callbacks.success) {
                params.success = function(data, status) {
                    /**
                     * XHR status code.
                     * @property {string|number}
                     * @member HttpRequest
                     */
                    request.status = (data && data.status) ? data.status : status;
                    callbacks.success(data, request);
                };
            }

            params.complete = function() {

                _requests[request.uid] = null;

                // Do not call complete callback if we are in token refresh loop
                // We'll call complete callback once the refresh completes
                if (!_refreshingToken && callbacks.complete) {
                    callbacks.complete(request);
                }
            };

            //Process the iframe transport request
            if (options.iframe === true) {
                if (token) {
                    data = data || {};
                    data['OAuth-Token'] = token;
                    params.data = data;
                }
            } else {
                // set data for create, update, and delete
                if (data && (method == 'create' || method == 'update' || method == 'delete')) {
                    params.data = JSON.stringify(data);
                }
            }

            // Don't process data on a non-GET request.
            if (params.type !== 'GET') {
                params.processData = false;
            }

            // Clients may override any of AJAX options.
            request = new HttpRequest(_.extend(params, options), this.debug);
            params.error = _handleErrorAndRefreshToken(self, request, callbacks);
            // Keep original error and complete callback for token refresh loop
            request._oerror = callbacks.error;
            request._ocomplete = callbacks.complete;

            //add request to requests hash
            request.uid = _ruid++;
            _requests[request.uid] = request;

            args = [
                token,
                options.skipMetadataHash ? null : this.getMetadataHash(),
                options.skipMetadataHash ? null : this.getUserprefHash()
            ];
            // Login request doesn't need auth token
            if (this.isLoginRequest(url)) {
                request.execute();
            } else if (this.isAuthRequest(url)) {
                request.execute(token);
            } else if (params.bulk && _allowBulk) {
                var bulkQueue = _bulkQueues[params.bulk] || [];
                if (!_bulkQueues[params.bulk]) {
                    _bulkQueues[params.bulk] = bulkQueue;
                }
                bulkQueue.push({
                    request: request,
                    args: args
                });
            } else {
                request.execute.apply(request, args);
            }

            return request;
        },

        /**
         * Begins a BulkAPI request. Previous uses of call() should have
         * options.bulk set to an ID.
         * Calling triggerBulkCall with the same ID will combine all the
         * previously queued requests into a single bulk call.
         * @param {number|string} bulkId
         * @member Api
         */
        triggerBulkCall: function(bulkId) {
            if (!_allowBulk) {
                return;
            }

            bulkId = bulkId || true;
            var queue = _bulkQueues[bulkId],
                requests = [],
                version = _.last(this.serverUrl.split('/'));

            if (!queue) {
                //TODO log an error here
                return;
            }
            _.each(queue, function(r) {
                var params = r.request.params,
                    args = r.args,
                    token = args[0],
                    mdHash = args[1],
                    upHash = args[2];

                if (token) {
                    params.headers = params.headers || {};
                    params.headers['OAuth-Token'] = token;
                }
                if (mdHash) {
                    params.headers = params.headers || {};
                    params.headers['X-Metadata-Hash'] = mdHash;
                }
                if (upHash) {
                    params.headers = params.headers || {};
                    params.headers['X-Userpref-Hash'] = upHash;
                }
                //Url needs to be trimmed down to the version number.
                if (!_.isEmpty(params.url)) {
                    params.url = version + params.url.substring(this.serverUrl.length);
                }

                requests.push(params);
            }, this);
            this.call('create', this.buildURL('bulk'), {requests: requests}, {
                success: function(o, parentReq) {
                    _.each(queue, function(r, i) {
                        var request = r.request,
                            result = o[i],
                            contents = result.contents || {},
                            xhr = new bulkXHR(result, parentReq);
                        request.xhr = xhr;

                        if (request.aborted === true || contents.error) {
                            request.status = 'error';
                            if (_.isFunction(request.params.error)) {
                                request.params.error.call(request.params, request, 'error', xhr.statusText);
                            }
                        }
                        else {
                            if (_.isFunction(request.params.success)) {
                                request.params.success.call(request.params, contents, 'success');
                            }
                        }
                        if (_.isFunction(request.params.complete)) {
                            request.params.complete.call(request.params, request);
                        }
                    });
                }
            });
            _bulkQueues[bulkId] = null;
        },

        clearBulkQueue: function() {
            _bulkQueues = {};
        },

        /**
         * Builds URL based on module name action and attributes of the format rooturl/module/id/action.
         *
         * The `attributes` hash must contain `id` of the resource being actioned upon
         * for record CRUD and `relatedId` if the URL is build for relationship CRUD.
         *
         * @param  {String} module module name.
         * @param  {String} action CRUD method.
         * @param  {Object} [attributes] object of resource being
         *   actioned upon, e.g. `{name: "bob", id:"123"}`.
         * @param  {Object} [params] URL parameters.
         * @return {String} URL for specified resource.
         * @private
         * @member Api
         */
        buildURL: function(module, action, attributes, params) {
            params = params || {};
            var parts = [];
            var url;
            parts.push(this.serverUrl);

            if (module) {
                parts.push(module);
            }

            if ((action != 'create') && attributes && attributes.id) {
                parts.push(attributes.id);
            }

            if (attributes && attributes.link && action != 'file') {
                parts.push('link');
                if (_.isString(attributes.link)) {
                    parts.push(attributes.link);
                }
            }

            if (action && $.inArray(action, _baseActions) === -1) {
                parts.push(action);
            }

            if (attributes && attributes.relatedId) {
                parts.push(attributes.relatedId);
            }

            if (attributes && action == 'file' && attributes.field) {
                parts.push(attributes.field);
                if (attributes.fileId)
                    parts.push(attributes.fileId);
            }

            url = parts.join('/');

            // URL parameters
            // remove nullish params
            _.each(params, function(value, key) {
                if (value === null || value === undefined) {
                    delete params[key];
                }
            });

            params = $.param(params);
            if (params.length > 0) {
                url += '?' + params;
            }

            return url;
        },

        /**
         * Builds a file resource URL.
         *
         * The `attributes` hash must contain the following properties:
         *
         *     {
         *         module: module name
         *         id: record id
         *         field: Name of the file field in the given module (optional).
         *     }
         *
         * Example 1:
         *
         *     var url = app.api.buildFileURL({
         *        module: 'Contacts',
         *        id: '123',
         *        field: 'picture'
         *     });
         *
         *     // Returns:
         *     'http://localhost:8888/sugarcrm/rest/v10/Contacts/123/file/picture?format=sugar-html-json&platform=base'
         *
         * The `field` property is optional. If omitted the method returns a
         * URL for a list of file resources.
         *
         * Example 2:
         *
         *     var url = app.api.buildFileURL({
         *        module: 'Contacts',
         *        id: '123'
         *     });
         *
         *     // Returns:
         *     'http://localhost:8888/sugarcrm/rest/v10/Contacts/123/file?platform=base'
         *
         * @param {Object} attributes Hash with file information.
         * @param {Object} [options] URL options hash.
         * @param {boolean} [options.htmlJsonFormat] Flag indicating if
         *   `sugar-html-json` format must be used (`true` by default if
         *   `field` property is specified).
         * @param {boolean} [options.passDownloadToken] Flag indicating if
         *   download token must be passed in the URL (`false` by default).
         * @param {boolean} [options.deleteIfFails] Flag indicating if
         *   related record should be marked deleted:1 if file operation
         *   unsuccessful.
         * @param {boolean} [options.keep] Flag indicating if the temporary
         *   file should be kept when issuing a `GET` request to the
         *   `FileTempApi` (it is cleaned up by default).
         * @return {string} URL for the file resource.
         * @member Api
         */
        buildFileURL: function(attributes, options) {
            var params = {};
            options = options || {};
            // We only concerned about the format if build URL for an actual file resource
            if (attributes.field && (options.htmlJsonFormat !== false)) {
                params.format = 'sugar-html-json';
            }

            if (options.deleteIfFails === true) {
                params.delete_if_fails = true;
            }

            // This is for BWC only. Don't document it and remove as soon as 6.7 is decommissioned.
            if (options.passOAuthToken) {
                params.oauth_token = this.getOAuthToken();
            }

            if (options.passDownloadToken) {
                params.download_token = this.getDownloadToken();
            }

            if (!_.isUndefined(options.forceDownload)) {
                params.force_download = (options.forceDownload) ? 1 : 0;
            }

            if (options.cleanCache === true) {
                params[(new Date()).getTime()] = 1;
            }

            if (options.platform !== undefined) {
                params.platform = options.platform;
            } else if (_platform) {
                params.platform = _platform;
            }

            if (options.keep === true) {
                params.keep = 1;
            }

            return this.buildURL(attributes.module, 'file', attributes, params);
        },

        /**
         * Returns the current access token
         */
        getOAuthToken: function() {
            return _keyValueStore ? _keyValueStore.get('AuthAccessToken') || _accessToken : _accessToken;
        },

        /**
         * Returns the current refresh token
         */
        getRefreshToken: function() {
            return _keyValueStore ? _keyValueStore.get('AuthRefreshToken') || _refreshToken : _refreshToken;
        },

        /**
         * Returns the current download token.
         */
        getDownloadToken: function() {
            return _keyValueStore ? _keyValueStore.get('DownloadToken') || _downloadToken : _downloadToken;
        },


        getMetadataHash: function() {
            return _keyValueStore ? _keyValueStore.get('meta:hash') : null;
        },

        /**
         * Gets the user preference hash for use in checking state of change
         *
         * @return {String} The user preference hash set from a /me response
         */
        getUserprefHash: function() {
            return _keyValueStore ? _keyValueStore.get('userpref:hash') : null;
        },

        /**
         * Fetches metadata.
         *
         * @param {Objec} [options] Options to decide what to get from
         *   the server.
         * @param {Array} [options.types] Metadata types to fetch.
         *   E.g.: `['vardefs','detailviewdefs']`
         * @param {Array} [options.modules] Module names to fetch.
         *   E.g.: `['accounts','contacts']`
         * @param {Object} [options.callbacks] callback object.
         * @param {Object} [options.public=false] Pass `true` to get the
         *   public metadata.
         * @param {Object} [options.params] Extra params to send to the
         *   request.
         * @return {HttpRequest} The AJAX request.
         * @member Api
         */
        getMetadata: function(options) {

            if (_.isString(options)) {
                console.warn('`Api::getMetadata` has changed signature since 7.9 ' +
                    'and will drop support for old signature in 7.10. Please update your code.');

                var oldOpt = arguments[4] || {};
                options = {
                    types: arguments[1],
                    modules: arguments[2],
                    callbacks: arguments[3],
                    public: oldOpt.getPublic,
                    params: oldOpt.params,
                };
            }

            options = options || {};
            var params = options.params || {};

            if (options.types) {
                params.type_filter = options.types.join(',');
            }

            if (options.modules) {
                params.module_filter = options.modules.join(',');
            }

            if (_platform) {
                params.platform = _platform;
            }

            var method = 'read';

            if (options.public) {
                method = 'public';
            } else {
                options.callbacks = options.callbacks || {};
                options.callbacks.success = _.wrap(options.callbacks.success, function (success, data, status) {
                    if (data) {
                        this._serverInfo = data.server_info;
                    }

                    if (success) {
                        success(data, status);
                    }
                });
            }

            params.module_dependencies = 1;

            var url = this.buildURL('metadata', method, null, params);
            return this.call(method, url, null, options.callbacks);
        },

        /**
         * Executes CRUD on records.
         *
         * @param {String} method operation type: create, read, update, or delete.
         * @param {String} module module name.
         * @param {Object} data request object. If it contains id, action,
         *   link, etc., URI will be adjusted accordingly.
         * If methods parameter is 'create' or 'update', the data object will be put in the request body payload.
         * @param {Object} [params] URL parameters.
         * @param {Object} [callbacks] callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        records: function(method, module, data, params, callbacks, options) {
            var url = this.buildURL(module, method, data, params);
            return this.call(method, url, data, callbacks, options);
        },

        /**
         * Executes CRUD on relationships.
         *
         * The data paramerer represents relationship information:
         * <pre>
         * {
         *    id: record ID
         *    link: relationship link name
         *    relatedId: ID of the related record
         *    related: object that contains request payload (related record or relationship fields)
         * }
         * </pre>
         *
         * @param {String} method operation type: create, read, update, or delete.
         * @param {String} module module name.
         * @param {Object} data object with relationship information.
         * @param {Object} [params] URL parameters.
         * @param {Object} [callbacks] callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        relationships: function(method, module, data, params, callbacks, options) {
            var url = this.buildURL(module, null, data, params);
            return this.call(method, url, data.related, callbacks, options);
        },

        /**
         * Marks/unmarks a record as favorite.
         * @param {string} module Module name.
         * @param {string} id Record ID.
         * @param {boolean} favorite Flag indicating if the record must be marked as favorite.
         * @param {Object} [callbacks] Callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        favorite: function(module, id, favorite, callbacks, options) {
            var action = favorite ? 'favorite' : 'unfavorite';
            var url = this.buildURL(module, action, { id: id });
            return this.call('update', url, null, callbacks, options);
        },

        /**
         * Subscribe/unsubscribe a record changes.
         * @param {string} module Module name.
         * @param {string} id Record ID.
         * @param {boolean} followed Flag indicates if wants to subscribe
         *   the record changes.
         * @param {Object} [callbacks] Callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api<commit_msg>SC-6323: Add `collection` method.<commit_after>/*
 * Copyright (c) 2017 SugarCRM Inc. Licensed by SugarCRM under the Apache 2.0 license.
 */

/**
 * Module that facilitates interaction with a SugarCRM REST API.
 * @module @sugarcrm/ventana
 */

var _instance;
var _methodsToRequest = {
        'read': 'GET',
        'update': 'PUT',
        'create': 'POST',
        'delete': 'DELETE'
    };
var _baseActions = ['read', 'update', 'create', 'delete'];
var _refreshTokenSuccess = function(c) {c();};
var _bulkQueues = { };
var _state = { };

var HttpError = function(request, textStatus, errorThrown) {

    request = request || {};
    request.xhr = request.xhr || {};

    /**
     * AJAX request that caused the error.
     * @property {HttpRequest}
     * @member HttpError
     */
    this.request = request;

    /**
     * XHR status code.
     * @property {string|number}
     * @member HttpError
     */
    this.status = request.xhr.status;

    /**
     * XHR response text.
     * @property {String}
     * @member HttpError
     */
    this.responseText = request.xhr.responseText;

    /**
     * String describing the type of error that occurred.
     *
     * Possible values (besides null) are `"timeout"`, `"error"`, `"abort"`, and `"parsererror"`.
     * @property {String}
     * @member HttpError
     */
    this.textStatus = textStatus;

    /**
     * Textual portion of the HTTP status when HTTP error occurs.
     *
     * For example, `"Not Found"` or `"Internal Server Error"`.
     * @property {String}
     * @member HttpError
     */
    this.errorThrown = errorThrown;

    // The response will not be always a JSON string

    if (typeof(this.responseText) === 'string' && this.responseText.length > 0) {
        try {
            var contentType = this.request.xhr.getResponseHeader('Content-Type');
            if (contentType && (contentType.indexOf('application/json') === 0)) {
                this.payload = JSON.parse(this.responseText);
                /**
                 * Error code.
                 *
                 * Additional failure information. See SugarCRM REST API
                 * documentation for a full list of error codes.
                 * @property {String}
                 * @member HttpError
                 */
                this.code = this.payload.error;

                /**
                 * Error message.
                 *
                 * Localized message appropriate for display to end user.
                 * @property {String}
                 * @member HttpError
                 */
                this.message = this.payload.error_message;
            }
        }
        catch (e) {
            // Ignore this error
        }
    }
};

/**
 * Represents AJAX error.
 *
 * See Jquery/Zepto documentation for details.
 *
 * - http://api.jquery.com/jQuery.ajax/
 * - http://zeptojs.com/#$.ajax
 *
 * @class HttpError
 */
_.extend(HttpError.prototype, {

    /**
     * Returns string representation of HTTP error.
     * @return {String} HTTP error as a string.
     * @member HttpError
     */
    toString: function() {
        return 'HTTP error: ' + this.status +
            '\ntype: ' + this.textStatus +
            '\nerror: ' + this.errorThrown +
            '\nresponse: ' + this.responseText +
            '\ncode: ' + this.code +
            '\nmessage: ' + this.message;
    }

});

var HttpRequest = function(params, debug) {
    /**
     * Request parameters.
     *
     * See Jquery/Zepto documentation for details.
     *
     * - http://api.jquery.com/jQuery.ajax/
     * - http://zeptojs.com/#$.ajax
     *
     * @property {Object}
     * @member HttpRequest
     */
    this.params = params; // TODO: Consider cloning

    /**
     * Flag indicating that a request must output debug information.
     * @property {Boolean}
     * @member HttpRequest
     */
    this.debug = debug;

    /**
     * The state object is used to store global enviroment conditions that may be relevent
     * when processing this request but would not be passed to the server.
     *
     * @property {Object}
     * @member HttpRequest
     */
    this.state = {};
};

/**
 * Represents AJAX request.
 *
 * Encapsulates XHR object and AJAX parameters.
 * @class HttpRequest
 *
 */
_.extend(HttpRequest.prototype, {

    /**
     * Executes AJAX request.
     * @param {string} token OAuth token. Must not be supplied for login
     *   type requests.
     * @param {string} mdHash current private metadata hash, used to
     *   validate the client metadata against the server.
     * @param {string} upHash Current User preferences hash, used to
     *   validate the client user pref data against the server.
     */
    execute: function(token, mdHash, upHash) {
        if (token) {
            this.params.headers = this.params.headers || {};
            this.params.headers['OAuth-Token'] = token;
        }
        if (mdHash) {
            this.params.headers = this.params.headers || {};
            this.params.headers['X-Metadata-Hash'] = mdHash;
        }
        if (upHash) {
            this.params.headers = this.params.headers || {};
            this.params.headers['X-Userpref-Hash'] = upHash;
        }

        //The state object is used to store global enviroment conditions that may be relevent
        //when processing this request but would not be passed to the server.
        this.state = _.extend(this.state, _state);

        if (this.debug) {
            console.log('====== Ajax Request Begin ======');
            console.log(this.params.type + ' ' + this.params.url);
            var parsedData = this.params.data ? JSON.parse(this.params.data) : null;
            if (parsedData && parsedData.password) parsedData.password = '***';
            console.log('Payload: ', this.params.data ? parsedData : 'N/A');
            var p = $.extend({}, this.params);
            delete p.data;
            console.log('params: ', p);
            console.log('====== Request End ======');
        }

        /**
         * XmlHttpRequest object.
         * @property {Object}
         * @member HttpRequest
         */
        this.xhr = $.ajax(this.params);
    }

});

/**
 * Fake jqXHR object because bulk calls do not have their own individual XHR requests
 * @param {object} result object returned from the BulkAPI
 * @param {string|number} [result.status=200] The response status.
 * @param {Object} [result.status=200] The response headers.
 * @param {Object} [result.contents={}] The response contents.
 * @param {HttpRequest} parentReq bulk API request that this request is a part of.
 */
var bulkXHR = function(result, parentReq) {
    var contents = result.contents || {};

    this.status = result.status || 200;
    this.statusText = result.status_text || '';
    this.responseText = _.isObject(contents) ? JSON.stringify(contents) : contents;
    this.readyState = 4;
    this._parent = parentReq.xhr;
    this.headers = result.headers;
};

_.extend(bulkXHR.prototype, {
    isResolved: function() {
        return this._parent.isResolved();
    },
    getResponseHeader: function(h) {
        return this.headers[h];
    },
    getAllResponseHeaders: function() {
        return _.reduce(this.headers, function(str, value, key) {
            return str.concat(key + ': ' + value + '\n');
        }, '');
    }
});

/**
 * SugarCRM Javascript API allows users to interact with SugarCRM instance via its REST interface.
 *
 * Use {@link Api#getInstance} method to create instances of Sugar API.
 * This method accepts arguments object with the following properties:
 * <pre>
 * {
 *   serverUrl: Sugar REST URL end-point
 *   platform: platform name ("portal", "mobile", etc.)
 *   keyValueStore: reference to key/value store provider used to read/save auth token from/to
 *   timeout: request timeout in seconds
 * }
 * </pre>
 *
 * The key/value store provider must implement three methods:
 * <pre><code>
 *   set: void function(String key, String value)
 *   get: String function(key)
 *   cut: void function(String key)
 * </code></pre>
 * The authentication tokens are kept in memory if the key/value store is not specified.
 *
 * Most of Sugar API methods accept `callbacks` object:
 * <pre>
 * {
 *   success: function(data) { },
 *   error: function(error) { },
 *   complete: function() { },
 * }
 * </pre>
 *
 * @class Api
 * @singleton
 */
function SugarApi(args) {
    var _serverUrl,
        _platform,
        _keyValueStore,
        _clientID,
        _timeout,
        _refreshingToken,
        _defaultErrorHandler,
        _externalLogin,
        _allowBulk,
        _externalLoginUICallback = null,
        _accessToken = null,
        _refreshToken = null,
        _downloadToken = null,
        // request queue
        // used to support multiple request while in refresh token loop
        _rqueue = [],
        // dictionary of currently executed requests (keys are IDs)
        _requests = {},
        // request unique ID (counter)
        _ruid = 0;

    // if no key/value store is provided, the auth token is kept in memory
    _keyValueStore = args && args.keyValueStore;
    _serverUrl = (args && args.serverUrl) || '/rest/v10';
    // there will only be a fallback default error handler if provided here
    _defaultErrorHandler = (args && args.defaultErrorHandler) || null;
    _platform = (args && args.platform) || '';
    _clientID = (args && args.clientID) || 'sugar';
    _timeout = ((args && args.timeout) || 30) * 1000;
    _externalLogin = false;
    _allowBulk = !(args && args.disableBulkApi);

    if (args && args.externalLoginUICallback && _.isFunction(args.externalLoginUICallback)) {
        _externalLoginUICallback = args.externalLoginUICallback;
    }

    if (_keyValueStore) {
        if (!$.isFunction(_keyValueStore.set) ||
            !$.isFunction(_keyValueStore.get) ||
            !$.isFunction(_keyValueStore.cut))
        {
            throw new Error('Failed to initialize Sugar API: key/value store provider is invalid');
        }

        var init = function() {
            _accessToken = _keyValueStore.get('AuthAccessToken');
            _refreshToken = _keyValueStore.get('AuthRefreshToken');
            _downloadToken = _keyValueStore.get('DownloadToken');
        };

        if ($.isFunction(_keyValueStore.initAsync)) {
            _keyValueStore.initAsync(init);
        } else {
            init();
        }

        if ($.isFunction(_keyValueStore.on)) {
            _keyValueStore.on('cache:clean', function(callback) {
                callback(['AuthAccessToken', 'AuthRefreshToken', 'DownloadToken']);
            });
        }
    }

    _refreshingToken = false;

    var _resetAuth = function(data) {
        // data is the response from the server
        if (data) {
            _accessToken = data.access_token;
            _refreshToken = data.refresh_token;
            _downloadToken = data.download_token;
            if (_keyValueStore) {
                _keyValueStore.set('AuthAccessToken', _accessToken);
                _keyValueStore.set('AuthRefreshToken', _refreshToken);
                _keyValueStore.set('DownloadToken', _downloadToken);
            }
        } else {
            _accessToken = null;
            _refreshToken = null;
            _downloadToken = null;
            if (_keyValueStore) {
                _keyValueStore.cut('AuthAccessToken');
                _keyValueStore.cut('AuthRefreshToken');
                _keyValueStore.cut('DownloadToken');
            }
        }
    };

    var _handleErrorAndRefreshToken = function(self, request, callbacks) {
        return function(xhr, textStatus, errorThrown) {
            var error = new HttpError(request, textStatus, errorThrown);
            var onError = function() {
                // Either regular request failed or token refresh failed
                // Call original error callback
                if (!_rqueue.length || self.refreshingToken(request.params.url)) {
                    if (callbacks.error) {
                        callbacks.error(error);
                    }
                    else if ($.isFunction(self.defaultErrorHandler)) {
                        self.defaultErrorHandler(error);
                    }
                } else {
                    // Token refresh failed
                    // Call original error callback for all queued requests
                    for (var i = 0; i < _rqueue.length; ++i) {
                        if (_rqueue[i]._oerror) _rqueue[i]._oerror(error);
                    }
                }
                // duplicated due to other types of authentication
                self.setRefreshingToken(false);
            };

            var r, refreshFailed = true;
            if (self.needRefreshAuthToken(request.params.url, error.code)) {
                //If we were unloading the queue when we got another 401, we should stop before we get into a loop
                if (request._dequeuing) {
                    if (request._oerror) request._oerror(error);
                    //The tokens we have are bad, nuke them.
                    self.resetAuth();
                    return;
                }
                _rqueue.push(request);

                self.setRefreshingToken(true);

                var refreshCallbacks = {
                    complete: function() {

                        // Call original complete callback for all queued requests
                        // only if token refresh failed
                        // In case of requests succeed, complete callback is called by ajax lib
                        if (refreshFailed) {
                            r = _rqueue.shift();
                            while (r) {
                                if (r._ocomplete) {
                                    r._ocomplete.call(this, r);
                                }
                                r = _rqueue.shift();
                            }
                        }
                    },
                    success: function() {
                        self.setRefreshingToken(false);
                        refreshFailed = false;
                        _refreshTokenSuccess(function() {
                            // Repeat original requests
                            r = _rqueue.shift();
                            while (r) {
                                r._dequeuing = true;
                                r.execute(self.getOAuthToken());
                                r = _rqueue.shift();
                            }
                        });
                    },
                    error: onError
                };

                if (!('crosstab' in window) || !crosstab.supported) {
                    self.login(null, {refresh: true}, {
                        complete: refreshCallbacks.complete,
                        success: refreshCallbacks.success,
                        error: refreshCallbacks.error
                    });
                    return;
                }

                crosstab(function() {
                    crosstab.on('auth:refresh:complete', function(event) {
                        var status = event.data;
                        switch (status) {
                            case 'success':
                                refreshCallbacks.success();
                                break;
                            case 'error':
                                refreshCallbacks.error();
                                break;
                            case 'complete':
                                refreshCallbacks.complete();
                                crosstab.off('auth:refresh:complete');
                                break;
                        }
                    });

                    crosstab.broadcastMaster('auth:refresh');
                });

            } else if (self.needQueue(request.params.url)) {
                // Queue subsequent request to execute it after token refresh completes
                _rqueue.push(request);
            } else if (_externalLogin && error.status == 401 && error.payload &&
                error.payload.url && error.payload.platform == _platform
            ) {
                self.handleExternalLogin(request, error, onError);
            } else {
                onError();
            }
        };
    };


    return {
        /**
         * Client Id for oAuth
         * @property {String}
         * @member Api
         */
        clientID: _clientID,

        /**
         * URL of Sugar REST end-point.
         * @property {String}
         * @member Api
         */
        serverUrl: _serverUrl,

        /**
         * Default fallback HTTP error handler. Used when api.call
         * is not supplied with an error: function in callbacks parameter.
         * @property {Function}
         * @member Api
         */
        defaultErrorHandler: _defaultErrorHandler,

        /**
         * Request timeout (in milliseconds).
         * @property {Number}
         * @member Api
         */
        timeout: _timeout,

        /**
         * Flag indicating if API should run in debug mode (console debugging of API calls).
         * @property {Boolean}
         * @member Api
         */
        debug: false,

        /**
         * Aborts a request by ID
         * @param {String} id Request ID
         */
        abortRequest: function(id) {
            var request = _requests[id];

            if (request) {
                request.aborted = true;
                if (request.xhr) {
                    request.xhr.aborted = true;
                    request.xhr.abort();
                }
            }
        },

        /**
         * Gets request by ID
         * @param {String} id Request ID
         */
        getRequest: function(id) {
            return _requests[id];
        },

        /**
         * Sets the callback to be triggered after a token refresh occurs
         * @param callback function to be called
         */
        setRefreshTokenSuccessCallback: function(callback) {
            if (_.isFunction(callback))
                _refreshTokenSuccess = callback;
        },

        /**
         * Makes AJAX call via jquery/zepto AJAX API.
         *
         * @param {string} method CRUD action to make (read, create, update,
         *   delete) are mapped to corresponding HTTP verb: GET, POST, PUT, DELETE.
         * @param {String} url resource URL.
         * @param {Object} [data] data will be stringified into JSON and
         *   set to request body.
         * @param {Object} [callbacks] callbacks object.
         * @param {Object} [options] options for request that map
         *   directly to the jquery/zepto Ajax options.
         * @return {HttpRequest} AJAX request.
         * @private
         * @member Api
         */
        call: function(method, url, data, callbacks, options) {
            var request,
                args,
                type = _methodsToRequest[method],
                self = this,
                token = this.getOAuthToken();

            options = options || {};
            callbacks = callbacks || {};

            // by default use json headers
            var params = {
                type: type,
                dataType: 'json',
                headers: {},
                timeout: options.timeout || this.timeout,
                contentType: 'application/json'
            };

            // if we dont have a url from options take arg url
            if (!options.url) {
                params.url = url;
            }

            if (callbacks.success) {
                params.success = function(data, status) {
                    /**
                     * XHR status code.
                     * @property {string|number}
                     * @member HttpRequest
                     */
                    request.status = (data && data.status) ? data.status : status;
                    callbacks.success(data, request);
                };
            }

            params.complete = function() {

                _requests[request.uid] = null;

                // Do not call complete callback if we are in token refresh loop
                // We'll call complete callback once the refresh completes
                if (!_refreshingToken && callbacks.complete) {
                    callbacks.complete(request);
                }
            };

            //Process the iframe transport request
            if (options.iframe === true) {
                if (token) {
                    data = data || {};
                    data['OAuth-Token'] = token;
                    params.data = data;
                }
            } else {
                // set data for create, update, and delete
                if (data && (method == 'create' || method == 'update' || method == 'delete')) {
                    params.data = JSON.stringify(data);
                }
            }

            // Don't process data on a non-GET request.
            if (params.type !== 'GET') {
                params.processData = false;
            }

            // Clients may override any of AJAX options.
            request = new HttpRequest(_.extend(params, options), this.debug);
            params.error = _handleErrorAndRefreshToken(self, request, callbacks);
            // Keep original error and complete callback for token refresh loop
            request._oerror = callbacks.error;
            request._ocomplete = callbacks.complete;

            //add request to requests hash
            request.uid = _ruid++;
            _requests[request.uid] = request;

            args = [
                token,
                options.skipMetadataHash ? null : this.getMetadataHash(),
                options.skipMetadataHash ? null : this.getUserprefHash()
            ];
            // Login request doesn't need auth token
            if (this.isLoginRequest(url)) {
                request.execute();
            } else if (this.isAuthRequest(url)) {
                request.execute(token);
            } else if (params.bulk && _allowBulk) {
                var bulkQueue = _bulkQueues[params.bulk] || [];
                if (!_bulkQueues[params.bulk]) {
                    _bulkQueues[params.bulk] = bulkQueue;
                }
                bulkQueue.push({
                    request: request,
                    args: args
                });
            } else {
                request.execute.apply(request, args);
            }

            return request;
        },

        /**
         * Begins a BulkAPI request. Previous uses of call() should have
         * options.bulk set to an ID.
         * Calling triggerBulkCall with the same ID will combine all the
         * previously queued requests into a single bulk call.
         * @param {number|string} bulkId
         * @member Api
         */
        triggerBulkCall: function(bulkId) {
            if (!_allowBulk) {
                return;
            }

            bulkId = bulkId || true;
            var queue = _bulkQueues[bulkId],
                requests = [],
                version = _.last(this.serverUrl.split('/'));

            if (!queue) {
                //TODO log an error here
                return;
            }
            _.each(queue, function(r) {
                var params = r.request.params,
                    args = r.args,
                    token = args[0],
                    mdHash = args[1],
                    upHash = args[2];

                if (token) {
                    params.headers = params.headers || {};
                    params.headers['OAuth-Token'] = token;
                }
                if (mdHash) {
                    params.headers = params.headers || {};
                    params.headers['X-Metadata-Hash'] = mdHash;
                }
                if (upHash) {
                    params.headers = params.headers || {};
                    params.headers['X-Userpref-Hash'] = upHash;
                }
                //Url needs to be trimmed down to the version number.
                if (!_.isEmpty(params.url)) {
                    params.url = version + params.url.substring(this.serverUrl.length);
                }

                requests.push(params);
            }, this);
            this.call('create', this.buildURL('bulk'), {requests: requests}, {
                success: function(o, parentReq) {
                    _.each(queue, function(r, i) {
                        var request = r.request,
                            result = o[i],
                            contents = result.contents || {},
                            xhr = new bulkXHR(result, parentReq);
                        request.xhr = xhr;

                        if (request.aborted === true || contents.error) {
                            request.status = 'error';
                            if (_.isFunction(request.params.error)) {
                                request.params.error.call(request.params, request, 'error', xhr.statusText);
                            }
                        }
                        else {
                            if (_.isFunction(request.params.success)) {
                                request.params.success.call(request.params, contents, 'success');
                            }
                        }
                        if (_.isFunction(request.params.complete)) {
                            request.params.complete.call(request.params, request);
                        }
                    });
                }
            });
            _bulkQueues[bulkId] = null;
        },

        clearBulkQueue: function() {
            _bulkQueues = {};
        },

        /**
         * Builds URL based on module name action and attributes of the format rooturl/module/id/action.
         *
         * The `attributes` hash must contain `id` of the resource being actioned upon
         * for record CRUD and `relatedId` if the URL is build for relationship CRUD.
         *
         * @param  {String} module module name.
         * @param  {String} action CRUD method.
         * @param  {Object} [attributes] object of resource being
         *   actioned upon, e.g. `{name: "bob", id:"123"}`.
         * @param  {Object} [params] URL parameters.
         * @return {String} URL for specified resource.
         * @private
         * @member Api
         */
        buildURL: function(module, action, attributes, params) {
            params = params || {};
            var parts = [];
            var url;
            parts.push(this.serverUrl);

            if (module) {
                parts.push(module);
            }

            if ((action != 'create') && attributes && attributes.id) {
                parts.push(attributes.id);
            }

            if (attributes && attributes.link && action != 'file') {
                parts.push('link');
                if (_.isString(attributes.link)) {
                    parts.push(attributes.link);
                }
            }

            if (action && $.inArray(action, _baseActions) === -1) {
                parts.push(action);
            }

            if (attributes) {
                if (attributes.relatedId) {
                    parts.push(attributes.relatedId);
                }

                if (action == 'file' && attributes.field) {
                    parts.push(attributes.field);
                    if (attributes.fileId) {
                        parts.push(attributes.fileId);
                    }
                } else if (action === 'collection' && attributes.field) {
                    parts.push(attributes.field);
                }
            }

            url = parts.join('/');

            // URL parameters
            // remove nullish params
            _.each(params, function(value, key) {
                if (value === null || value === undefined) {
                    delete params[key];
                }
            });

            params = $.param(params);
            if (params.length > 0) {
                url += '?' + params;
            }

            return url;
        },

        /**
         * Builds a file resource URL.
         *
         * The `attributes` hash must contain the following properties:
         *
         *     {
         *         module: module name
         *         id: record id
         *         field: Name of the file field in the given module (optional).
         *     }
         *
         * Example 1:
         *
         *     var url = app.api.buildFileURL({
         *        module: 'Contacts',
         *        id: '123',
         *        field: 'picture'
         *     });
         *
         *     // Returns:
         *     'http://localhost:8888/sugarcrm/rest/v10/Contacts/123/file/picture?format=sugar-html-json&platform=base'
         *
         * The `field` property is optional. If omitted the method returns a
         * URL for a list of file resources.
         *
         * Example 2:
         *
         *     var url = app.api.buildFileURL({
         *        module: 'Contacts',
         *        id: '123'
         *     });
         *
         *     // Returns:
         *     'http://localhost:8888/sugarcrm/rest/v10/Contacts/123/file?platform=base'
         *
         * @param {Object} attributes Hash with file information.
         * @param {Object} [options] URL options hash.
         * @param {boolean} [options.htmlJsonFormat] Flag indicating if
         *   `sugar-html-json` format must be used (`true` by default if
         *   `field` property is specified).
         * @param {boolean} [options.passDownloadToken] Flag indicating if
         *   download token must be passed in the URL (`false` by default).
         * @param {boolean} [options.deleteIfFails] Flag indicating if
         *   related record should be marked deleted:1 if file operation
         *   unsuccessful.
         * @param {boolean} [options.keep] Flag indicating if the temporary
         *   file should be kept when issuing a `GET` request to the
         *   `FileTempApi` (it is cleaned up by default).
         * @return {string} URL for the file resource.
         * @member Api
         */
        buildFileURL: function(attributes, options) {
            var params = {};
            options = options || {};
            // We only concerned about the format if build URL for an actual file resource
            if (attributes.field && (options.htmlJsonFormat !== false)) {
                params.format = 'sugar-html-json';
            }

            if (options.deleteIfFails === true) {
                params.delete_if_fails = true;
            }

            // This is for BWC only. Don't document it and remove as soon as 6.7 is decommissioned.
            if (options.passOAuthToken) {
                params.oauth_token = this.getOAuthToken();
            }

            if (options.passDownloadToken) {
                params.download_token = this.getDownloadToken();
            }

            if (!_.isUndefined(options.forceDownload)) {
                params.force_download = (options.forceDownload) ? 1 : 0;
            }

            if (options.cleanCache === true) {
                params[(new Date()).getTime()] = 1;
            }

            if (options.platform !== undefined) {
                params.platform = options.platform;
            } else if (_platform) {
                params.platform = _platform;
            }

            if (options.keep === true) {
                params.keep = 1;
            }

            return this.buildURL(attributes.module, 'file', attributes, params);
        },

        /**
         * Returns the current access token
         */
        getOAuthToken: function() {
            return _keyValueStore ? _keyValueStore.get('AuthAccessToken') || _accessToken : _accessToken;
        },

        /**
         * Returns the current refresh token
         */
        getRefreshToken: function() {
            return _keyValueStore ? _keyValueStore.get('AuthRefreshToken') || _refreshToken : _refreshToken;
        },

        /**
         * Returns the current download token.
         */
        getDownloadToken: function() {
            return _keyValueStore ? _keyValueStore.get('DownloadToken') || _downloadToken : _downloadToken;
        },


        getMetadataHash: function() {
            return _keyValueStore ? _keyValueStore.get('meta:hash') : null;
        },

        /**
         * Gets the user preference hash for use in checking state of change
         *
         * @return {String} The user preference hash set from a /me response
         */
        getUserprefHash: function() {
            return _keyValueStore ? _keyValueStore.get('userpref:hash') : null;
        },

        /**
         * Fetches metadata.
         *
         * @param {Objec} [options] Options to decide what to get from
         *   the server.
         * @param {Array} [options.types] Metadata types to fetch.
         *   E.g.: `['vardefs','detailviewdefs']`
         * @param {Array} [options.modules] Module names to fetch.
         *   E.g.: `['accounts','contacts']`
         * @param {Object} [options.callbacks] callback object.
         * @param {Object} [options.public=false] Pass `true` to get the
         *   public metadata.
         * @param {Object} [options.params] Extra params to send to the
         *   request.
         * @return {HttpRequest} The AJAX request.
         * @member Api
         */
        getMetadata: function(options) {

            if (_.isString(options)) {
                console.warn('`Api::getMetadata` has changed signature since 7.9 ' +
                    'and will drop support for old signature in 7.10. Please update your code.');

                var oldOpt = arguments[4] || {};
                options = {
                    types: arguments[1],
                    modules: arguments[2],
                    callbacks: arguments[3],
                    public: oldOpt.getPublic,
                    params: oldOpt.params,
                };
            }

            options = options || {};
            var params = options.params || {};

            if (options.types) {
                params.type_filter = options.types.join(',');
            }

            if (options.modules) {
                params.module_filter = options.modules.join(',');
            }

            if (_platform) {
                params.platform = _platform;
            }

            var method = 'read';

            if (options.public) {
                method = 'public';
            } else {
                options.callbacks = options.callbacks || {};
                options.callbacks.success = _.wrap(options.callbacks.success, function (success, data, status) {
                    if (data) {
                        this._serverInfo = data.server_info;
                    }

                    if (success) {
                        success(data, status);
                    }
                });
            }

            params.module_dependencies = 1;

            var url = this.buildURL('metadata', method, null, params);
            return this.call(method, url, null, options.callbacks);
        },

        /**
         * Executes CRUD on records.
         *
         * @param {String} method operation type: create, read, update, or delete.
         * @param {String} module module name.
         * @param {Object} data request object. If it contains id, action,
         *   link, etc., URI will be adjusted accordingly.
         * If methods parameter is 'create' or 'update', the data object will be put in the request body payload.
         * @param {Object} [params] URL parameters.
         * @param {Object} [callbacks] callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        records: function(method, module, data, params, callbacks, options) {
            var url = this.buildURL(module, method, data, params);
            return this.call(method, url, data, callbacks, options);
        },

        /**
         * Executes CRUD on relationships.
         *
         * The data paramerer represents relationship information:
         * <pre>
         * {
         *    id: record ID
         *    link: relationship link name
         *    relatedId: ID of the related record
         *    related: object that contains request payload (related record or relationship fields)
         * }
         * </pre>
         *
         * @param {String} method operation type: create, read, update, or delete.
         * @param {String} module module name.
         * @param {Object} data object with relationship information.
         * @param {Object} [params] URL parameters.
         * @param {Object} [callbacks] callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        relationships: function(method, module, data, params, callbacks, options) {
            var url = this.buildURL(module, null, data, params);
            return this.call(method, url, data.related, callbacks, options);
        },

        /**
         * Fetches a collection field.
         *
         * @param {string} module Module name.
         * @param {Object} data object containing information to build the url.
         * @param {string} data.field The name of the collection field to fetch.
         * @param {string} data.id The name of the bean id the collection field
         *   belongs to.
         * @param {Object} [params] URL parameters.
         * @param {Object} [callbacks] callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        collection: function(module, data, params, callbacks, options) {
            var url = this.buildURL(module, 'collection', data, params);
            return this.call('read', url, null, callbacks, options);
        },

        /**
         * Marks/unmarks a record as favorite.
         * @param {string} module Module name.
         * @param {string} id Record ID.
         * @param {boolean} favorite Flag indicating if the record must be marked as favorite.
         * @param {Object} [callbacks] Callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
         */
        favorite: function(module, id, favorite, callbacks, options) {
            var action = favorite ? 'favorite' : 'unfavorite';
            var url = this.buildURL(module, action, { id: id });
            return this.call('update', url, null, callbacks, options);
        },

        /**
         * Subscribe/unsubscribe a record changes.
         * @param {string} module Module name.
         * @param {string} id Record ID.
         * @param {boolean} followed Flag indicates if wants to subscribe
         *   the record changes.
         * @param {Object} [callbacks] Callback object.
         * @param {Object} [options] request options.
         * @return {HttpRequest} AJAX request.
         * @member Api
{'fraction_non_alphanumeric': 0.10596649484536083, 'fraction_numerical': 0.0022422680412371135, 'mean_word_length': 1.4501452386966405, 'pattern_counts': {'":': 0, '<': 25, '<?xml version=': 0, '>': 27, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 54, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5471592', 'n_tokens_mistral': 20023, 'n_tokens_neox': 19129, 'n_words': 5371}