/*************************************************************************
*
* ADOBE CONFIDENTIAL
* ___________________
*
* Copyright 2016 Adobe Systems Incorporated
* All Rights Reserved.
*
* NOTICE: All information contained herein is, and remains
* the property of Adobe Systems Incorporated and its suppliers,
* if any. The intellectual and technical concepts contained
* herein are proprietary to Adobe Systems Incorporated and its
* suppliers and are protected by trade secret or copyright law.
* Dissemination of this information or reproduction of this material
* is strictly forbidden unless prior written permission is obtained
* from Adobe Systems Incorporated.
**************************************************************************/
/*jslint node: true, vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, unparam: true */
/*global nodeRequire, define, module, require, moment, console, ArrayBuffer, Uint8Array, window, XMLHttpRequest */

// Support either node.js or in the browser
/* istanbul ignore else */
if (typeof define !== 'function') { var define = require('amdefine')(module); }
if (typeof nodeRequire !== 'function') { var nodeRequire = require; }

define(function (require, exports, module) {
    'use strict';

    /**
        Utility functions
    **/

    function isNodeJS() {
        var requireType = typeof global; // Avoid JSLint warning
        /* istanbul ignore next */
        return requireType !== 'undefined' && global.Buffer;
    }

    function generateGUID() {
        var s4 = function () {
            return Math.floor((1 + Math.random()) * 0x10000)
                .toString(16)
                .substring(1);
        };
        return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
    }

    function truncateEventQueue(queue, maxLength) {
        var truncatedQueue = queue;
        if (queue && queue.length > maxLength && maxLength > 0) {
            var startIndex = queue.length - maxLength;
            truncatedQueue = queue.slice(startIndex, queue.length);
        }
        return truncatedQueue;
    }

    function pad(n, length) {
        var str = n.toString();
        if (str.length < length) {
            var padding = [];
            padding.length = length - str.length + 1;
            str = padding.join('0') + str;
        }
        return str;
    }

    function extend(dest, from) {
        var props = Object.getOwnPropertyNames(from);

        props.forEach(function (name) {
            if (typeof from[name] === 'object') {
                if (typeof dest[name] !== 'object') {
                    dest[name] = {};
                }
                extend(dest[name], from[name]);
            } else {
                var destination = Object.getOwnPropertyDescriptor(from, name);
                Object.defineProperty(dest, name, destination);
            }
        });

        return dest;
    }

    function consumeStream(stream) {
        var noOp = function () {return; };
        stream.on('data', noOp);
        stream.on('end', noOp);
    }

    function notifyCallbacks(callbacks, err, numSentEvents) {
        callbacks.forEach(function (callback) {
            // Call each callback in a timeout, so if there's an exception in one callback it doesn't affect any others
            setTimeout(function () {
                callback(err, numSentEvents);
            });
        });
    }


    /**
        Constants
    **/

    var LOG_PREFIX = 'Ingest :: ';
    var ANALYTICS_HOST = {
        prod:   'cc-api-data.adobe.io',
        stage:  'cc-api-data-stage.adobe.io',
        dev:    'cc-api-data-dev.adobe.io'
    };
    var INGEST_PATH = '/ingest';
    var RETRY_RANDOM_SECONDS = 10;

    // Settable options, with their default values
    var DEFAULT_OPTIONS = {
        ENVIRONMENT: 'prod',
        ALLOW_NO_TOKEN: false,
        ANALYTICS_INGEST_TYPE: 'dunamis',
        ANALYTICS_MAX_QUEUED_EVENTS: 50,
        ANALYTICS_DEBOUNCE: 10000,
        ANALYTICS_API_KEY: null,
        ANALYTICS_X_PRODUCT: null,
        ANALYTICS_X_PRODUCT_LOCATION: undefined,
        ANALYTICS_PROJECT: null,
        ANALYTICS_USER_REGION: 'UNKNOWN',
        TIMESTAMP_PROPERTY_NAME: 'event.dts_end'
    };
    var REQUIRED_OPTIONS = [
        'ANALYTICS_API_KEY',
        'ANALYTICS_X_PRODUCT',
        'ANALYTICS_PROJECT',
    ];


    /**
        Ingest Class
    **/

    // Constructor
    function Ingest(dependencies, options) {
        var that = this;
        dependencies = dependencies || {};
        options = options || {};

        var throwError = function (message) {
            that._log(message);
            throw new Error('ERROR: ' + message);
        };

        // Internal state
        this._queuedEvents = [];
        this._queuedCallbacks = [];
        this._lastSendTime = 0;
        this._isEnabled = false; // Sending analytics is disabled by default

        // Configure dependencies
        this._dependencies = extend({}, dependencies);
        if (!dependencies.getAccessToken || typeof dependencies.getAccessToken !== 'function') {
            throwError('Missing dependency: getAccessToken');
        }

        // Configure options
        this._options = {};
        Object.keys(DEFAULT_OPTIONS).forEach(function (key) {
            this._options[key] = options[key] || DEFAULT_OPTIONS[key];
        }, this);

        // Make sure required options have been passed in
        REQUIRED_OPTIONS.forEach(function (option) {
            if (!this._options[option]) {
                throwError('Missing option: ' + option);
            }
        }, this);
    }

    Ingest.prototype._log = function (message) {
        var doLog = this._dependencies.log || console.log.bind(console);
        doLog(LOG_PREFIX + message);
    };

    Ingest.prototype._getAgent = function (url, callback) {
        if (this._dependencies.getAgent) {
            this._dependencies.getAgent(url, callback);
            return;
        }
        callback(null, {});
    };

    Ingest.prototype._getAccessToken = function (callback) {
        this._dependencies.getAccessToken(callback);
    };

    Ingest.prototype._clearAccessToken = function (callback) {
        if (this._dependencies.clearAccessToken) {
            this._dependencies.clearAccessToken();
        }
    };

    Ingest.prototype._getEnvironment = function () {
        return ANALYTICS_HOST[this._options.ENVIRONMENT] ? this._options.ENVIRONMENT : 'prod';
    };

    Ingest.prototype._getAnalyticsHost = function () {
        return ANALYTICS_HOST[this._getEnvironment()];
    };

    Ingest.prototype._formatTimestamp = function (date) {
        // Corresponds to moment format string 'YYYY-MM-DDTHH:mm:ss.SSSZZ'
        var YYYY = date.getFullYear();
        var MM = pad(date.getMonth() + 1, 2); // Month is 0-11
        var DD = pad(date.getDate(), 2);
        var HH = pad(date.getHours(), 2);
        var mm = pad(date.getMinutes(), 2);
        var ss = pad(date.getSeconds(), 2);
        var SSS = pad(date.getMilliseconds(), 3);

        var offset = date.getTimezoneOffset();
        var sign = offset < 0 ? '+' : '-'; // Sign is inverted
        var hours = Math.floor(Math.abs(offset) / 60);
        var mins = Math.abs(offset) % 60;
        var ZZ = sign + pad(hours, 2) + pad(mins, 2);

        return YYYY + '-' + MM + '-' + DD + 'T' + HH + ':' + mm + ':' + ss + '.' + SSS + ZZ;
    };

    Ingest.prototype._updateDebounce = function (headers) {
        var retryAfterHeader = headers && (headers['retry-after'] || headers['Retry-After']);
        var retryAfter = 0;

        if (retryAfterHeader) {
            var retryTime;
            try {
                // First, try to parse it as a number (retry time in seconds)
                retryTime = parseInt(retryAfterHeader, 10);
            } catch (ignore) {
            }

            if (retryTime) {
                retryAfter = Math.max(0, retryTime);
            } else {
                // If that fails, try to parse it as a date
                var retryDate = Date.parse(retryAfterHeader);
                if (retryDate) {
                    // Need to add a randomised element to ensure requests don't all come back at the same time
                    var now = new Date().valueOf();
                    var retrySeconds = Math.max(0, retryDate - now) / 1000;
                    var retryRandom = Math.floor(Math.random() * RETRY_RANDOM_SECONDS);
                    retryAfter = retrySeconds + retryRandom;
                }
            }
        }

        this._options.ANALYTICS_DEBOUNCE = Math.max(retryAfter * 1000, this._options.ANALYTICS_DEBOUNCE);
    };

    Ingest.prototype._queueEvent = function (event) {
        if (this._queuedEvents.length >= this._options.ANALYTICS_MAX_QUEUED_EVENTS) {
            this._queuedEvents.shift();
        }
        this._queuedEvents.push(event);
    };

    Ingest.prototype._requeueEvents = function (failedEvents) {
        // If we failed sending events, add them back to the beginning of the queue - but make sure it doesn't go over the maximum length
        this._queuedEvents = failedEvents.concat(this._queuedEvents);
        this._queuedEvents = truncateEventQueue(this._queuedEvents, this._options.ANALYTICS_MAX_QUEUED_EVENTS);
    };

    Ingest.prototype._sendAnalytics = function (sendImmediately, callback, retryAttemps) {
        var that = this;
        retryAttemps = retryAttemps || 0;

        if (callback) {
            this._queuedCallbacks.push(callback);
        }

        if (!this._isEnabled || this._queuedEvents.length === 0) {
            var callbacks = this._queuedCallbacks;
            this._queuedCallbacks = [];
            if (!this._isEnabled) {
                notifyCallbacks(callbacks, new Error('Analytics Disabled'));
            } else {
                notifyCallbacks(callbacks, null, 0);
            }
            return;
        }
        var debounce = this._options.ANALYTICS_DEBOUNCE;

        if (sendImmediately) {
            // Clear any timeout, and set the debounce to zero, to force an immediate send
            debounce = 0;
            clearTimeout(this._pendingSendAnalyticsTimeout);
            this._pendingSendAnalyticsTimeout = undefined;
        }

        if (this._sendingEvents || this._pendingSendAnalyticsTimeout) {
            this._log('Queued ' + this._queuedEvents.length + ' events to be sent.');
            // We're in the middle of sending analytics already
            // This will automatically kick off another send afterwards, so no need to do anything
            return;
        }

        var currentTime = new Date().valueOf();
        if (currentTime - this._lastSendTime < debounce) {
            // Throttle analytics, so we don't send too often - this allows us to batch up analytics
            this._pendingSendAnalyticsTimeout = setTimeout(function () {
                that._pendingSendAnalyticsTimeout = undefined;
                that._sendAnalytics();
            }, debounce);
            return;
        }

        this._lastSendTime = currentTime;
        // The queued events are now going to be sent
        this._sendingEvents = this._queuedEvents;
        this._sendingCallbacks = this._queuedCallbacks;
        this._queuedEvents = [];
        this._queuedCallbacks = [];

        var requestId = generateGUID();
        var logPrefix = '[' + requestId + '] ';
        var ingestData = {
            events: this._sendingEvents
        };

        // This gets called when finished, whether we got a response or failed with an error
        var onFinished = function (err) {
            var numNewEvents = that._queuedEvents.length;
            var numSentEvents = that._sendingEvents.length;
            if (err) {
                that._requeueEvents(that._sendingEvents);
                that._log(logPrefix + 'Error sending ' + numSentEvents + ' events: ' + err);
            } else {
                that._log(logPrefix + 'Success sending ' + numSentEvents + ' events: ' + JSON.stringify(that._sendingEvents));
            }
            delete that._sendingEvents;

            var sendingCallbacks = that._sendingCallbacks;
            delete that._sendingCallbacks;
            if (err) {
                notifyCallbacks(sendingCallbacks, err);
            } else {
                notifyCallbacks(sendingCallbacks, null, numSentEvents);
            }

            // If there were any new events while sending the last batch, trigger another send.
            // Note: This doesn't auto-trigger a retry if we failed, and there were no new events.
            if (numNewEvents > 0) {
                that._sendAnalytics();
            }
        };

        // This gets called when we get an actual response from the server
        var handleResponse = function (statusCode, headers) {
            that._updateDebounce(headers);

            if (statusCode === 401 && retryAttemps === 0) {
                that._clearAccessToken();

                that._requeueEvents(that._sendingEvents);
                delete that._sendingEvents;

                that._queuedCallbacks = that._sendingCallbacks.concat(that._queuedCallbacks);
                delete that._sendingCallbacks;

                // Retry one more time
                that._log(logPrefix + 'Access token is expired. Retry one more time.');
                that._sendAnalytics(true, undefined, retryAttemps + 1);
                return;
            }
            if (statusCode !== 200) {
                onFinished(new Error('Unexpected Response: ' + statusCode));
                return;
            }
            onFinished();
        };

        this._getAccessToken(function (err, token) {
            if (err) {
                onFinished(err);
                return;
            }
            if ((!token || token.length === 0) && !that._options.ALLOW_NO_TOKEN) {
                onFinished(new Error('No access token'));
                return;
            }

            var urlBase = 'https://' + that._getAnalyticsHost();
            that._log(logPrefix + 'Sending analytics to ' + urlBase + INGEST_PATH);
            if (isNodeJS()) {
                var reqHeaders = {
                    'x-api-key'         : that._options.ANALYTICS_API_KEY,
                    'X-Product'         : that._options.ANALYTICS_X_PRODUCT,
                    'X-Request-Id'      : requestId,
                    'Content-Type'      : 'application/json'
                };
                if (token) {
                    reqHeaders.Authorization = 'Bearer ' + token;
                }
                var requestOptions = {
                    hostname: that._getAnalyticsHost(),
                    port: 443,
                    path: INGEST_PATH,
                    method: 'POST',
                    headers: reqHeaders
                };
                if (that._options.ANALYTICS_X_PRODUCT_LOCATION) {
                    requestOptions.headers['X-Product-Location'] = that._options.ANALYTICS_X_PRODUCT_LOCATION;
                }

                that._getAgent(urlBase, function (err, proxyOptions) {
                    if (proxyOptions && proxyOptions.agent) {
                        requestOptions.agent = proxyOptions && proxyOptions.agent;
                    } else {
                        extend(requestOptions, proxyOptions || {});
                    }

                    var https = nodeRequire('https');
                    var req = https.request(requestOptions, function (response) {
                        if (response) {
                            // Need to consume the response data, otherwise the connection resource will never be released.
                            consumeStream(response);
                        }
                        var statusCode = response && response.statusCode;
                        var headers = response && response.headers;
                        handleResponse(statusCode, headers);
                    });
                    // Use req.once, rather than req.on (avoids hanging onto the resource in case of error)
                    req.once('error', function (err) {
                        onFinished(err);
                    });

                    req.end(JSON.stringify(ingestData));
                });

            } else {
                // Browser
                var requestBody = JSON.stringify(ingestData);
                var sendIngestRequest = function (body) {
                    // Send the request
                    var xhr = new XMLHttpRequest();
                    xhr.onreadystatechange = function () {
                        if (this.readyState === 4) {
                            var headers = xhr.getAllResponseHeaders();
                            handleResponse(this.status, headers);
                        }
                    };
                    xhr.onerror = function (error) {
                        onFinished(new Error(error));
                    };
                    xhr.open('POST', urlBase + INGEST_PATH, true);
                    xhr.setRequestHeader('x-api-key',           that._options.ANALYTICS_API_KEY);
                    xhr.setRequestHeader('x-user-region',       that._options.ANALYTICS_USER_REGION);
                    xhr.setRequestHeader('Content-Type',        'application/json');
                    if (token) {
                        xhr.setRequestHeader('Authorization',       'Bearer ' + token);
                    }
                    xhr.setRequestHeader('X-Product',           that._options.ANALYTICS_X_PRODUCT);
                    xhr.setRequestHeader('X-Product-Location',  that._options.ANALYTICS_X_PRODUCT_LOCATION);
                    xhr.setRequestHeader('X-Request-Id',        requestId);
                    xhr.send(body);
                };
                sendIngestRequest(requestBody);
            }
        });
    };


    /**
        Public APIs
    **/

    /**
     * Configure whether analytics is enabled or not. Note: By default, analytics are disabled, so you need to
     * explicitly call `ingest.enable(true)` to enable sending analytics.
     *
     * When sending analytics is disabled, events are still queued up, so they can be sent when it's reenabled.
     *
     * @param {Boolean} isEnabled Whether to enable or disable sending analytics.
     *
     * @memberof Ingest
     */
    Ingest.prototype.enable = function (isEnabled) {
        this._isEnabled = isEnabled;
        if (isEnabled) {
            // If we enable analytics, trigger flushing any queued events
            this._sendAnalytics(true);
        }
    };

    /**
     * Post an analytics event to ingest.
     *
     * @param {Object} payload Ingest payload to be sent
     * @param {Function} [callback] If supplied, called when the event has been posted (or failed)
     *
     * @memberof Ingest
     */
    Ingest.prototype.postEvent = function (payload, callback) {
        var timestamp = this._formatTimestamp(new Date());
        payload[this._options.TIMESTAMP_PROPERTY_NAME] = timestamp;

        var event = {
            time: timestamp,
            project: this._options.ANALYTICS_PROJECT,
            environment: this._getEnvironment(),
            ingesttype: this._options.ANALYTICS_INGEST_TYPE,
            data: payload
        };
        this._queueEvent(event);
        this._sendAnalytics(false, callback);
    };

    /**
     * Flush the analytics (trigger sending any queued analytics to the server)
     *
     * @param {Boolean} immediate Flush event immediately or not
     * @param {Function} [callback] If supplied, called when the flush has finished (or failed)
     *
     * @memberof Ingest
     */
    Ingest.prototype.flush = function (sendImmediately, callback) {
        this._sendAnalytics(sendImmediately, callback);
    };

    return Ingest;
});
