/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2013 Center for History and New Media
                     George Mason University, Fairfax, Virginia, USA
                     http://zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
*/

/**
 * @property {String} (readOnly) objectType
 * @property {String} (readOnly) libraryKey
 * @property {String|false|undefined} parentKey - False if no parent, or undefined if not
 *                                                applicable (e.g. search objects)
 * @property {Integer|false|undefined} parentID - False if no parent, or undefined if not
 *                                                applicable (e.g. search objects)
 */

Zotero.DataObject = function () {
	let objectType = this._objectType;
	this._ObjectType = objectType[0].toUpperCase() + objectType.substr(1);
	this._objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
	this._ObjectTypePlural = this._objectTypePlural[0].toUpperCase() + this._objectTypePlural.substr(1);
	this._ObjectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
	
	this._id = null;
	this._libraryID = null;
	this._key = null;
	this._dateAdded = null;
	this._dateModified = null;
	this._version = null;
	this._synced = null;
	this._identified = false;
	this._parentID = null;
	this._parentKey = null;
	this._deleted = null;
	
	this._relations = [];
	
	// Set in dataObjects.js
	this._inCache = false;
	
	this._loaded = {};
	this._skipDataTypeLoad = {};
	this._markAllDataTypeLoadStates(false);
	
	this._clearChanged();
};

Zotero.DataObject.prototype._objectType = 'dataObject';
Zotero.DataObject.prototype._dataTypes = ['primaryData'];

Zotero.defineProperty(Zotero.DataObject.prototype, 'objectType', {
	get: function () { return this._objectType; }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'id', {
	get: function () { return this._id; }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'libraryID', {
	get: function () { return this._libraryID; }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'library', {
	get: function () {
		return Zotero.Libraries.get(this._libraryID);
	}
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'key', {
	get: function () { return this._key; }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'libraryKey', {
	get: function () { return this._libraryID + "/" + this._key; }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'parentKey', {
	get: function () { return this._getParentKey(); },
	set: function (v) { return this._setParentKey(v); }
});
Zotero.defineProperty(Zotero.DataObject.prototype, 'parentID', {
	get: function () { return this._getParentID(); },
	set: function (v) { return this._setParentID(v); }
});

Zotero.defineProperty(Zotero.DataObject.prototype, '_canHaveParent', {
	value: true
});

// Define boolean properties
for (let name of ['deleted']) {
	let prop = '_' + name;
	Zotero.defineProperty(Zotero.DataObject.prototype, name, {
			get: function () {
				if (!this.id) {
					return false;
				}
				var val = this._getLatestField(name);
				if (this[prop] !== null) {
					return this[prop];
				}
				this._requireData('primaryData');
			},
			set: function (val) {
				val = !!val;
				var oldVal = this._getLatestField(name);
				if (oldVal == val) {
					Zotero.debug(`Field '${name}' hasn't changed`);
					return;
				}
				Zotero.debug(`Field '${name}' has changed from '${oldVal}' to '${val}'`, 4);
				this._markFieldChange(name, val);
			}
	});
}

Zotero.defineProperty(Zotero.DataObject.prototype, 'ObjectsClass', {
	get: function () { return this._ObjectsClass; }
});


Zotero.DataObject.prototype._get = function (field) {
	if (field != 'id') this._disabledCheck();
	
	if (this['_' + field] !== null) {
		return this['_' + field];
	}
	if (field != 'libraryID' && field != 'key' && field != 'id') {
		this._requireData('primaryData');
	}
	return null;
}


Zotero.DataObject.prototype._set = function (field, value) {
	this._disabledCheck();
	
	if (field == 'id' || field == 'libraryID' || field == 'key') {
		return this._setIdentifier(field, value);
	}
	
	this._requireData('primaryData');
	
	switch (field) {
		case 'name':
			value = value.trim().normalize();
			break;
		
		case 'version':
			value = parseInt(value);
			break;
		
		case 'synced':
			value = !!value;
			break;
	}
	
	if (this['_' + field] != value || field == 'synced') {
		this._markFieldChange(field, this['_' + field]);
		if (!this._changed.primaryData) {
			this._changed.primaryData = {};
		}
		this._changed.primaryData[field] = true;
		
		switch (field) {
			default:
				this['_' + field] = value;
		}
	}
}


Zotero.DataObject.prototype._setIdentifier = function (field, value) {
	switch (field) {
	case 'id':
		value = Zotero.DataObjectUtilities.checkDataID(value);
		if (this._id) {
			if (value === this._id) {
				return;
			}
			throw new Error("ID cannot be changed");
		}
		if (this._key) {
			throw new Error("Cannot set id if key is already set");
		}
		break;
		
	case 'libraryID':
		value = Zotero.DataObjectUtilities.checkLibraryID(value);
		break;
		
	case 'key':
		if (this._libraryID === null) {
			throw new Error("libraryID must be set before key");
		}
		value = Zotero.DataObjectUtilities.checkKey(value);
		if (this._key) {
			if (value === this._key) {
				return;
			}
			throw new Error("Key cannot be changed");
		}
		if (this._id) {
			throw new Error("Cannot set key if id is already set");
		}
	}
	
	if (value === this['_' + field]) {
		return;
	}
	
	// If primary data is loaded, the only allowed identifier change is libraryID, and then only
	// for unidentified objects, and then only either if a libraryID isn't yet set (because
	// primary data gets marked as loaded when fields are set for new items, but some methods
	// (setCollections(), save()) automatically set the user library ID after that if none is
	// specified), or for searches (for the sake of the library switcher in the advanced search
	// window, though that could probably be rewritten)
	if (this._loaded.primaryData) {
		if (!(!this._identified && field == 'libraryID'
				&& (!this._libraryID || this._objectType == 'search'))) {
			throw new Error("Cannot change " + field + " after object is already loaded");
		}
	}
	
	if (field == 'id' || field == 'key') {
		this._identified = true;
	}
	
	this['_' + field] = value;
}


/**
 * Get the id of the parent object
 *
 * @return {Integer|false|undefined}  The id of the parent object, false if none, or undefined
 *                                      on object types to which it doesn't apply (e.g., searches)
 */
Zotero.DataObject.prototype._getParentID = function () {
	if (this._parentID !== null) {
		return this._parentID;
	}
	if (!this._parentKey) {
		if (this._objectType == 'search') {
			return undefined;
		}
		return false;
	}
	return this._parentID = this.ObjectsClass.getIDFromLibraryAndKey(this._libraryID, this._parentKey);
}


/**
 * Set the id of the parent object
 *
 * @param {Number|false} [id=false]
 * @return {Boolean} True if changed, false if stayed the same
 */
Zotero.DataObject.prototype._setParentID = function (id) {
	return this._setParentKey(
		id
		? this.ObjectsClass.getLibraryAndKeyFromID(Zotero.DataObjectUtilities.checkDataID(id)).key
		: false
	);
}


Zotero.DataObject.prototype._getParentKey = function () {
	if (!this._canHaveParent) {
		return undefined;
	}
	return this._parentKey ? this._parentKey : false
}

/**
 * Set the key of the parent object
 *
 * @param {String|false} [key=false]
 * @return {Boolean} True if changed, false if stayed the same
 */
Zotero.DataObject.prototype._setParentKey = function (key) {
	if (!this._canHaveParent) {
		throw new Error("Cannot set parent key for " + this._objectType);
	}
	
	key = Zotero.DataObjectUtilities.checkKey(key) || false;
	
	if (key === this._parentKey || (!this._parentKey && !key)) {
		return false;
	}
	this._markFieldChange('parentKey', this._parentKey);
	this._changed.parentKey = true;
	this._parentKey = key;
	this._parentID = null;
	return true;
}

//
// Relations
//
/**
 * Returns all relations of the object
 *
 * @return {Object} - Object with predicates as keys and arrays of values
 */
Zotero.DataObject.prototype.getRelations = function () {
	this._requireData('relations');
	
	var relations = {};
	for (let i=0; i<this._relations.length; i++) {
		let rel = this._relations[i];
		// Relations are stored internally as predicate-object pairs
		let p = rel[0];
		if (!relations[p]) {
			relations[p] = [];
		}
		relations[p].push(rel[1]);
	}
	return relations;
}


/**
 * Returns all relations of the object with a given predicate
 *
 * @return {String[]} - URIs linked to this object with the given predicate
 */
Zotero.DataObject.prototype.getRelationsByPredicate = function (predicate) {
	this._requireData('relations');
	
	if (!predicate) {
		throw new Error("Predicate not provided");
	}
	
	var relations = [];
	for (let i=0; i<this._relations.length; i++) {
		let rel = this._relations[i];
		// Relations are stored internally as predicate-object pairs
		let p = rel[0];
		if (p !== predicate) {
			continue;
		}
		relations.push(rel[1]);
	}
	return relations;
}


/**
 * @return {Boolean} - True if the relation has been queued, false if it already exists
 */
Zotero.DataObject.prototype.addRelation = function (predicate, object) {
	this._requireData('relations');
	
	if (!predicate) {
		throw new Error("Predicate not provided");
	}
	if (!object) {
		throw new Error("Object not provided");
	}
	
	for (let i = 0; i < this._relations.length; i++) {
		let rel = this._relations[i];
		if (rel[0] == predicate && rel[1] == object) {
			Zotero.debug("Relation " + predicate + " - " + object + " already exists for "
				+ this._objectType + " " + this.libraryKey);
			return false;
		}
	}
	
	this._markFieldChange('relations', this._relations);
	this._changed.relations = true;
	this._relations.push([predicate, object]);
	return true;
}


Zotero.DataObject.prototype.hasRelation = function (predicate, object) {
	this._requireData('relations');
	
	for (let i = 0; i < this._relations.length; i++) {
		let rel = this._relations[i];
		if (rel[0] == predicate && rel[1] == object) {
			return true
		}
	}
	return false;
}


Zotero.DataObject.prototype.removeRelation = function (predicate, object) {
	this._requireData('relations');
	
	for (let i = 0; i < this._relations.length; i++) {
		let rel = this._relations[i];
		if (rel[0] == predicate && rel[1] == object) {
			Zotero.debug("Removing relation " + predicate + " - " + object + " from "
				+ this._objectType + " " + this.libraryKey);
			this._markFieldChange('relations', this._relations);
			this._changed.relations = true;
			this._relations.splice(i, 1);
			return true;
		}
	}
	
	Zotero.debug("Relation " + predicate + " - " + object + " did not exist for "
		+ this._objectType + " " + this.libraryKey);
	return false;
}


/**
 * Updates the object's relations
 *
 * @param {Object} newRelations Object with predicates as keys and URI[] as values
 * @return {Boolean} True if changed, false if stayed the same
 */
Zotero.DataObject.prototype.setRelations = function (newRelations) {
	this._requireData('relations');
	
	if (typeof newRelations != 'object') {
		throw new Error(`Relations must be an object (${typeof newRelations} given)`);
	}
	
	var oldRelations = this._relations;
	
	// Limit predicates to letters and colons for now
	for (let p in newRelations) {
		if (!/^[a-z]+:[a-z]+$/i.test(p)) {
			throw new Error(`Invalid relation predicate '${p}'`);
		}
	}
	
	// Relations are stored internally as a flat array with individual predicate-object pairs,
	// so convert the incoming relations to that
	var newRelationsFlat = this.ObjectsClass.flattenRelations(newRelations);
	
	var changed = false;
	if (oldRelations.length != newRelationsFlat.length) {
		changed = true;
	}
	else {
		let sortFunc = function (a, b) {
			if (a[0] < b[0]) return -1;
			if (a[0] > b[0]) return 1;
			if (a[1] < b[1]) return -1;
			if (a[1] > b[1]) return 1;
			return 0;
		};
		oldRelations.sort(sortFunc);
		newRelationsFlat.sort(sortFunc);
		
		for (let i=0; i<oldRelations.length; i++) {
			if (oldRelations[i][0] != newRelationsFlat[i][0]
					|| oldRelations[i][1] != newRelationsFlat[i][1]) {
				changed = true;
				break;
			}
		}
	}
	
	if (!changed) {
		Zotero.debug("Relations have not changed for " + this._objectType + " " + this.libraryKey, 4);
		return false;
	}
	
	this._markFieldChange('relations', this._relations);
	this._changed.relations = true;
	this._relations = newRelationsFlat;
	return true;
}


/**
 * Return an object in the specified library equivalent to this object
 *
 * Use Zotero.Collection.getLinkedCollection() and Zotero.Item.getLinkedItem() instead of
 * calling this directly.
 *
 * @param {Integer} [libraryID]
 * @return {Promise<Zotero.DataObject|false>} Linked object, or false if not found
 */
Zotero.DataObject.prototype._getLinkedObject = async function (libraryID, bidirectional) {
	if (!libraryID) {
		throw new Error("libraryID not provided");
	}
	
	if (libraryID == this._libraryID) {
		throw new Error(this._ObjectType + " is already in library " + libraryID);
	}
	
	var predicate = Zotero.Relations.linkedObjectPredicate;
	var libraryObjectPrefix = Zotero.URI.getLibraryURI(libraryID)
		+ "/" + this._objectTypePlural + "/";
	
	// Try the relations with this as a subject
	var uris = this.getRelationsByPredicate(predicate);
	for (let i = 0; i < uris.length; i++) {
		let uri = uris[i];
		if (uri.startsWith(libraryObjectPrefix)) {
			let obj = await Zotero.URI['getURI' + this._ObjectType](uri);
			if (!obj) {
				Zotero.debug("Referenced linked " + this._objectType + " '" + uri + "' not found "
					+ "in Zotero." + this._ObjectType + "::getLinked" + this._ObjectType + "()", 2);
				continue;
			}
			// Ignore items in the trash
			if (obj.objectType == 'item' && obj.deleted) {
				continue;
			}
			return obj;
		}
	}
	
	// Then try relations with this as an object
	if (bidirectional) {
		var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this);
		let objects = await Zotero.Relations.getByPredicateAndObject(
			this._objectType, predicate, thisURI
		);
		for (let i = 0; i < objects.length; i++) {
			let obj = objects[i];
			if (obj.objectType != this._objectType) {
				Zotero.logError("Found linked object of different type "
					+ "(expected " + this._objectType + ", found " + obj.objectType + ")");
				continue;
			}
			if (obj.libraryID == libraryID) {
				// Ignore items in the trash
				if (obj.objectType == 'item' && obj.deleted) {
					continue;
				}
				return obj;
			}
		}
	}
	
	return false;
};


/**
 * Add a linked-item relation to a pair of objects
 *
 * A separate save() is not required.
 *
 * @param {Zotero.DataObject} object
 * @param {Promise<Boolean>}
 */
Zotero.DataObject.prototype._addLinkedObject = async function (object) {
	if (object.libraryID == this._libraryID) {
		throw new Error("Can't add linked " + this._objectType + " in same library");
	}
	
	var predicate = Zotero.Relations.linkedObjectPredicate;
	var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this);
	var objectURI = Zotero.URI['get' + this._ObjectType + 'URI'](object);
	
	var exists = this.hasRelation(predicate, objectURI);
	if (exists) {
		Zotero.debug(this._ObjectTypePlural + " " + this.libraryKey
			+ " and " + object.libraryKey + " are already linked");
		return false;
	}
	
	// If one of the items is a personal library, store relation with that. Otherwise, use
	// current item's library (which in calling code is the new, copied item, since that's what
	// the user definitely has access to).
	var userLibraryID = Zotero.Libraries.userLibraryID;
	if (this.libraryID == userLibraryID || object.libraryID != userLibraryID) {
		this.addRelation(predicate, objectURI);
		await this.save({
			skipDateModifiedUpdate: true,
			skipSelect: true
		});
	}
	else {
		object.addRelation(predicate, thisURI);
		await object.save({
			skipDateModifiedUpdate: true,
			skipSelect: true
		});
	}
	
	return true;
};


//
// Bulk data loading functions
//
// These are called by Zotero.DataObjects.prototype.loadDataType().
//
Zotero.DataObject.prototype.loadPrimaryData = async function (reload, failOnMissing) {
	if (this._loaded.primaryData && !reload) return;
	
	var id = this._id;
	var key = this._key;
	var libraryID = this._libraryID;
	
	if (!id && !key) {
		throw new Error('ID or key not set in Zotero.' + this._ObjectType + '.loadPrimaryData()');
	}
	
	var columns = [], join = [], where = [];
	var primaryFields = this.ObjectsClass.primaryFields;
	var idField = this.ObjectsClass.idColumn;
	for (let i=0; i<primaryFields.length; i++) {
		let field = primaryFields[i];
		// If field not already set
		if (field == idField || this['_' + field] === null || reload) {
			columns.push(this.ObjectsClass.getPrimaryDataSQLPart(field));
		}
	}
	if (!columns.length) {
		return;
	}
	
	// This should match Zotero.*.primaryDataSQL, but without
	// necessarily including all columns
	var sql = "SELECT " + columns.join(", ") + this.ObjectsClass.primaryDataSQLFrom;
	if (id) {
		sql += " AND O." + idField + "=? ";
		var params = id;
	}
	else {
		sql += " AND O.key=? AND O.libraryID=? ";
		var params = [key, libraryID];
	}
	sql += (where.length ? ' AND ' + where.join(' AND ') : '');
	var row = await Zotero.DB.rowQueryAsync(sql, params);
	
	if (!row) {
		if (failOnMissing) {
			throw new Error(this._ObjectType + " " + (id ? id : libraryID + "/" + key)
				+ " not found in Zotero." + this._ObjectType + ".loadPrimaryData()");
		}
		
		// If object doesn't exist, mark all data types as loaded
		this._markAllDataTypeLoadStates(true);
		
		return;
	}
	
	this.loadFromRow(row, reload);
};


/**
 * Reloads loaded, changed data
 *
 * @param {String[]} [dataTypes] - Data types to reload, or all loaded types if not provide
 * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally.
 *                                            This should be set to true for data that was
 *                                            changed externally (e.g., globally renamed tags).
 */
Zotero.DataObject.prototype.reload = async function (dataTypes, reloadUnchanged) {
	if (!this._id) {
		return;
	}
	
	if (!dataTypes) {
		dataTypes = Object.keys(this._loaded).filter(type => this._loaded[type]);
	}
	
	if (dataTypes && dataTypes.length) {
		for (let i=0; i<dataTypes.length; i++) {
			let dataType = dataTypes[i];
			if (!this._loaded[dataType] || this._skipDataTypeLoad[dataType]
					|| (!reloadUnchanged && !this._changed[dataType] && !this._dataTypesToReload.has(dataType))) {
				continue;
			}
			await this.loadDataType(dataType, true);
			this._dataTypesToReload.delete(dataType);
		}
	}
};

/**
 * Checks whether a given data type has been loaded
 *
 * @param {String} [dataType=primaryData] Data type to check
 * @throws {Zotero.DataObjects.UnloadedDataException} If not loaded, unless the
 *   data has not yet been "identified"
 */
Zotero.DataObject.prototype._requireData = function (dataType) {
	if (this._loaded[dataType] === undefined) {
		throw new Error(dataType + " is not a valid data type for " + this._ObjectType + " objects");
	}
	
	if (dataType != 'primaryData') {
		this._requireData('primaryData');
	}
	
	if (!this._identified) {
		this._loaded[dataType] = true;
	}
	else if (!this._loaded[dataType]) {
		throw new Zotero.Exception.UnloadedDataException(
			"'" + dataType + "' not loaded for " + this._objectType + " ("
				+ this._id + "/" + this._libraryID + "/" + this._key + ")",
			dataType
		);
	}
}


/**
 * Loads data for a given data type
 * @param {String} dataType
 * @param {Boolean} reload
 * @param {Promise}
 */
Zotero.DataObject.prototype.loadDataType = function (dataType, reload) {
	return this._ObjectsClass._loadDataTypeInLibrary(dataType, this.libraryID, [this.id]);
}

Zotero.DataObject.prototype.loadAllData = async function (reload) {
	for (let i=0; i<this._dataTypes.length; i++) {
		let type = this._dataTypes[i];
		if (!this._skipDataTypeLoad[type]) {
			await this.loadDataType(type, reload);
		}
	}
};

Zotero.DataObject.prototype._markAllDataTypeLoadStates = function (loaded) {
	for (let i = 0; i < this._dataTypes.length; i++) {
		this._loaded[this._dataTypes[i]] = loaded;
	}
}

Zotero.DataObject.prototype._hasFieldChanged = function (field) {
	return field in this._changedData;
};

Zotero.DataObject.prototype._getChangedField = function (field) {
	return this._changedData[field];
};

/**
 * Get either the unsaved value of a field or the saved value if unchanged since the last save
 */
Zotero.DataObject.prototype._getLatestField = function (field) {
	return this._changedData[field] !== undefined ? this._changedData[field] : this['_' + field];
};

/**
 * Get either the unsaved value of a field or the saved value if unchanged since the last save
 */
Zotero.DataObject.prototype._getLatestField = function (field) {
	return this._changedData[field] !== undefined ? this._changedData[field] : this['_' + field];
};

/**
 * Save old version of data that's being changed, to pass to the notifier
 * @param {String} field
 * @param {} value - Old value for old-style 'changed' fields, and new value for 'changedData' fields
 */
Zotero.DataObject.prototype._markFieldChange = function (field, value) {
	// New method (changedData)
	if (['deleted', 'tags'].includes(field) || field.startsWith('annotation')) {
		if (Array.isArray(value)) {
			this._changedData[field] = [...value];
		}
		else if (typeof value === 'object' && value !== null) {
			this._changedData[field] = Object.assign({}, value);
		}
		else {
			this._changedData[field] = value;
		}
		return;
	}
	
	// Only save if object already exists and field not already changed
	if (!this.id || this._previousData[field] !== undefined) {
		return;
	}
	if (Array.isArray(value)) {
		this._previousData[field] = [];
		Object.assign(this._previousData[field], value)
	}
	else {
		this._previousData[field] = value;
	}
}


Zotero.DataObject.prototype.hasChanged = function () {
	var changed = Object.keys(this._changed).filter(dataType => this._changed[dataType])
		.concat(Object.keys(this._changedData));
	if (changed.length == 1
			&& changed[0] == 'primaryData'
			&& Object.keys(this._changed.primaryData).length == 1
			&& this._changed.primaryData.synced
			&& this._previousData.synced == this._synced) {
		return false;
	}
	return !!changed.length;
}


/**
 * Clears log of changed values
 * @param {String} [dataType] data type/field to clear. Defaults to clearing everything
 */
Zotero.DataObject.prototype._clearChanged = function (dataType) {
	if (dataType) {
		delete this._changed[dataType];
		
		// Unlike _changed, which has primary/item data under .primaryData/.itemData properties,
		// _previousData has individual top-level fields regardless of category, so when clearing
		// 'primaryData'/'itemData', check whether each stored field is a primary data or item field
		// and clear if so
		switch (dataType) {
			case 'primaryData':
			case 'itemData':
				let toDelete = [];
				for (let field of Object.keys(this._previousData)) {
					if (dataType == 'primaryData') {
						if (this.ObjectsClass.isPrimaryField(field)) {
							toDelete.push(field);
						}
					}
					else if (dataType == 'itemData') {
						if (Zotero.ItemFields.getID(field)) {
							toDelete.push(field);
						}
					}
				}
				for (let field of toDelete) {
					delete this._previousData[field];
				}
				break;
			
			default:
				delete this._previousData[dataType];
		}
		
		delete this._changedData[dataType];
	}
	else {
		this._changed = {};
		this._previousData = {};
		this._changedData = {};
		this._dataTypesToReload = new Set();
	}
}

/**
 * Clears field change log
 * @param {String} field
 */
Zotero.DataObject.prototype._clearFieldChange = function (field) {
	delete this._previousData[field];
	delete this._changedData[field];
}


/**
 * Mark a data type as requiring a reload when the current save finishes. The changed state is cleared
 * before the new data is saved to the database (so that further updates during the save process don't
 * get lost), so we need to separately keep track of what changed.
 */
Zotero.DataObject.prototype._markForReload = function (dataType) {
	this._dataTypesToReload.add(dataType);
}


/**
 * @param {String} [op='edit'] - Operation to check; if not provided, check edit privileges for
 *     library
 */
Zotero.DataObject.prototype.isEditable = function (_op = 'edit') {
	let library = Zotero.Libraries.get(this.libraryID);
	if (library.libraryType == 'feed') {
		return true;
	}
	return library.editable;
};

/**
 * Save changes to database
 *
 * @param {Object} [options]
 * @param {Boolean} [options.skipCache] - Don't save add new object to the cache; if set, object
 *                                         is disabled after save
 * @param {Boolean} [options.skipDateModifiedUpdate]
 * @param {Boolean} [options.skipClientDateModifiedUpdate]
 * @param {Boolean} [options.skipNotifier] - Don't trigger Zotero.Notifier events
 * @param {Boolean} [options.skipSelect] - Don't select object automatically in trees
 * @param {Boolean} [options.skipSyncedUpdate] - Don't automatically set 'synced' to false
 * @return {Promise<Integer|Boolean>}  Promise for itemID of new item,
 *                                     TRUE on item update, or FALSE if item was unchanged
 */
Zotero.DataObject.prototype.save = async function (options = {}) {
	var env = {
		options: Object.assign({}, options),
		transactionOptions: {}
	};
	
	if (!env.options.tx && !Zotero.DB.inTransaction()) {
		Zotero.logError("save() called on Zotero." + this._ObjectType + " without a wrapping "
			+ "transaction -- use saveTx() instead");
		Zotero.debug((new Error).stack, 2);
		env.options.tx = true;
	}
	
	if (env.options.skipAll) {
		[
			'skipDateModifiedUpdate',
			'skipClientDateModifiedUpdate',
			'skipSyncedUpdate',
			'skipEditCheck',
			'skipNotifier',
			'skipSelect'
		].forEach(x => env.options[x] = true);
	}
	
	var proceed = await this._initSave(env);
	if (!proceed) return false;
	
	if (env.isNew) {
		Zotero.debug('Saving data for new ' + this._objectType + ' to database', 4);
	}
	else {
		Zotero.debug('Updating database with new ' + this._objectType + ' data', 4);
	}
	
	try {
		if (Zotero.DataObject.prototype._finalizeSave == this._finalizeSave) {
			throw new Error("_finalizeSave not implemented for Zotero." + this._ObjectType);
		}
		
		env.notifierData = {};
		// Pass along any 'notifierData' values, which become 'extraData' in notifier events
		if (env.options.notifierData) {
			Object.assign(env.notifierData, env.options.notifierData);
		}
		if (env.options.skipSelect) {
			env.notifierData.skipSelect = true;
		}
		// Pass along event-level notifier options, which become top-level extraData properties
		for (let option of Zotero.Notifier.EVENT_LEVEL_OPTIONS) {
			if (env.options[option] !== undefined) {
				env.notifierData[option] = env.options[option];
			}
		}
		if (!env.isNew) {
			env.notifierData.changed = Object.assign({}, this._previousData);
			for (let field in this._changedData) {
				env.notifierData.changed[field] = this['_' + field];
			}
		}
		
		// Create transaction
		let result
		if (env.options.tx) {
			result = await Zotero.DB.executeTransaction(async function () {
				Zotero.DataObject.prototype._saveData.call(this, env);
				await this._saveData(env);
				await Zotero.DataObject.prototype._finalizeSave.call(this, env);
				return this._finalizeSave(env);
			}.bind(this), env.transactionOptions);
		}
		// Use existing transaction
		else {
			Zotero.DB.requireTransaction();
			Zotero.DataObject.prototype._saveData.call(this, env);
			await this._saveData(env);
			await Zotero.DataObject.prototype._finalizeSave.call(this, env);
			result = this._finalizeSave(env);
		}
		this._postSave(env);
		return result;
	}
	catch(e) {
		return this._recoverFromSaveError(env, e)
		.catch(function (e2) {
			Zotero.debug(e2, 1);
		})
		.then(function () {
			if (env.options.errorHandler) {
				env.options.errorHandler(e);
			}
			else {
				Zotero.logError(e);
			}
			throw e;
		})
	}
};


Zotero.DataObject.prototype.saveTx = function (options = {}) {
	options = Object.assign({}, options);
	options.tx = true;
	return this.save(options);
}


Zotero.DataObject.prototype._initSave = async function (env) {
	// Default to user library if not specified
	if (this.libraryID === null) {
		this._libraryID = Zotero.Libraries.userLibraryID;
	}
	
	env.isNew = !this.id;
	
	if (!this.hasChanged()) {
		Zotero.debug(this._ObjectType + ' ' + this.id + ' has not changed', 4);
		return false;
	}
	
	if (!env.options.skipEditCheck) {
		if (!this.isEditable()) {
			throw new Error("Cannot edit " + this._objectType + " in library "
				+ Zotero.Libraries.get(this.libraryID).name);
		}
	}
	
	let targetLib = Zotero.Libraries.get(this.libraryID);
	if (!targetLib.isChildObjectAllowed(this._objectType)) {
		throw new Error("Cannot add " + this._objectType + " to a " + targetLib.libraryType + " library");
	}
	
	// Undo registerObject() on failure
	if (env.isNew) {
		var func = function () {
			this.ObjectsClass.unload(this._id);
		}.bind(this);
		if (env.options.tx) {
			env.transactionOptions.onRollback = func;
		}
		else {
			Zotero.DB.addCurrentCallback("rollback", func);
		}
	}
	
	env.relationsToRegister = [];
	env.relationsToUnregister = [];
	
	return true;
};

Zotero.DataObject.prototype._saveData = function (env) {
	var libraryID = env.libraryID = this.libraryID || Zotero.Libraries.userLibraryID;
	var key = env.key = this._key = this.key ? this.key : this._generateKey();
	
	env.sqlColumns = [];
	env.sqlValues = [];
	
	if (env.isNew) {
		env.sqlColumns.push(
			'libraryID',
			'key'
		);
		env.sqlValues.push(
			libraryID,
			key
		);
	}
	
	if (this._changed.primaryData && this._changed.primaryData.version) {
		env.sqlColumns.push('version');
		env.sqlValues.push(this.version || 0);
	}
	
	if (this._changed.primaryData && this._changed.primaryData.synced) {
		env.sqlColumns.push('synced');
		env.sqlValues.push(this.synced ? 1 : 0);
	}
	// Set synced to 0 by default
	else if (!env.isNew && !env.options.skipSyncedUpdate) {
		env.sqlColumns.push('synced');
		env.sqlValues.push(0);
	}
	
	if (env.isNew || !env.options.skipClientDateModifiedUpdate) {
		env.sqlColumns.push('clientDateModified');
		env.sqlValues.push(Zotero.DB.transactionDateTime);
	}
	
	if (!env.options.skipNotifier && this._changedData.deleted !== undefined) {
		Zotero.Notifier.queue('refresh', 'trash', this.libraryID, {}, env.options.notifierQueue);
		if (!env.isNew && this._changedData.deleted) {
			Zotero.Notifier.queue('trash', this._objectType, this.id, {}, env.options.notifierQueue);
		}
	}
};

Zotero.DataObject.prototype._finalizeSave = async function (env) {
	// Relations
	if (this._changed.relations) {
		let toAdd, toRemove;
		// Convert to individual JSON objects, diff, and convert back
		if (this._previousData.relations) {
			let oldRelationsJSON = this._previousData.relations.map(x => JSON.stringify(x));
			let newRelationsJSON = this._relations.map(x => JSON.stringify(x));
			toAdd = Zotero.Utilities.arrayDiff(newRelationsJSON, oldRelationsJSON)
				.map(x => JSON.parse(x));
			toRemove = Zotero.Utilities.arrayDiff(oldRelationsJSON, newRelationsJSON)
				.map(x => JSON.parse(x));
		}
		else {
			toAdd = this._relations;
			toRemove = [];
		}
		
		if (toAdd.length) {
			// Convert predicates to ids
			let toAddConverted = [];
			for (let rel of toAdd) {
				let predicateID = await Zotero.RelationPredicates.add(rel[0]);
				let object = rel[1];
				toAddConverted.push([predicateID, object]);
			}
			let sql = "INSERT INTO " + this._objectType + "Relations "
				+ "(" + this._ObjectsClass.idColumn + ", predicateID, object) VALUES ";
			await Zotero.Utilities.Internal.forEachChunkAsync(
				toAddConverted,
				Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3),
				async function (chunk) {
					await Zotero.DB.queryAsync(
						sql + chunk.map(x => "(?, ?, ?)").join(", "),
						chunk.map(x => [this.id, x[0], x[1]])
							.reduce((x, y) => x.concat(y))
					);
				}.bind(this)
			);
			env.relationsToRegister = toAdd;
		}
		
		if (toRemove.length) {
			for (let i = 0; i < toRemove.length; i++) {
				let sql = "DELETE FROM " + this._objectType + "Relations "
					+ "WHERE " + this._ObjectsClass.idColumn + "=? AND predicateID=? AND object=?";
				await Zotero.DB.queryAsync(
					sql,
					[
						this.id,
						((await Zotero.RelationPredicates.add(toRemove[i][0]))),
						toRemove[i][1]
					]
				);
				env.relationsToUnregister.push([toRemove[i][0], toRemove[i][1]]);
			}
		}
	}
	
	if (env.isNew) {
		if (!env.skipCache) {
			// Register this object's identifiers in Zotero.DataObjects. This has to happen here so
			// that the object exists for the reload() in objects' finalizeSave methods.
			this.ObjectsClass.registerObject(this);
		}
		// If object isn't being reloaded, disable it, since its data may be out of date
		else {
			this._disabled = true;
		}
	}
	else if (env.skipCache) {
		Zotero.logError("skipCache is only for new objects");
	}
};


/**
 * Actions to perform after DB transaction
 */
Zotero.DataObject.prototype._postSave = function (env) {
	for (let i = 0; i < env.relationsToRegister.length; i++) {
		let rel = env.relationsToRegister[i];
		Zotero.debug(rel);
		Zotero.Relations.register(this._objectType, this.id, rel[0], rel[1]);
	}
	for (let i = 0; i < env.relationsToUnregister.length; i++) {
		let rel = env.relationsToUnregister[i];
		Zotero.Relations.unregister(this._objectType, this.id, rel[0], rel[1]);
	}
};


Zotero.DataObject.prototype._recoverFromSaveError = async function (env) {
	await this.reload(null, true);
	this._clearChanged();
};


/**
 * Update object version, efficiently
 *
 * Used by sync code
 *
 * @param {Integer} version
 * @param {Boolean} [skipDB=false]
 */
Zotero.DataObject.prototype.updateVersion = async function (version, skipDB) {
	if (!this.id) {
		throw new Error("Cannot update version of unsaved " + this._objectType);
	}
	if (version != parseInt(version)) {
		throw new Error("'version' must be an integer");
	}
	
	this._version = parseInt(version);
	
	if (!skipDB) {
		var cl = this.ObjectsClass;
		var sql = "UPDATE " + cl.table + " SET version=? WHERE " + cl.idColumn + "=?";
		await Zotero.DB.queryAsync(sql, [parseInt(version), this.id]);
	}
	
	if (this._changed.primaryData && this._changed.primaryData.version) {
		if (Objects.keys(this._changed.primaryData).length == 1) {
			delete this._changed.primaryData;
		}
		else {
			delete this._changed.primaryData.version;
		}
	}
};

/**
 * Update object sync status, efficiently
 *
 * Used by sync code
 *
 * @param {Boolean} synced
 * @param {Boolean} [skipDB=false]
 */
Zotero.DataObject.prototype.updateSynced = async function (synced, skipDB) {
	if (!this.id) {
		throw new Error("Cannot update sync status of unsaved " + this._objectType);
	}
	if (typeof synced != 'boolean') {
		throw new Error("'synced' must be a boolean");
	}
	
	this._synced = synced;
	
	if (!skipDB) {
		var cl = this.ObjectsClass;
		var sql = "UPDATE " + cl.table + " SET synced=? WHERE " + cl.idColumn + "=?";
		await Zotero.DB.queryAsync(sql, [synced ? 1 : 0, this.id]);
	}
	
	if (this._changed.primaryData && this._changed.primaryData.synced) {
		if (Object.keys(this._changed.primaryData).length == 1) {
			delete this._changed.primaryData;
		}
		else {
			delete this._changed.primaryData.synced;
		}
	}
};

/**
 * Delete object from database
 *
 * @param {Object} [options]
 * @param {Boolean} [options.deleteItems] - Move descendant items to trash (Collection only)
 * @param {Boolean} [options.skipDeleteLog] - Don't add to sync delete log
 */
Zotero.DataObject.prototype.erase = async function (options = {}) {
	if (!options || typeof options != 'object') {
		throw new Error("'options' must be an object (" + typeof options + ")");
	}
	
	var env = {
		options: Object.assign({}, options)
	};
	
	if (!env.options.tx && !Zotero.DB.inTransaction()) {
		Zotero.logError("erase() called on Zotero." + this._ObjectType + " without a wrapping "
			+ "transaction -- use eraseTx() instead");
		Zotero.debug((new Error).stack, 2);
		env.options.tx = true;
	}
	
	let proceed = await this._initErase(env);
	if (!proceed) return false;
	
	Zotero.debug('Deleting ' + this.objectType + ' ' + this.id);
	
	if (env.options.tx) {
		return Zotero.DB.executeTransaction(async function () {
			await this._eraseData(env);
			await this._finalizeErase(env);
		}.bind(this))
	}
	else {
		Zotero.DB.requireTransaction();
		await this._eraseData(env);
		await this._finalizeErase(env);
	}
};

Zotero.DataObject.prototype.eraseTx = function (options) {
	options = options || {};
	options.tx = true;
	return this.erase(options);
};

Zotero.DataObject.prototype._initErase = function (env) {
	env.notifierData = {};
	env.notifierData[this.id] = {
		libraryID: this.libraryID,
		key: this.key
	};
	
	if (!env.options.skipEditCheck) {
		if (!this.isEditable('erase')) {
			throw new Error(`Cannot erase ${this._objectType} in library `
				+ Zotero.Libraries.get(this.libraryID).name);
		}
	}
	
	return true;
};

Zotero.DataObject.prototype._finalizeErase = async function (env) {
	// Delete versions from sync cache
	if (this._objectType != 'feedItem') {
		await Zotero.Sync.Data.Local.deleteCacheObjectVersions(
			this.objectType, this._libraryID, this._key
		);
	}
	
	Zotero.DB.addCurrentCallback("commit", function () {
		this.ObjectsClass.unload(env.deletedObjectIDs || this.id);
	}.bind(this));
	
	if (env.options.skipDeleteLog) {
		env.notifierData[this.id].skipDeleteLog = true;
	}
	
	if (!env.options.skipNotifier) {
		Zotero.Notifier.queue(
			'delete',
			this._objectType,
			Object.keys(env.notifierData).map(id => parseInt(id)),
			env.notifierData,
			env.options.notifierQueue
		);
	}
};


Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
	let uri = Zotero.URI.getObjectURI(this);
	var json = {
		key: this.key,
		version: this.version,
		library: this.library.toResponseJSON({ ...options, includeGroupDetails: false }),
		links: {
			self: {
				href: Zotero.URI.toAPIURL(uri, options.apiURL),
				type: 'application/json'
			},
			alternate: Zotero.Users.getCurrentUserID() ? {
				href: Zotero.URI.toWebURL(uri),
				type: 'text/html'
			} : undefined
		},
		meta: {},
		data: this.toJSON(options)
	};
	if (options.version) {
		json.version = json.data.version = options.version;
	}
	if (this.parentID) {
		json.links.up = {
			href: Zotero.URI.toAPIURL(Zotero.URI.getObjectURI(this.ObjectsClass.get(this.parentID)), options.apiURL),
			type: 'application/json'
		};
	}
	return json;
};


/**
 * Subclasses can override to provide more information that requires awaiting promises.
 * Delegates to {@link Zotero.DataObject#toResponseJSON} by default.
 *
 * @returns {Promise<Object>}
 */
Zotero.DataObject.prototype.toResponseJSONAsync = async function (options = {}) {
	return this.toResponseJSON(options);
};


Zotero.DataObject.prototype._preToJSON = function (options) {
	var env = { options };
	env.mode = options.mode || 'new';
	if (env.mode == 'patch') {
		if (!options.patchBase) {
			throw new Error("Cannot use patch mode if patchBase not provided");
		}
	}
	else if (options.patchBase) {
		if (options.mode) {
			Zotero.debug("Zotero.Item.toJSON: ignoring provided patchBase in " + env.mode + " mode", 2);
		}
		// If patchBase provided and no explicit mode, use 'patch'
		else {
			env.mode = 'patch';
		}
	}
	return env;
}

Zotero.DataObject.prototype._postToJSON = function (env) {
	var deleted = this._getLatestField('deleted');
	if (deleted || env.options.mode == 'full') {
		env.obj.deleted = !!deleted;
	}
	
	if (env.mode == 'patch') {
		env.obj = Zotero.DataObjectUtilities.patch(env.options.patchBase, env.obj);
	}
	if (env.options.includeVersion === false) {
		delete env.obj.version;
	}
	return env.obj;
}


/**
 * Generates data object key
 * @return {String} key
 */
Zotero.DataObject.prototype._generateKey = function () {
	return Zotero.Utilities.generateObjectKey();
}

Zotero.DataObject.prototype._disabledCheck = function () {
	if (this._disabled) {
		Zotero.logError(this._ObjectType + " is disabled -- "
			+ "use Zotero." + this._ObjectTypePlural  + ".getAsync()");
	}
}
