define(function (require) {
  return function DataFactory(Private) {
    var d3 = require('d3');
    var _ = require('lodash');

    var injectZeros = Private(require('ui/vislib/components/zero_injection/inject_zeros'));
    var orderKeys = Private(require('ui/vislib/components/zero_injection/ordered_x_keys'));
    var getLabels = Private(require('ui/vislib/components/labels/labels'));
    var color = Private(require('ui/vislib/components/color/color'));
    var errors = require('ui/errors');

    /**
     * Provides an API for pulling values off the data
     * and calculating values using the data
     *
     * @class Data
     * @constructor
     * @param data {Object} Elasticsearch query results
     * @param attr {Object|*} Visualization options
     */
    function Data(data, attr, uiState) {
      if (!(this instanceof Data)) {
        return new Data(data, attr, uiState);
      }

      this.uiState = uiState;

      var self = this;
      var offset;

      if (attr.mode === 'stacked') {
        offset = 'zero';
      } else if (attr.mode === 'percentage') {
        offset = 'expand';
      } else if (attr.mode === 'grouped') {
        offset = 'group';
      } else {
        offset = attr.mode;
      }

      this.data = data;
      this.type = this.getDataType();

      this.labels = this._getLabels(this.data);
      this.color = this.labels ? color(this.labels, uiState.get('vis.colors')) : undefined;
      this._normalizeOrdered();

      this._attr = _.defaults(attr || {}, {
        stack: d3.layout.stack()
          .x(function (d) { return d.x; })
          .y(function (d) {
            if (offset === 'expand') {
              return Math.abs(d.y);
            }
            return d.y;
          })
          .offset(offset || 'zero')
      });

      if (attr.mode === 'stacked' && attr.type === 'histogram') {
        this._attr.stack.out(function (d, y0, y) {
          return self._stackNegAndPosVals(d, y0, y);
        });
      }
    }

    Data.prototype._updateData = function () {
      if (this.data.rows) {
        _.map(this.data.rows, this._updateDataSeriesLabel, this);
      } else if (this.data.columns) {
        _.map(this.data.columns, this._updateDataSeriesLabel, this);
      } else {
        this._updateDataSeriesLabel(this.data);
      }
    };

    Data.prototype._updateDataSeriesLabel = function (eachData) {
      if (eachData.series) {
        eachData.series[0].label = this.get('yAxisLabel');
      }
    };

    Data.prototype._getLabels = function (data) {
      if (this.type === 'series') {
        var noLabel = getLabels(data).length === 1 && getLabels(data)[0] === '';
        if (noLabel) {
          this._updateData();
          return [(this.get('yAxisLabel'))];
        }
        return getLabels(data);
      }
      return this.pieNames();
    };

    /**
     * Returns true for positive numbers
     */
    Data.prototype._isPositive = function (num) {
      return num >= 0;
    };

    /**
     * Returns true for negative numbers
     */
    Data.prototype._isNegative = function (num) {
      return num < 0;
    };

    /**
     * Adds two input values
     */
    Data.prototype._addVals = function (a, b) {
      return a + b;
    };

    /**
     * Returns the results of the addition of numbers in a filtered array.
     */
    Data.prototype._sumYs = function (arr, callback) {
      var filteredArray = arr.filter(callback);

      return (filteredArray.length) ? filteredArray.reduce(this._addVals) : 0;
    };

    /**
     * Calculates the d.y0 value for stacked data in D3.
     */
    Data.prototype._calcYZero = function (y, arr) {
      if (y >= 0) return this._sumYs(arr, this._isPositive);
      return this._sumYs(arr, this._isNegative);
    };

    /**
     *
     */
    Data.prototype._getCounts = function (i, j) {
      var data = this.chartData();
      var dataLengths = {};

      dataLengths.charts = data.length;
      dataLengths.stacks = dataLengths.charts ? data[i].series.length : 0;
      dataLengths.values = dataLengths.stacks ? data[i].series[j].values.length : 0;

      return dataLengths;
    };

    /**
     *
     */
    Data.prototype._createCache = function () {
      var cache = {
        index: {
          chart: 0,
          stack: 0,
          value: 0
        },
        yValsArr: []
      };

      cache.count = this._getCounts(cache.index.chart, cache.index.stack);

      return cache;
    };

    /**
     * Stacking function passed to the D3 Stack Layout `.out` API.
     * See: https://github.com/mbostock/d3/wiki/Stack-Layout
     * It is responsible for calculating the correct d.y0 value for
     * mixed datasets containing both positive and negative values.
     */
    Data.prototype._stackNegAndPosVals = function (d, y0, y) {
      var data = this.chartData();

      // Storing counters and data characteristics needed to stack values properly
      if (!this._cache) {
        this._cache = this._createCache();
      }

      d.y0 = this._calcYZero(y, this._cache.yValsArr);
      ++this._cache.index.stack;


      // last stack, or last value, reset the stack count and y value array
      var lastStack = (this._cache.index.stack >= this._cache.count.stacks);
      if (lastStack) {
        this._cache.index.stack = 0;
        ++this._cache.index.value;
        this._cache.yValsArr = [];
      // still building the stack collection, push v value to array
      } else if (y !== 0) {
        this._cache.yValsArr.push(y);
      }

      // last value, prepare for the next chart, if one exists
      var lastValue = (this._cache.index.value >= this._cache.count.values);
      if (lastValue) {
        this._cache.index.value = 0;
        ++this._cache.index.chart;

        // no more charts, reset the queue and finish
        if (this._cache.index.chart >= this._cache.count.charts) {
          this._cache = this._createCache();
          return;
        }

        // get stack and value count for next chart
        var chartSeries = data[this._cache.index.chart].series;
        this._cache.count.stacks = chartSeries.length;
        this._cache.count.values = chartSeries.length ? chartSeries[this._cache.index.stack].values.length : 0;
      }
    };

    Data.prototype.getDataType = function () {
      var data = this.getVisData();
      var type;

      data.forEach(function (obj) {
        if (obj.series) {
          type = 'series';
        } else if (obj.slices) {
          type = 'slices';
        } else if (obj.geoJson) {
          type = 'geoJson';
        }
      });

      return type;
    };

    /**
     * Returns an array of the actual x and y data value objects
     * from data with series keys
     *
     * @method chartData
     * @returns {*} Array of data objects
     */
    Data.prototype.chartData = function () {
      if (!this.data.series) {
        var arr = this.data.rows ? this.data.rows : this.data.columns;
        return _.toArray(arr);
      }
      return [this.data];
    };

    /**
     * Returns an array of chart data objects
     *
     * @method getVisData
     * @returns {*} Array of chart data objects
     */
    Data.prototype.getVisData = function () {
      var visData;

      if (this.data.rows) {
        visData = this.data.rows;
      } else if (this.data.columns) {
        visData = this.data.columns;
      } else {
        visData = [this.data];
      }

      return visData;
    };

    /**
     * get min and max for all cols, rows of data
     *
     * @method getMaxMin
     * @return {Object}
     */
    Data.prototype.getGeoExtents = function () {
      var visData = this.getVisData();

      return _.reduce(_.pluck(visData, 'geoJson.properties'), function (minMax, props) {
        return {
          min: Math.min(props.min, minMax.min),
          max: Math.max(props.max, minMax.max)
        };
      }, { min: Infinity, max: -Infinity });
    };

    /**
     * Returns array of chart data objects for pie data objects
     *
     * @method pieData
     * @returns {*} Array of chart data objects
     */
    Data.prototype.pieData = function () {
      if (!this.data.slices) {
        return this.data.rows ? this.data.rows : this.data.columns;
      }
      return [this.data];
    };

    /**
     * Get attributes off the data, e.g. `tooltipFormatter` or `xAxisFormatter`
     * pulls the value off the first item in the array
     * these values are typically the same between data objects of the same chart
     * TODO: May need to verify this or refactor
     *
     * @method get
     * @param thing {String} Data object key
     * @returns {*} Data object value
     */
    Data.prototype.get = function (thing, def) {
      var source = (this.data.rows || this.data.columns || [this.data])[0];
      return _.get(source, thing, def);
    };

    /**
     * Returns true if null values are present
     * @returns {*}
     */
    Data.prototype.hasNullValues = function () {
      var chartData = this.chartData();

      return chartData.some(function (chart) {
        return chart.series.some(function (obj) {
          return obj.values.some(function (d) {
            return d.y === null;
          });
        });
      });
    };

    /**
     * Return an array of all value objects
     * Pluck the data.series array from each data object
     * Create an array of all the value objects from the series array
     *
     * @method flatten
     * @returns {Array} Value objects
     */
    Data.prototype.flatten = function () {
      return _(this.chartData())
      .pluck('series')
      .flattenDeep()
      .pluck('values')
      .flattenDeep()
      .value();
    };

    /**
     * Determines whether histogram charts should be stacked
     * TODO: need to make this more generic
     *
     * @method shouldBeStacked
     * @returns {boolean}
     */
    Data.prototype.shouldBeStacked = function () {
      var isHistogram = (this._attr.type === 'histogram');
      var isArea = (this._attr.type === 'area');
      var isOverlapping = (this._attr.mode === 'overlap');
      var grouped = (this._attr.mode === 'grouped');

      var stackedHisto = isHistogram && !grouped;
      var stackedArea = isArea && !isOverlapping;

      return stackedHisto || stackedArea;
    };

    /**
     * Validates that the Y axis min value defined by user input
     * is a number.
     *
     * @param val {Number} Y axis min value
     * @returns {Number} Y axis min value
     */
    Data.prototype.validateUserDefinedYMin = function (val) {
      if (!_.isNumber(val)) {
        throw new Error('validateUserDefinedYMin expects a number');
      }
      return val;
    };

    /**
     * Calculates the lowest Y value across all charts, taking
     * stacking into consideration.
     *
     * @method getYMin
     * @param {function} [getValue] - optional getter that will receive a
     *                              point and should return the value that should
     *                              be considered
     * @returns {Number} Min y axis value
     */
    Data.prototype.getYMin = function (getValue) {
      var self = this;
      var arr = [];

      if (this._attr.mode === 'percentage' || this._attr.mode === 'wiggle' ||
        this._attr.mode === 'silhouette') {
        return 0;
      }

      var flat = this.flatten();
      // if there is only one data point and its less than zero,
      // return 0 as the yMax value.
      if (!flat.length || flat.length === 1 && flat[0].y > 0) {
        return 0;
      }

      var min = Infinity;

      // for each object in the dataArray,
      // push the calculated y value to the initialized array (arr)
      _.each(this.chartData(), function (chart) {
        var calculatedMin = self._getYExtent(chart, 'min', getValue);
        if (!_.isUndefined(calculatedMin)) {
          min = Math.min(min, calculatedMin);
        }
      });

      return min;
    };

    /**
     * Calculates the highest Y value across all charts, taking
     * stacking into consideration.
     *
     * @method getYMax
     * @param {function} [getValue] - optional getter that will receive a
     *                              point and should return the value that should
     *                              be considered
     * @returns {Number} Max y axis value
     */
    Data.prototype.getYMax = function (getValue) {
      var self = this;
      var arr = [];

      if (self._attr.mode === 'percentage') {
        return 1;
      }

      var flat = this.flatten();
      // if there is only one data point and its less than zero,
      // return 0 as the yMax value.
      if (!flat.length || flat.length === 1 && flat[0].y < 0) {
        return 0;
      }

      var max = -Infinity;

      // for each object in the dataArray,
      // push the calculated y value to the initialized array (arr)
      _.each(this.chartData(), function (chart) {
        var calculatedMax = self._getYExtent(chart, 'max', getValue);
        if (!_.isUndefined(calculatedMax)) {
          max = Math.max(max, calculatedMax);
        }
      });

      return max;
    };

    /**
     * Calculates the stacked values for each data object
     *
     * @method stackData
     * @param series {Array} Array of data objects
     * @returns {*} Array of data objects with x, y, y0 keys
     */
    Data.prototype.stackData = function (series) {
      // Should not stack values on line chart
      if (this._attr.type === 'line') return series;
      return this._attr.stack(series);
    };

    /**
     * Returns the max Y axis value for a `series` array based on
     * a specified callback function (calculation).
     * @param {function} [getValue] - Optional getter that will be used to read
     *                              values from points when calculating the extent.
     *                              default is either this._getYStack or this.getY
     *                              based on this.shouldBeStacked().
     */
    Data.prototype._getYExtent = function (chart, extent, getValue) {
      if (this.shouldBeStacked()) {
        this.stackData(_.pluck(chart.series, 'values'));
        getValue = getValue || this._getYStack;
      } else {
        getValue = getValue || this._getY;
      }

      var points = chart.series
      .reduce(function (points, series) {
        return points.concat(series.values);
      }, [])
      .map(getValue);

      return d3[extent](points);
    };

    /**
     * Calculates the y stack value for each data object
     */
    Data.prototype._getYStack = function (d) {
      return d.y0 + d.y;
    };

    /**
     * Calculates the Y max value
     */
    Data.prototype._getY = function (d) {
      return d.y;
    };

    /**
     * Helper function for getNames
     * Returns an array of objects with a name (key) value and an index value.
     * The index value allows us to sort the names in the correct nested order.
     *
     * @method returnNames
     * @param array {Array} Array of data objects
     * @param index {Number} Number of times the object is nested
     * @param columns {Object} Contains name formatter information
     * @returns {Array} Array of labels (strings)
     */
    Data.prototype.returnNames = function (array, index, columns) {
      var names = [];
      var self = this;

      _.forEach(array, function (obj, i) {
        names.push({
          label: obj.name,
          values: obj,
          index: index
        });

        if (obj.children) {
          var plusIndex = index + 1;

          _.forEach(self.returnNames(obj.children, plusIndex, columns), function (namedObj) {
            names.push(namedObj);
          });
        }
      });

      return names;
    };

    /**
     * Flattens hierarchical data into an array of objects with a name and index value.
     * The indexed value determines the order of nesting in the data.
     * Returns an array with names sorted by the index value.
     *
     * @method getNames
     * @param data {Object} Chart data object
     * @param columns {Object} Contains formatter information
     * @returns {Array} Array of names (strings)
     */
    Data.prototype.getNames = function (data, columns) {
      var slices = data.slices;

      if (slices.children) {
        var namedObj = this.returnNames(slices.children, 0, columns);

        return _(namedObj)
        .sortBy(function (obj) {
          return obj.index;
        })
        .unique(function (d) {
          return d.label;
        })
        .value();
      }
    };

    /**
     * Removes zeros from pie chart data
     * @param slices
     * @returns {*}
     */
    Data.prototype._removeZeroSlices = function (slices) {
      var self = this;

      if (!slices.children) return slices;

      slices = _.clone(slices);
      slices.children = slices.children.reduce(function (children, child) {
        if (child.size !== 0) {
          children.push(self._removeZeroSlices(child));
        }
        return children;
      }, []);

      return slices;
    };

    /**
     * Returns an array of names ordered by appearance in the nested array
     * of objects
     *
     * @method pieNames
     * @returns {Array} Array of unique names (strings)
     */
    Data.prototype.pieNames = function (data) {
      var self = this;
      var names = [];

      _.forEach(data, function (obj) {
        var columns = obj.raw ? obj.raw.columns : undefined;
        obj.slices = self._removeZeroSlices(obj.slices);

        _.forEach(self.getNames(obj, columns), function (name) {
          names.push(name);
        });
      });

      return _.uniq(names, 'label');
    };

    /**
     * Inject zeros into the data
     *
     * @method injectZeros
     * @returns {Object} Data object with zeros injected
     */
    Data.prototype.injectZeros = function () {
      return injectZeros(this.data);
    };

    /**
     * Returns an array of all x axis values from the data
     *
     * @method xValues
     * @returns {Array} Array of x axis values
     */
    Data.prototype.xValues = function () {
      return orderKeys(this.data);
    };

    /**
     * Return an array of unique labels
     * Curently, only used for vertical bar and line charts,
     * or any data object with series values
     *
     * @method getLabels
     * @returns {Array} Array of labels (strings)
     */
    Data.prototype.getLabels = function () {
      return getLabels(this.data);
    };

    /**
     * Returns a function that does color lookup on labels
     *
     * @method getColorFunc
     * @returns {Function} Performs lookup on string and returns hex color
     */
    Data.prototype.getColorFunc = function () {
      return color(this.getLabels(), this.uiState.get('vis.colors'));
    };

    /**
     * Returns a function that does color lookup on names for pie charts
     *
     * @method getPieColorFunc
     * @returns {Function} Performs lookup on string and returns hex color
     */
    Data.prototype.getPieColorFunc = function () {
      return color(this.pieNames(this.getVisData()).map(function (d) {
        return d.label;
      }), this.uiState.get('vis.colors'));
    };

    /**
     * ensure that the datas ordered property has a min and max
     * if the data represents an ordered date range.
     *
     * @return {undefined}
     */
    Data.prototype._normalizeOrdered = function () {
      var data = this.getVisData();
      var self = this;

      data.forEach(function (d) {
        if (!d.ordered || !d.ordered.date) return;

        var missingMin = d.ordered.min == null;
        var missingMax = d.ordered.max == null;

        if (missingMax || missingMin) {
          var extent = d3.extent(self.xValues());
          if (missingMin) d.ordered.min = extent[0];
          if (missingMax) d.ordered.max = extent[1];
        }
      });
    };

    /**
     * Calculates min and max values for all map data
     * series.rows is an array of arrays
     * each row is an array of values
     * last value in row array is bucket count
     *
     * @method mapDataExtents
     * @param series {Array} Array of data objects
     * @returns {Array} min and max values
     */
    Data.prototype.mapDataExtents = function (series) {
      var values;
      values = _.map(series.rows, function (row) {
        return row[row.length - 1];
      });
      var extents = [_.min(values), _.max(values)];
      return extents;
    };

    /**
     * Get the maximum number of series, considering each chart
     * individually.
     *
     * @return {number} - the largest number of series from all charts
     */
    Data.prototype.maxNumberOfSeries = function () {
      return this.chartData().reduce(function (max, chart) {
        return Math.max(max, chart.series.length);
      }, 0);
    };

    return Data;
  };
});
