/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2009 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 *****
*/

if (!Zotero.Sync.Storage.Mode) {
	Zotero.Sync.Storage.Mode = {};
}

Zotero.Sync.Storage.Mode.ZFS = function (options) {
	this.options = options;
	this.apiClient = options.apiClient;
	
	this._s3Backoff = 1;
	this._s3ConsecutiveFailures = 0;
	this._maxS3Backoff = 60;
	this._maxS3ConsecutiveFailures = options.maxS3ConsecutiveFailures !== undefined
		? options.maxS3ConsecutiveFailures : 5;
};
Zotero.Sync.Storage.Mode.ZFS.prototype = {
	mode: "zfs",
	name: "ZFS",
	verified: true,
	
	
	/**
	 * Begin download process for individual file
	 *
	 * @param {Zotero.Sync.Storage.Request} request
	 * @return {Promise<Zotero.Sync.Storage.Result>}
	 */
	downloadFile: async function (request) {
		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
		if (!item) {
			throw new Error("Item '" + request.name + "' not found");
		}
		
		var path = item.getFilePath();
		if (!path) {
			Zotero.debug(`Cannot download file for attachment ${item.libraryKey} with no path`);
			return new Zotero.Sync.Storage.Result;
		}
		
		var destPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp');
		
		// Create an empty file to check file access
		try {
			await IOUtils.write(destPath, new Uint8Array());
		}
		catch (e) {
			Zotero.File.checkFileAccessError(e, destPath, 'create');
		}
		
		var requestData = {item};
		
		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
		var uri = this.apiClient.buildRequestURI(params);
		
		return new Promise(async (resolve, reject) => {
			var resultOptions = {};
			try {
				let req = await Zotero.HTTP.download(
					uri,
					destPath,
					{
						successCodes: [200, 302, 404],
						headers: this.apiClient.getHeaders(),
						noCache: true,
						notificationCallbacks: {
							asyncOnChannelRedirect: async function (oldChannel, newChannel, flags, callback) {
								// These will be used in processDownload() if the download succeeds
								oldChannel.QueryInterface(Components.interfaces.nsIHttpChannel);
								
								Zotero.debug(`Handling ${oldChannel.responseStatus} redirect for ${item.libraryKey}`);
								Zotero.debug(oldChannel.URI.spec);
								Zotero.debug(newChannel.URI.spec);
								
								var header;
								try {
									header = "Zotero-File-Modification-Time";
									requestData.mtime = parseInt(oldChannel.getResponseHeader(header));
									header = "Zotero-File-MD5";
									requestData.md5 = oldChannel.getResponseHeader(header);
									header = "Zotero-File-Compressed";
									requestData.compressed = oldChannel.getResponseHeader(header) == 'Yes';
								}
								catch (_e) {
									reject(new Error(`${header} header not set in file request for ${item.libraryKey}`));
									callback.onRedirectVerifyCallback(Cr.NS_ERROR_ABORT);
									return;
								}
								
								if (!(await IOUtils.exists(path))) {
									callback.onRedirectVerifyCallback(Cr.NS_OK);
									return;
								}
								
								var updateHash = false;
								var fileModTime = await item.attachmentModificationTime;
								if (requestData.mtime == fileModTime) {
									Zotero.debug("File mod time matches remote file -- skipping download of "
										+ item.libraryKey);
								}
								// If not compressed, check hash, in case only timestamp changed
								else if (!requestData.compressed && (await item.attachmentHash) == requestData.md5) {
									Zotero.debug("File hash matches remote file -- skipping download of "
										+ item.libraryKey);
									updateHash = true;
								}
								else {
									callback.onRedirectVerifyCallback(Cr.NS_OK);
									return;
								}
								
								// Update local metadata and stop request, skipping file download
								await OS.File.setDates(path, null, new Date(requestData.mtime));
								item.attachmentSyncedModificationTime = requestData.mtime;
								if (updateHash) {
									item.attachmentSyncedHash = requestData.md5;
								}
								item.attachmentSyncState = "in_sync";
								await item.saveTx({ skipAll: true });
								resultOptions.localChanges = true;
								
								callback.onRedirectVerifyCallback(Cr.NS_ERROR_ABORT);
							},
							
							onProgress: function (req, progress, progressMax) {
								request.onProgress(progress, progressMax);
							},
						},
					}
				);
				
				if (req.status == 302) {
					resolve(new Zotero.Sync.Storage.Result(resultOptions));
					return;
				}
				
				if (req.status == 404) {
					Zotero.debug("Remote file not found for item " + item.libraryKey);
					// Don't refresh item pane rows when nothing happened
					request.skipProgressBarUpdate = true;
					resolve(new Zotero.Sync.Storage.Result);
					return;
				}
				
				// Don't try to process if the request has been cancelled
				if (request.isFinished()) {
					Zotero.debug(`Download request ${request.name} is no longer running after file download`, 2);
					resolve(new Zotero.Sync.Storage.Result);
					return;
				}
				
				Zotero.debug("Finished download of " + destPath);
				
				resolve(await Zotero.Sync.Storage.Local.processDownload(requestData));
			}
			catch (e) {
				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
					// If S3 connection is interrupted, delay and retry, or bail if too many
					// consecutive failures
					if (e.xmlhttp.status == 0) {
						if (++this._s3ConsecutiveFailures < this._maxS3ConsecutiveFailures) {
							let libraryKey = item.libraryKey;
							let msg = "S3 returned 0 for " + libraryKey + " -- retrying download";
							Zotero.logError(msg);
							if (this._s3Backoff < this._maxS3Backoff) {
								this._s3Backoff *= 2;
							}
							Zotero.debug("Delaying " + libraryKey + " download for "
								+ this._s3Backoff + " seconds", 2);
							Zotero.Promise.delay(this._s3Backoff * 1000)
							.then(function () {
								resolve(this.downloadFile(request));
							}.bind(this));
							return;
						}
						
						Zotero.debug(this._s3ConsecutiveFailures
							+ " consecutive S3 failures -- aborting", 1);
						this._s3ConsecutiveFailures = 0;
					}
				}
				Zotero.logError(e);
				reject(new Error(Zotero.Sync.Storage.defaultError));
			}
		});
	},
	
	
	uploadFile: async function (request) {
		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
		var isZipUpload = await this._isZipUpload(item);
		
		// If we got a quota error for this library, skip upload for all zipped attachments
		// and for single-file attachments that are bigger than the remaining space. This is cleared
		// in storageEngine for manual syncs.
		var remaining = Zotero.Sync.Storage.Local.storageRemainingForLibrary.get(item.libraryID);
		if (remaining !== undefined) {
			let skip = false;
			if (isZipUpload) {
				Zotero.debug("Skipping multi-file upload after quota error");
				skip = true;
			}
			else {
				let size;
				try {
					// API rounds megabytes to 1 decimal place
					size = (((await OS.File.stat(item.getFilePath()))).size / 1024 / 1024).toFixed(1);
				}
				catch (e) {
					Zotero.logError(e);
				}
				if (size >= remaining) {
					Zotero.debug(`Skipping file upload after quota error (${size} >= ${remaining})`);
					skip = true;
				}
			}
			if (skip) {
				// Stop trying to upload files if there's very little storage remaining
				if (request.engine && remaining < Zotero.Sync.Storage.Local.STORAGE_REMAINING_MINIMUM) {
					Zotero.debug(`${remaining} MB remaining in storage -- skipping further uploads`);
					request.engine.stop('upload');
				}
				throw await this._getQuotaError(item);
			}
		}
		
		if (isZipUpload) {
			let created = await Zotero.Sync.Storage.Utilities.createUploadFile(request);
			if (!created) {
				return new Zotero.Sync.Storage.Result;
			}
		}
		
		try {
			return await this._processUploadFile(request);
		}
		catch (e) {
			// Stop trying to upload files if we hit a quota error and there's very little space
			// remaining. If there's more space, we keep going, because it might just be a big file.
			if (request.engine && e.error == Zotero.Error.ERROR_ZFS_OVER_QUOTA) {
				let remaining = Zotero.Sync.Storage.Local.storageRemainingForLibrary.get(item.libraryID);
				if (remaining < Zotero.Sync.Storage.Local.STORAGE_REMAINING_MINIMUM) {
					Zotero.debug(`${remaining} MB remaining in storage -- skipping further uploads`);
					request.engine.stop('upload');
				}
			}
			throw e;
		}
	},
	
	
	/**
	 * Remove all synced files from the server
	 */
	purgeDeletedStorageFiles: async function (libraryID) {
		if (libraryID != Zotero.Libraries.userLibraryID) return;
		
		var sql = "SELECT value FROM settings WHERE setting=? AND key=?";
		var values = await Zotero.DB.columnQueryAsync(sql, ['storage', 'zfsPurge']);
		if (!values.length) {
			return false;
		}
		
		Zotero.debug("Unlinking synced files on ZFS");
		
		var params = this._getRequestParams(libraryID, "removestoragefiles");
		var uri = this.apiClient.buildRequestURI(params);
		
		await Zotero.HTTP.request("POST", uri, "");
		
		var sql = "DELETE FROM settings WHERE setting=? AND key=?";
		await Zotero.DB.queryAsync(sql, ['storage', 'zfsPurge']);
	},
	
	
	//
	// Private methods
	//
	_getRequestParams: function (libraryID, target) {
		var library = Zotero.Libraries.get(libraryID);
		return {
			libraryType: library.libraryType,
			libraryTypeID: library.libraryTypeID,
			target
		};
	},
	
	
	/**
	 * Get authorization from API for uploading file
	 *
	 * @param {Zotero.Item} item
	 * @return {Object|String} - Object with upload params or 'exists'
	 */
	_getFileUploadParameters: async function (item) {
		var funcName = "Zotero.Sync.Storage.ZFS._getFileUploadParameters()";
		
		var path = item.getFilePath();
		var filename = PathUtils.filename(path);
		var zip = await this._isZipUpload(item);
		if (zip) {
			var uploadPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.zip');
		}
		else {
			var uploadPath = path;
		}
		
		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
		var uri = this.apiClient.buildRequestURI(params);
		
		// TODO: One-step uploads
		/*var headers = {
			"Content-Type": "application/json"
		};
		var storedHash = yield Zotero.Sync.Storage.Local.getSyncedHash(item.id);
		//var storedModTime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
		if (storedHash) {
			headers["If-Match"] = storedHash;
		}
		else {
			headers["If-None-Match"] = "*";
		}
		var mtime = yield item.attachmentModificationTime;
		var hash = Zotero.Utilities.Internal.md5(file);
		var json = {
			md5: hash,
			mtime,
			filename,
			size: file.fileSize
		};
		if (zip) {
			json.zip = true;
		}
		
		try {
			var req = yield this.apiClient.makeRequest(
				"POST", uri, { body: JSON.stringify(json), headers, debug: true }
			);
		}*/
		
		var headers = {
			"Content-Type": "application/x-www-form-urlencoded"
		};
		var storedHash = item.attachmentSyncedHash;
		//var storedModTime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
		if (storedHash) {
			headers["If-Match"] = storedHash;
		}
		else {
			headers["If-None-Match"] = "*";
		}
		
		// Build POST body
		var params = {
			mtime: await item.attachmentModificationTime,
			md5: await item.attachmentHash,
			filename,
			filesize: ((await OS.File.stat(uploadPath))).size
		};
		if (zip) {
			params.zipMD5 = await Zotero.Utilities.Internal.md5Async(uploadPath);
			params.zipFilename = PathUtils.filename(uploadPath);
		}
		var body = [];
		for (let i in params) {
			body.push(i + "=" + encodeURIComponent(params[i]));
		}
		body = body.join('&');
		
		var req;
		while (true) {
			try {
				req = await this.apiClient.makeRequest(
					"POST",
					uri,
					{
						body,
						headers,
						// This should include all errors in _handleUploadAuthorizationFailure()
						successCodes: [200, 201, 204, 403, 404, 412, 413],
						debug: true
					}
				);
			}
			catch (e) {
				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
					let msg = "Unexpected status code " + e.status + " in " + funcName
						 + " (" + item.libraryKey + ")";
					Zotero.logError(msg);
					Zotero.debug(e.xmlhttp.getAllResponseHeaders());
					throw new Error(Zotero.Sync.Storage.defaultError);
				}
				throw e;
			}
			
			let result = await this._handleUploadAuthorizationFailure(req, item);
			if (result instanceof Zotero.Sync.Storage.Result) {
				return result;
			}
			// If remote attachment exists but has no hash (which can happen for an old (pre-4.0?)
			// attachment with just an mtime, or after a storage purge), send again with If-None-Match
			else if (result == "ERROR_412_WITHOUT_VERSION") {
				if (headers["If-None-Match"]) {
					throw new Error("412 returned for request with If-None-Match");
				}
				delete headers["If-Match"];
				headers["If-None-Match"] = "*";
				storedHash = null;
				Zotero.debug("Retrying with If-None-Match");
			}
			else {
				break;
			}
		}
		
		try {
			var json = JSON.parse(req.responseText);
		}
		catch (e) {
			Zotero.logError(e);
			Zotero.debug(req.responseText, 1);
		}
		if (!json) {
			 throw new Error("Invalid response retrieving file upload parameters");
		}
		
		if (!json.uploadKey && !json.exists) {
			throw new Error("Invalid response retrieving file upload parameters");
		}
		
		if (json.exists) {
			let version = req.getResponseHeader('Last-Modified-Version');
			if (!version) {
				throw new Error("Last-Modified-Version not provided");
			}
			json.version = version;
		}
		
		// TEMP
		//
		// Passed through to _updateItemFileInfo()
		json.mtime = params.mtime;
		json.md5 = params.md5;
		if (storedHash) {
			json.storedHash = storedHash;
		}
		
		return json;
	},
	
	
	/**
	 * Handle known errors from upload authorization request
	 *
	 * These must be included in successCodes in _getFileUploadParameters()
	 */
	_handleUploadAuthorizationFailure: async function (req, item) {
		//
		// These must be included in successCodes above.
		// TODO: 429?
		if (req.status == 403) {
			let groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID);
			let e = new Zotero.Error(
				"File editing denied for group",
				"ZFS_FILE_EDITING_DENIED",
				{
					groupID: groupID
				}
			);
			throw e;
		}
		// This shouldn't happen, but if it does, mark item for upload and restart sync
		else if (req.status == 404) {
			Zotero.logError(`Item ${item.libraryID}/${item.key} not found in upload authorization `
				+ 'request -- marking for upload');
			await Zotero.Sync.Data.Local.markObjectAsUnsynced(item);
			return new Zotero.Sync.Storage.Result({
				syncRequired: true
			});
		}
		else if (req.status == 412) {
			let version = req.getResponseHeader('Last-Modified-Version');
			if (!version) {
				return "ERROR_412_WITHOUT_VERSION";
			}
			if (version > item.version) {
				// Mark object for redownloading, in case the library version is up to date and
				// it's just the attachment item that somehow didn't get updated
				await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
					'item', item.libraryID, [item.key], true
				);
				return new Zotero.Sync.Storage.Result({
					syncRequired: true
				});
			}
			
			// Get updated item metadata
			let library = Zotero.Libraries.get(item.libraryID);
			let { json, error } = await this.apiClient.downloadObjects(
				library.libraryType,
				library.libraryTypeID,
				'item',
				[item.key]
			)[0];
			if (error) {
				Zotero.logError(error);
				throw new Error(Zotero.Sync.Storage.defaultError);
			}
			if (json.length > 1) {
				throw new Error("More than one result for item lookup");
			}
			
			await Zotero.Sync.Data.Local.saveCacheObjects('item', item.libraryID, json);
			json = json[0];
			
			if (json.data.version > item.version) {
				// Mark object for redownloading, in case the library version is up to date and
				// it's just the attachment item that somehow didn't get updated
				await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
					'item', item.libraryID, [item.key], true
				);
				return new Zotero.Sync.Storage.Result({
					syncRequired: true
				});
			}
			
			let fileHash = await item.attachmentHash;
			let fileModTime = await item.attachmentModificationTime;
			
			Zotero.debug("MD5");
			Zotero.debug(json.data.md5);
			Zotero.debug(fileHash);
			
			if (json.data.md5 == fileHash) {
				item.attachmentSyncedModificationTime = fileModTime;
				item.attachmentSyncedHash = fileHash;
				item.attachmentSyncState = "in_sync";
				await item.saveTx({ skipAll: true });
				
				return new Zotero.Sync.Storage.Result;
			}
			
			item.attachmentSyncState = "in_conflict";
			await item.saveTx({ skipAll: true });
			
			return new Zotero.Sync.Storage.Result({
				fileSyncRequired: true
			});
		}
		else if (req.status == 413) {
			let retry = req.getResponseHeader('Retry-After');
			if (retry) {
				let minutes = Math.round(retry / 60);
				throw new Zotero.Error(
					Zotero.getString('sync.storage.error.zfs.tooManyQueuedUploads', minutes),
					"ZFS_UPLOAD_QUEUE_LIMIT"
				);
			}
			
			// Store the remaining space so that we can skip files bigger than that until the next
			// manual sync. Values are in megabytes.
			let usage = req.getResponseHeader('Zotero-Storage-Usage');
			let quota = req.getResponseHeader('Zotero-Storage-Quota');
			Zotero.Sync.Storage.Local.storageRemainingForLibrary.set(item.libraryID, quota - usage);
			
			throw await this._getQuotaError(item);
		}
	},
	
	/**
	 * Given parameters from authorization, upload file to S3
	 */
	_uploadFile: async function (request, item, params) {
		if (request.isFinished()) {
			Zotero.debug("Upload request " + request.name + " is no longer running after getting "
				+ "upload parameters");
			return new Zotero.Sync.Storage.Result;
		}
		
		var file = await this._getUploadFile(item);
		
		Components.utils.importGlobalProperties(["File"]);
		file = File.createFromFileName ? File.createFromFileName(file.path) : new File(file);
		// File.createFromFileName() returns a Promise in Fx54+
		if (file.then) {
			file = await file;
		}
		
		var blob = new Blob([params.prefix, file, params.suffix]);
		
		try {
			var req = await Zotero.HTTP.request(
				"POST",
				params.url,
				{
					headers: {
						"Content-Type": params.contentType
					},
					body: blob,
					requestObserver: function (req) {
						request.setChannel(req.channel);
						req.upload.addEventListener("progress", function (event) {
							if (event.lengthComputable) {
								request.onProgress(event.loaded, event.total);
							}
						});
					},
					debug: true,
					successCodes: [201],
					timeout: 0
				}
			);
		}
		catch (e) {
			// Certificate error
			if (e instanceof Zotero.Error) {
				throw e;
			}
			
			// For timeouts and failures from S3, which happen intermittently,
			// wait a little and try again
			let timeoutMessage = "Your socket connection to the server was not read from or "
				+ "written to within the timeout period.";
			if (e.status == 0
					|| (e.status == 400 && e.xmlhttp.responseText.indexOf(timeoutMessage) != -1)) {
				if (this._s3ConsecutiveFailures >= this._maxS3ConsecutiveFailures) {
					Zotero.debug(this._s3ConsecutiveFailures
						+ " consecutive S3 failures -- aborting", 1);
					this._s3ConsecutiveFailures = 0;
					let e = Zotero.getString('sync.storage.error.zfs.restart', Zotero.appName);
					throw new Error(e);
				}
				else {
					let msg = "S3 returned " + e.status + " (" + item.libraryKey + ") "
						+ "-- retrying upload"
					Zotero.logError(msg);
					Zotero.debug(e.xmlhttp.responseText, 1);
					if (this._s3Backoff < this._maxS3Backoff) {
						this._s3Backoff *= 2;
					}
					this._s3ConsecutiveFailures++;
					Zotero.debug("Delaying " + item.libraryKey + " upload for "
						+ this._s3Backoff + " seconds", 2);
					await Zotero.Promise.delay(this._s3Backoff * 1000);
					return this._uploadFile(request, item, params);
				}
			}
			else if (e.status == 500) {
				// TODO: localize
				throw new Error("File upload failed. Please try again.");
			}
			else {
				Zotero.logError(`Unexpected file upload status ${e.status} (${item.libraryKey})`);
				Zotero.debug(e, 1);
				Components.utils.reportError(e.xmlhttp.responseText);
				throw new Error(Zotero.Sync.Storage.defaultError);
			}
			
			// TODO: Detect cancel?
			//onUploadCancel(httpRequest, status, data)
			//deferred.resolve(false);
		}
		
		request.setChannel(false);
		return this._onUploadComplete(req, request, item, params);
	},
	
	
	/**
	 * Post-upload file registration with API
	 */
	_onUploadComplete: async function (req, request, item, params) {
		var uploadKey = params.uploadKey;
		
		Zotero.debug("Upload of attachment " + item.key + " finished with status code " + req.status);
		Zotero.debug(req.responseText);
		
		// Decrease backoff delay on successful upload
		if (this._s3Backoff > 1) {
			this._s3Backoff /= 2;
		}
		// And reset consecutive failures
		this._s3ConsecutiveFailures = 0;
		
		var requestParams = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
		var uri = this.apiClient.buildRequestURI(requestParams);
		var headers = {
			"Content-Type": "application/x-www-form-urlencoded"
		};
		if (params.storedHash) {
			headers["If-Match"] = params.storedHash;
		}
		else {
			headers["If-None-Match"] = "*";
		}
		var body = "upload=" + uploadKey;
		
		// Register upload on server
		try {
			req = await this.apiClient.makeRequest(
				"POST",
				uri,
				{
					body,
					headers,
					successCodes: [204],
					requestObserver: function (xmlhttp) {
						request.setChannel(xmlhttp.channel);
					},
					debug: true
				}
			);
		}
		catch (e) {
			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
				let msg = `Unexpected file registration status ${e.status} (${item.libraryKey})`;
				Zotero.logError(msg);
				Zotero.logError(e.xmlhttp.responseText);
				Zotero.debug(e.xmlhttp.getAllResponseHeaders());
				throw new Error(Zotero.Sync.Storage.defaultError);
			}
			throw e;
		}
		
		var version = req.getResponseHeader('Last-Modified-Version');
		if (!version) {
			throw new Error("Last-Modified-Version not provided");
		}
		params.version = version;
		
		await this._updateItemFileInfo(item, params);
		
		return new Zotero.Sync.Storage.Result({
			localChanges: true,
			remoteChanges: true
		});
	},
	
	
	/**
	 * Update the local attachment item with the mtime and hash of the uploaded file and the
	 * library version returned by the upload request, and save a modified version of the item
	 * to the sync cache
	 */
	_updateItemFileInfo: async function (item, params) {
		// Mark as in-sync
		await Zotero.DB.executeTransaction(async function () {
				// Store file mod time and hash
			item.attachmentSyncedModificationTime = params.mtime;
			item.attachmentSyncedHash = params.md5;
			item.attachmentSyncState = "in_sync";
			await item.save({ skipAll: true });
			
			// Update sync cache with new file metadata and version from server
			var json = await Zotero.Sync.Data.Local.getCacheObject(
				'item', item.libraryID, item.key, item.version
			);
			if (json) {
				json.version = params.version;
				json.data.version = params.version;
				json.data.mtime = params.mtime;
				json.data.md5 = params.md5;
				await Zotero.Sync.Data.Local.saveCacheObject('item', item.libraryID, json);
			}
			// Update item with new version from server
			await Zotero.Items.updateVersion([item.id], params.version);
			
			// TODO: Can filename, contentType, and charset change the attachment item?
		});
		
		try {
			if (await this._isZipUpload(item)) {
				var file = Zotero.getTempDirectory();
				file.append(item.key + '.zip');
				await OS.File.remove(file.path);
			}
		}
		catch (e) {
			Components.utils.reportError(e);
		}
	},
	
	
	_onUploadCancel: async function (httpRequest, status, data) {
		var request = data.request;
		var item = data.item;
		
		Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status);
		
		try {
			if (await this._isZipUpload(item)) {
				var file = Zotero.getTempDirectory();
				file.append(item.key + '.zip');
				file.remove(false);
			}
		}
		catch (e) {
			Components.utils.reportError(e);
		}
	},
	
	
	_getUploadFile: async function (item) {
		if (await this._isZipUpload(item)) {
			var file = Zotero.getTempDirectory();
			var filename = item.key + '.zip';
			file.append(filename);
		}
		else {
			var file = item.getFile();
		}
		return file;
	},
	
	
	/**
	 * Get attachment item metadata on storage server
	 *
	 * @param {Zotero.Item} item
	 * @param {Zotero.Sync.Storage.Request} request
	 * @return {Promise<Object>|false} - Promise for object with 'hash', 'filename', 'mtime',
	 *                                   'compressed', or false if item not found
	 */
	_getStorageFileInfo: async function (item, request) {
		var funcName = "Zotero.Sync.Storage.ZFS._getStorageFileInfo()";
		
		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
		var uri = this.apiClient.buildRequestURI(params);
		
		try {
			let req = await this.apiClient.makeRequest(
				"GET",
				uri,
				{
					successCodes: [200, 404],
					requestObserver: function (xmlhttp) {
						request.setChannel(xmlhttp.channel);
					}
				}
			);
			if (req.status == 404) {
				return new Zotero.Sync.Storage.Result;
			}
			
			let info = {};
			info.hash = req.getResponseHeader('ETag');
			if (!info.hash) {
				let msg = `Hash not found in info response in ${funcName} (${item.libraryKey})`;
				Zotero.debug(msg, 1);
				Zotero.debug(req.status);
				Zotero.debug(req.responseText);
				Components.utils.reportError(msg);
				try {
					Zotero.debug(req.getAllResponseHeaders());
				}
				catch (e) {
					Zotero.debug("Response headers unavailable");
				}
				let e = Zotero.getString('sync.storage.error.zfs.restart', Zotero.appName);
				throw new Error(e);
			}
			info.filename = req.getResponseHeader('X-Zotero-Filename');
			let mtime = req.getResponseHeader('X-Zotero-Modification-Time');
			info.mtime = parseInt(mtime);
			info.compressed = req.getResponseHeader('X-Zotero-Compressed') == 'Yes';
			Zotero.debug(info);
			
			return info;
		}
		catch (e) {
			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
				if (e.xmlhttp.status == 0) {
					var msg = "Request cancelled getting storage file info";
				}
				else {
					var msg = "Unexpected status code " + e.xmlhttp.status
						+ " getting storage file info for item " + item.libraryKey;
				}
				Zotero.debug(msg, 1);
				Zotero.debug(e.xmlhttp.responseText);
				Components.utils.reportError(msg);
				throw new Error(Zotero.Sync.Storage.defaultError);
			}
			
			throw e;
		}
	},
	
	
	/**
	 * Upload the file to the server
	 *
	 * @param {Zotero.Sync.Storage.Request} request
	 * @return {Promise}
	 */
	_processUploadFile: async function (request) {
		/*
		updateSizeMultiplier(
			(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
		);
		*/
		
		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
		
		
		/*var info = yield this._getStorageFileInfo(item, request);
		
		if (request.isFinished()) {
			Zotero.debug("Upload request '" + request.name
				+ "' is no longer running after getting file info");
			return false;
		}
		
		// Check for conflict
		if (item.attachmentSyncState
				!= Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD) {
			if (info) {
				// Local file time
				var fmtime = yield item.attachmentModificationTime;
				// Remote mod time
				var mtime = info.mtime;
				
				var useLocal = false;
				var same = !(yield Zotero.Sync.Storage.checkFileModTime(item, fmtime, mtime));
				
				// Ignore maxed-out 32-bit ints, from brief problem after switch to 32-bit servers
				if (!same && mtime == 2147483647) {
					Zotero.debug("Remote mod time is invalid -- uploading local file version");
					useLocal = true;
				}
				
				if (same) {
					yield Zotero.DB.executeTransaction(async function () {
						await Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime);
						await Zotero.Sync.Storage.setSyncState(
							item.id, Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC
						);
					});
					return {
						localChanges: true,
						remoteChanges: false
					};
				}
				
				let smtime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
				if (!useLocal && smtime != mtime) {
					Zotero.debug("Conflict -- last synced file mod time "
						+ "does not match time on storage server"
						+ " (" + smtime + " != " + mtime + ")");
					return {
						localChanges: false,
						remoteChanges: false,
						conflict: {
							local: { modTime: fmtime },
							remote: { modTime: mtime }
						}
					};
				}
			}
			else {
				Zotero.debug("Remote file not found for item " + item.libraryKey);
			}
		}*/
		
		var result = await this._getFileUploadParameters(item);
		if (result.exists) {
			await this._updateItemFileInfo(item, result);
			return new Zotero.Sync.Storage.Result({
				localChanges: true,
				remoteChanges: true
			});
		}
		else if (result instanceof Zotero.Sync.Storage.Result) {
			return result;
		}
		return this._uploadFile(request, item, result);
	},
	
	
	_isZipUpload: async function (item) {
		return (item.isImportedAttachment() && item.attachmentContentType.startsWith('text/'))
			|| Zotero.Attachments.hasMultipleFiles(item);
	},
	
	
	_getQuotaError: async function (item) {
		var text, buttonText = null, buttonCallback;
		var libraryType = item.library.libraryType;
		
		// Group file
		if (libraryType == 'group') {
			let group = Zotero.Groups.getByLibraryID(item.libraryID);
			text = Zotero.getString('sync.storage.error.zfs.groupQuotaReached1', group.name) + "\n\n"
					+ Zotero.getString('sync.storage.error.zfs.groupQuotaReached2');
		}
		// Personal file
		else {
			text = Zotero.getString('sync.storage.error.zfs.personalQuotaReached1') + "\n\n"
					+ Zotero.getString('sync.storage.error.zfs.personalQuotaReached2');
			buttonText = Zotero.getString('sync.storage.openAccountSettings');
			buttonCallback = function () {
				let url = "https://www.zotero.org/settings/storage";
				let win = Services.wm.getMostRecentWindow("navigator:browser");
				win.ZoteroPane.loadURI(url, { metaKey: true, ctrlKey: true, shiftKey: true });
			}
		}
		
		var filename = item.attachmentFilename;
		var fileSize = (await OS.File.stat(item.getFilePath())).size;
		
		text += "\n\n" + filename
			// TODO: Format more intelligently (e.g., use MB or GB for large files)
			+ " (" + Zotero.Utilities.numberFormat(Math.round(fileSize / 1024), 0) + " KB)";
		
		var e = new Zotero.Error(
			text,
			"ZFS_OVER_QUOTA",
			{
				dialogButtonText: buttonText,
				dialogButtonCallback: buttonCallback
			}
		);
		e.errorType = 'warning';
		return e;
	}
}
