/**
 * DataSet
 *
 * Usage:
 *     var dataSet = new DataSet({
 *         fieldId: '_id',
 *         fieldTypes: {
 *             // ...
 *         }
 *     });
 *
 *     dataSet.add(item);
 *     dataSet.add(data);
 *     dataSet.update(item);
 *     dataSet.update(data);
 *     dataSet.remove(id);
 *     dataSet.remove(ids);
 *     var data = dataSet.get();
 *     var data = dataSet.get(id);
 *     var data = dataSet.get(ids);
 *     var data = dataSet.get(ids, options, data);
 *     dataSet.clear();
 *
 * A data set can:
 * - add/remove/update data
 * - gives triggers upon changes in the data
 * - can  import/export data in various data formats
 * @param {Object} [options]   Available options:
 *                             {String} fieldId Field name of the id in the
 *                                              items, 'id' by default.
 *                             {Object.<String, String} fieldTypes
 *                                              A map with field names as key,
 *                                              and the field type as value.
 */
function DataSet (options) {
    var me = this;

    this.options = options || {};
    this.data = {};                                 // map with data indexed by id
    this.fieldId = this.options.fieldId || 'id';    // name of the field containing id
    this.fieldTypes = {};                           // field types by field name

    if (this.options.fieldTypes) {
        util.forEach(this.options.fieldTypes, function (value, field) {
            if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') {
                me.fieldTypes[field] = 'Date';
            }
            else {
                me.fieldTypes[field] = value;
            }
        });
    }

    // event subscribers
    this.subscribers = {};

    this.internalIds = {};            // internally generated id's
}

/**
 * Subscribe to an event, add an event listener
 * @param {String} event        Event name. Available events: 'put', 'update',
 *                              'remove'
 * @param {function} callback   Callback method. Called with three parameters:
 *                                  {String} event
 *                                  {Object | null} params
 *                                  {String} senderId
 * @param {String} [id]         Optional id for the sender, used to filter
 *                              events triggered by the sender itself.
 */
DataSet.prototype.subscribe = function (event, callback, id) {
    var subscribers = this.subscribers[event];
    if (!subscribers) {
        subscribers = [];
        this.subscribers[event] = subscribers;
    }

    subscribers.push({
        id: id ? String(id) : null,
        callback: callback
    });
};

/**
 * Unsubscribe from an event, remove an event listener
 * @param {String} event
 * @param {function} callback
 */
DataSet.prototype.unsubscribe = function (event, callback) {
    var subscribers = this.subscribers[event];
    if (subscribers) {
        this.subscribers[event] = subscribers.filter(function (listener) {
            return (listener.callback != callback);
        });
    }
};

/**
 * Trigger an event
 * @param {String} event
 * @param {Object | null} params
 * @param {String} [senderId]       Optional id of the sender. The event will
 *                                  be triggered for all subscribers except the
 *                                  sender itself.
 * @private
 */
DataSet.prototype._trigger = function (event, params, senderId) {
    if (event == '*') {
        throw new Error('Cannot trigger event *');
    }

    var subscribers = [];
    if (event in this.subscribers) {
        subscribers = subscribers.concat(this.subscribers[event]);
    }
    if ('*' in this.subscribers) {
        subscribers = subscribers.concat(this.subscribers['*']);
    }

    subscribers.forEach(function (listener) {
        if (listener.id != senderId && listener.callback) {
            listener.callback(event, params, senderId || null);
        }
    });
};

/**
 * Add data. Existing items with the same id will be overwritten.
 * @param {Object | Array | DataTable} data
 * @param {String} [senderId] Optional sender id, used to trigger events for
 *                            all but this sender's event subscribers.
 */
DataSet.prototype.add = function (data, senderId) {
    var items = [],
        id,
        me = this;

    if (data instanceof Array) {
        // Array
        data.forEach(function (item) {
            var id = me._addItem(item);
            items.push(id);
        });
    }
    else if (util.isDataTable(data)) {
        // Google DataTable
        var columns = this._getColumnNames(data);
        for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) {
            var item = {};
            columns.forEach(function (field, col) {
                item[field] = data.getValue(row, col);
            });
            id = me._addItem(item);
            items.push(id);
        }
    }
    else if (data instanceof Object) {
        // Single item
        id = me._addItem(data);
        items.push(id);
    }
    else {
        throw new Error('Unknown dataType');
    }

    this._trigger('add', {items: items}, senderId);
};

/**
 * Update existing items. Items with the same id will be merged
 * @param {Object | Array | DataTable} data
 * @param {String} [senderId] Optional sender id, used to trigger events for
 *                            all but this sender's event subscribers.
 */
DataSet.prototype.update = function (data, senderId) {
    var items = [],
        id,
        me = this;

    if (data instanceof Array) {
        // Array
        data.forEach(function (item) {
            var id = me._updateItem(item);
            items.push(id);
        });
    }
    else if (util.isDataTable(data)) {
        // Google DataTable
        var columns = this._getColumnNames(data);
        for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) {
            var item = {};
            columns.forEach(function (field, col) {
                item[field] = data.getValue(row, col);
            });
            id = me._updateItem(item);
            items.push(id);
        }
    }
    else if (data instanceof Object) {
        // Single item
        id = me._updateItem(data);
        items.push(id);
    }
    else {
        throw new Error('Unknown dataType');
    }

    this._trigger('update', {items: items}, senderId);
};

/**
 * Get a data item or multiple items
 * @param {String | Number | Array | Object} [ids]   Id of a single item, or an
 *                                          array with multiple id's, or
 *                                          undefined or an Object with options
 *                                          to retrieve all data.
 * @param {Object} [options]                Available options:
 *                                          {String} [type]
 *                                              'DataTable' or 'Array' (default)
 *                                          {Object.<String, String>} [fieldTypes]
 *                                          {String[]} [fields]  filter fields
 * @param {Array | DataTable} [data]        If provided, items will be appended
 *                                          to this array or table. Required
 *                                          in case of Google DataTable
 * @return {Array | Object | DataTable | null} data
 * @throws Error
 */
DataSet.prototype.get = function (ids, options, data) {
    var me = this;

    // shift arguments when first argument contains the options
    if (util.getType(ids) == 'Object') {
        data = options;
        options = ids;
        ids = undefined;
    }

    // merge field types
    var fieldTypes = {};
    if (this.options && this.options.fieldTypes) {
        util.forEach(this.options.fieldTypes, function (value, field) {
            fieldTypes[field] = value;
        });
    }
    if (options && options.fieldTypes) {
        util.forEach(options.fieldTypes, function (value, field) {
            fieldTypes[field] = value;
        });
    }

    var fields = options ? options.fields : undefined;

    // determine the return type
    var type;
    if (options && options.type) {
        type = (options.type == 'DataTable') ? 'DataTable' : 'Array';

        if (data && (type != util.getType(data))) {
            throw new Error('Type of parameter "data" (' + util.getType(data) + ') ' +
                'does not correspond with specified options.type (' + options.type + ')');
        }
        if (type == 'DataTable' && !util.isDataTable(data)) {
            throw new Error('Parameter "data" must be a DataTable ' +
                'when options.type is "DataTable"');
        }
    }
    else if (data) {
        type = (util.getType(data) == 'DataTable') ? 'DataTable' : 'Array';
    }
    else {
        type = 'Array';
    }

    if (type == 'DataTable') {
        // return a Google DataTable
        var columns = this._getColumnNames(data);
        if (ids == undefined) {
            // return all data
            util.forEach(this.data, function (item) {
                me._appendRow(data, columns, me._castItem(item));
            });
        }
        else if (util.isNumber(ids) || util.isString(ids)) {
            var item = me._castItem(me.data[ids], fieldTypes, fields);
            this._appendRow(data, columns, item);
        }
        else if (ids instanceof Array) {
            ids.forEach(function (id) {
                var item = me._castItem(me.data[id], fieldTypes, fields);
                me._appendRow(data, columns, item);
            });
        }
        else {
            throw new TypeError('Parameter "ids" must be ' +
                'undefined, a String, Number, or Array');
        }
    }
    else {
        // return an array
        data = data || [];
        if (ids == undefined) {
            // return all data
            util.forEach(this.data, function (item) {
                data.push(me._castItem(item, fieldTypes, fields));
            });
        }
        else if (util.isNumber(ids) || util.isString(ids)) {
            // return a single item
            return this._castItem(me.data[ids], fieldTypes, fields);
        }
        else if (ids instanceof Array) {
            ids.forEach(function (id) {
                data.push(me._castItem(me.data[id], fieldTypes, fields));
            });
        }
        else {
            throw new TypeError('Parameter "ids" must be ' +
                'undefined, a String, Number, or Array');
        }
    }

    return data;
};

/**
 * Remove an object by pointer or by id
 * @param {String | Number | Object | Array} id   Object or id, or an array with
 *                                                objects or ids to be removed
 * @param {String} [senderId] Optional sender id, used to trigger events for
 *                            all but this sender's event subscribers.
 */
DataSet.prototype.remove = function (id, senderId) {
    var items = [],
        me = this;

    if (util.isNumber(id) || util.isString(id)) {
        delete this.data[id];
        delete this.internalIds[id];
        items.push(id);
    }
    else if (id instanceof Array) {
        id.forEach(function (id) {
            me.remove(id);
        });
        items = items.concat(id);
    }
    else if (id instanceof Object) {
        // search for the object
        for (var i in this.data) {
            if (this.data.hasOwnProperty(i)) {
                if (this.data[i] == id) {
                    delete this.data[i];
                    delete this.internalIds[i];
                    items.push(i);
                }
            }
        }
    }

    this._trigger('remove', {items: items}, senderId);
};

/**
 * Clear the data
 * @param {String} [senderId] Optional sender id, used to trigger events for
 *                            all but this sender's event subscribers.
 */
DataSet.prototype.clear = function (senderId) {
    var items = Object.keys(this.data);

    this.data = [];
    this.internalIds = {};

    this._trigger('remove', {items: items}, senderId);
};

/**
 * Add a single item
 * @param {Object} item
 * @return {String} id
 * @private
 */
DataSet.prototype._addItem = function (item) {
    var id = item[this.fieldId];
    if (id == undefined) {
        // generate an id
        id = util.randomUUID();
        item[this.fieldId] = id;

        this.internalIds[id] = item;
    }

    var d = {};
    for (var field in item) {
        if (item.hasOwnProperty(field)) {
            var type = this.fieldTypes[field];  // type may be undefined
            d[field] = util.cast(item[field], type);
        }
    }
    this.data[id] = d;
    //TODO: fail when an item with this id already exists?

    return id;
};

/**
 * Cast and filter the fields of an item
 * @param {Object | undefined} item
 * @param {Object.<String, String>} [fieldTypes]
 * @param {String[]} [fields]
 * @return {Object | null} castedItem
 * @private
 */
DataSet.prototype._castItem = function (item, fieldTypes, fields) {
    var clone,
        fieldId = this.fieldId,
        internalIds = this.internalIds;

    if (item) {
        clone = {};
        fieldTypes = fieldTypes || {};
        
        if (fields) {
            // output filtered fields
            util.forEach(item, function (value, field) {
                if (fields.indexOf(field) != -1) {
                    clone[field] = util.cast(value, fieldTypes[field]);
                }
            });
        }
        else {
            // output all fields, except internal ids
            util.forEach(item, function (value, field) {
                if (field != fieldId || !(value in internalIds)) {
                    clone[field] = util.cast(value, fieldTypes[field]);
                }
            });
        }
    }
    else {
        clone = null;
    }

    return clone;
};

/**
 * Update a single item: merge with existing item
 * @param {Object} item
 * @return {String} id
 * @private
 */
DataSet.prototype._updateItem = function (item) {
    var id = item[this.fieldId];
    if (id == undefined) {
        throw new Error('Item has no id (item: ' + JSON.stringify(item) + ')');
    }
    var d = this.data[id];
    if (d) {
        // merge with current item
        for (var field in item) {
            if (item.hasOwnProperty(field)) {
                var type = this.fieldTypes[field];  // type may be undefined
                d[field] = util.cast(item[field], type);
            }
        }
    }
    else {
        // create new item
        this._addItem(item);
    }

    return id;
};

/**
 * Get an array with the column names of a Google DataTable
 * @param {DataTable} dataTable
 * @return {Array} columnNames
 * @private
 */
DataSet.prototype._getColumnNames = function (dataTable) {
    var columns = [];
    for (var col = 0, cols = dataTable.getNumberOfColumns(); col < cols; col++) {
        columns[col] = dataTable.getColumnId(col) || dataTable.getColumnLabel(col);
    }
    return columns;
};

/**
 * Append an item as a row to the dataTable
 * @param dataTable
 * @param columns
 * @param item
 * @private
 */
DataSet.prototype._appendRow = function (dataTable, columns, item) {
    var row = dataTable.addRow();
    columns.forEach(function (field, col) {
        dataTable.setValue(row, col, item[field]);
    });
};