"use strict";

describe("Zotero.Integration", function () {
	const INTEGRATION_TYPE_ITEM = 1;
	const INTEGRATION_TYPE_BIBLIOGRAPHY = 2;
	const INTEGRATION_TYPE_TEMP = 3;
	/**
	 * To be used as a reference for Zotero-Word Integration plugins
	 * 
	 * NOTE: Functions must return promises instead of values!
	 * The functions defined for the dummy are promisified below
	 */
	var DocumentPluginDummy = {};

	/**
	 * The Application class corresponds to a word processing application.
	 */
	DocumentPluginDummy.Application = function () { 
		this.doc = new DocumentPluginDummy.Document();
		this.primaryFieldType = "Field";
		this.secondaryFieldType = "Bookmark";
		this.supportedNotes = ['footnotes', 'endnotes'];
		// Will display an option to switch word processors in the Doc Prefs
		this.supportsImportExport = true;
		// Will allow inserting notes
		this.supportsTextInsertion = true;
		this.supportsCitationMerging = false;
		this.fields = [];
	};
	DocumentPluginDummy.Application.prototype = {
		/**
		 * Gets the active document.
		 * @returns {DocumentPluginDummy.Document}
		 */
		getActiveDocument: function () {return this.doc},
		/**
		 * Gets the document by some app-specific identifier.
		 * @param {String|Number} docID
		 */
		getDocument: function (docID) {return this.doc},
		QueryInterface: function () {return this},
	};

	/**
	 * The Document class corresponds to a single word processing document.
	 */
	DocumentPluginDummy.Document = function () {this.fields = [], this.fieldIdx = 0};
	DocumentPluginDummy.Document.prototype = {
		/**
		 * Displays a dialog in the word processing application
		 * @param {String} dialogText
		 * @param {Number} icon - one of the constants defined in integration.js for dialog icons
		 * @param {Number} buttons - one of the constants defined in integration.js for dialog buttons
		 * @returns {Number}
		 *		 - Yes: 2, No: 1, Cancel: 0
		 *		 - Yes: 1, No: 0
		 *		 - Ok: 1, Cancel: 0
		 *		 - Ok: 0
		 */
		displayAlert: (dialogText, icon, buttons) => 0,
		/**
		 * Brings this document to the foreground (if necessary to return after displaying a dialog)
		 */
		activate: () => 0,
		/**
		 * Determines whether a field can be inserted at the current position.
		 * @param {String} fieldType
		 * @returns {Boolean}
		 */
		canInsertField: (fieldType) => true,
		/**
		 * Returns the field in which the cursor resides, or NULL if none.
		 * @param {String} fieldType
		 * @returns {Boolean}
		 */
		cursorInField: (fieldType) => false,
		/**
		 * Get document data property from the current document
		 * @returns {String}
		 */
		getDocumentData: function () {return this.data},
		/**
		 * Set document data property
		 * @param {String} data
		 */
		setDocumentData: function (data) {this.data = data},
		/**
		 * Inserts a field at cursor position and initializes the field object.
		 * If Document.insertText() was called previously inserts the field
		 * directly after the inserted text.
		 * @param {String} fieldType
		 * @param {Integer} noteType
		 * @returns {DocumentPluginDummy.Field}
		 */
		insertField: function (fieldType, noteType) {
			if (typeof noteType != "number") {
				throw new Error("noteType must be an integer");
			}
			var field = new DocumentPluginDummy.Field(this, this.fieldIdx++);
			this.fields.push(field);
			return field;
		},
		/**
		 * Inserts rich text at cursor position. If Document.insertField() was called
		 * previously inserts the text directly after the inserted field.
		 * @param {String} text
		 */
		insertText: function (text) { return; },
		/**
		 * Converts placeholders (which are text with links to https://www.zotero.org/?[placeholderID])
		 * to fields and sets their field codes to strings in `codes` in the reverse order of their appearance
		 * @param {String[]} codes
		 * @param {String[]} placeholderIDs - the order of placeholders to be replaced
		 * @param {Number} noteType - controls whether citations should be in-text or in footnotes/endnotes
		 * @param {String} fieldType
		 * @return {Field[]}
		 */
		convertPlaceholdersToFields: function (codes, placeholderIDs, noteType, fieldType) {
			return codes.map(code => {
				let field = new DocumentPluginDummy.Field(this);
				field.code = code;
				this.fields.push(field);
				return field;
			});
		},
		/**
		 * Gets all fields present in the document.
		 * @param {String} fieldType
		 * @returns {DocumentPluginDummy.Field[]}
		 */
		getFields: function (fieldType) {return Array.from(this.fields)},
		/**
		 * Sets the bibliography style, overwriting the current values for this document
		 */
		setBibliographyStyle: (firstLineIndent, bodyIndent, lineSpacing, entrySpacing,
			tabStops, tabStopsCount) => 0,
		/**
		 * Converts all fields in a document to a different fieldType or noteType
		 * @params {DocumentPluginDummy.Field[]} fields
		 */
		convert: (fields, toFieldType, toNoteType, count) => 0,
		/**
		 * Cleans up the document state and resumes processor for editing
		 */
		cleanup: () => 0,

		/**
		 * Informs the document processor that the operation is complete
		 */
		complete: () => 0,
		
		/**
		 * Converts field text in document to their underlying codes and appends
		 * document preferences and bibliography style as paragraphs at the end
		 * of the document. Prefixes:
		 * 	- Bibliography style: "BIBLIOGRAPHY_STYLE "
		 * 	- Document preferences: "DOCUMENT_PREFERENCES "
		 * 	
		 * 	All Zotero exported text must be converted to a hyperlink
		 * 	(with any url, e.g. http://www.zotero.org)
		 */
		exportDocument: (fieldType) => 0,
		
		/**
		 * Converts a document from an exported form described in #exportDocument()
		 * to a Zotero editable form. Bibliography Style and Document Preferences
		 * text is removed and stored internally within the doc. The citation codes are
		 * also stored within the doc in appropriate representation. 
		 * 
		 * Note that no citation text updates are needed. Zotero will issue field updates 
		 * manually.
		 * 
		 * @returns {Boolean} whether the document contained importable data
		 */
		importDocument: (fieldType) => 0,
	};

	/**
	 * The Field class corresponds to a field containing an individual citation
	 * or bibliography
	 */
	DocumentPluginDummy.Field = function (doc, idx) {
		this.doc = doc;
		this.code = '';
		// This is actually required and current integration code depends on text being non-empty upon insertion.
		// insertBibliography will fail if there is no placeholder text.
		this.text = '{Placeholder}';
		this.idx = idx;
		this.adjacent = false;
		this.wrappedJSObject = this;
	};
	DocumentPluginDummy.Field.noteIndex = 0;
	DocumentPluginDummy.Field.prototype = {
		/**
		 * Deletes this field and its contents.
		 */
		delete: function () {this.doc.fields = this.doc.fields.filter((field) => field !== this)},
		/**
		 * Removes this field, but maintains the field's contents.
		 */
		removeCode: function () {this.code = ""},
		/**
		 * Selects this field.
		 */
		select: () => 0,
		/**
		 * Sets the text inside this field to a specified plain text string or pseudo-RTF formatted text
		 * string.
		 * @param {String} text
		 * @param {Boolean} isRich
		 */
		setText: function (text, isRich) {this.text = text},
		/**
		 * Gets the text inside this field, preferably with formatting, but potentially without
		 * @returns {String}
		 */
		getText: function () {return this.text},
		/**
		 * Sets field's code
		 * @param {String} code
		 */
		setCode: function (code) {this.code = code},
		/**
		 * Gets field's code.
		 * @returns {String}
		 */
		getCode: function () {return this.code},
		/**
		 * Returns true if this field and the passed field are actually references to the same field.
		 * @param {DocumentPluginDummy.Field} field
		 * @returns {Boolean}
		 */
		equals: function (field) {return this == field},
		/**
		 * This field's note index, if it is in a footnote or endnote; otherwise zero.
		 * @returns {Number}
		 */
		getNoteIndex: () => 0,
		
		/**
		 * Whether this field is adjacent to the next field (meaning they can be merged).
		 * @returns {boolean}
		 */
		isAdjacentToNextField: function () { return this.adjacent },
	};

	// Processing functions for logging and promisification
	for (let cls of ['Application', 'Document', 'Field']) {
		for (let methodName in DocumentPluginDummy[cls].prototype) {
			if (methodName !== 'QueryInterface') {
				let method = DocumentPluginDummy[cls].prototype[methodName];
				DocumentPluginDummy[cls].prototype[methodName] = async function () {
					try {
						if (cls == 'Field') {
							Zotero.debug(`DocumentPluginDummy: ${cls}[${this.idx}].${methodName} invoked with args ${JSON.stringify(arguments)}`, 2);
						}
						else {
							Zotero.debug(`DocumentPluginDummy: ${cls}.${methodName} invoked with args ${JSON.stringify(arguments)}`, 2);
						}
					} catch (e) {
						Zotero.debug(`DocumentPluginDummy: ${cls}.${methodName} invoked with args ${arguments}`, 2);
					}
					var result = method.apply(this, arguments);
					try {
						Zotero.debug(`Result: ${JSON.stringify(result)}`, 2);
					} catch (e) {
						Zotero.debug(`Result: ${result}`, 2);
					}
					return result;
				}
			}
		}
	}
	
	var testItems;
	var applications = {};
	var addEditCitationSpy, displayDialogStub;
	var styleID = "http://www.zotero.org/styles/apa";
	var stylePath = OS.Path.join(getTestDataDirectory().path, 'apa.csl');

	var commandList = [
		'addCitation', 'editCitation', 'addEditCitation',
		'addBibliography', 'editBibliography', 'addEditBibliography',
		'refresh', 'removeCodes', 'setDocPrefs'
	];
	
	function execCommand(command, docID) {
		if (! commandList.includes(command)) {
			throw new Error(`${command} is not a valid document command`);
		}
		if (typeof docID === "undefined") {
			throw new Error(`docID cannot be undefined`)
		}
		Zotero.debug(`execCommand '${command}': ${docID}`, 2);
		return Zotero.Integration.execCommand("dummy", command, docID);
	}
	
	var dialogResults = {
		addCitationDialog: {},
		citationDialog: {},
		integrationDocPrefs: {},
		selectItemsDialog: {},
		editBibliographyDialog: {}
	};
	
	async function initDoc(docID, options={}) {
		applications[docID] = new DocumentPluginDummy.Application();
		var data = new Zotero.Integration.DocumentData();
		data.prefs = {
			noteType: 0,
			fieldType: "Field",
			automaticJournalAbbreviations: true
		};
		data.style = {styleID, locale: 'en-US', hasBibliography: true, bibliographyStyleHasBeenSet: true};
		data.sessionID = Zotero.Utilities.randomString(10);
		Object.assign(data, options);
		await (await applications[docID].getDocument(docID)).setDocumentData(data.serialize());
	}
	
	function setDefaultIntegrationDocPrefs() {
		dialogResults.integrationDocPrefs = {
			style: styleID,
			locale: 'en-US',
			fieldType: 'Field',
			automaticJournalAbbreviations: false,
			useEndnotes: 0
		};
	}
	setDefaultIntegrationDocPrefs();
	
	function setAddEditItems(items) {
		if (items.length == undefined) items = [items];
		dialogResults.citationDialog = async function (dialogName, io) {
			io.citation.citationItems = items.map(function (item) {
				item = Zotero.Cite.getItem(item.id);
				return {id: item.id, uris: item.cslURIs, itemData: item.cslItemData};
			});
			try {
				await io.previewFn(io.citation);
			}
			catch (e) {}
			io._acceptDeferred.resolve(() => {});
		};
	}
	
	async function insertMultipleCitations() {
		var docID = this.test.fullTitle();
		if (!(docID in applications)) await initDoc(docID);
		var doc = applications[docID].doc;

		setAddEditItems(testItems[0]);
		await execCommand('addEditCitation', docID);
		assert.equal(doc.fields.length, 1);
		var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
		assert.equal(citation.citationItems.length, 1);
		assert.equal(citation.citationItems[0].id, testItems[0].id);

		setAddEditItems(testItems.slice(1, 3));
		await execCommand('addEditCitation', docID);
		assert.equal(doc.fields.length, 2);
		citation = await (new Zotero.Integration.CitationField(doc.fields[1], doc.fields[1].code)).unserialize();
		assert.equal(citation.citationItems.length, 2);
		for (let i = 1; i < 3; i++) {
			assert.equal(citation.citationItems[i-1].id, testItems[i].id);
		}
	};
	
	before(function* () {
		yield Zotero.Styles.init();
		yield Zotero.Styles.install({file: stylePath}, styleID, true);

		testItems = [];
		for (let i = 0; i < 5; i++) {
			let testItem = yield createDataObject('item', {libraryID: Zotero.Libraries.userLibraryID});
			testItem.setField('title', `title${i}`);
			testItem.setCreator(0, {creatorType: 'author', name: `Author No${i}`});
			testItems.push(testItem);
		}
		setAddEditItems(testItems[0]);
		
		sinon.stub(Zotero.Integration, 'getApplication').callsFake(function (agent, command, docID) {
			if (!applications[docID]) {
				applications[docID] = new DocumentPluginDummy.Application();
			}
			return applications[docID];
		});
		
		displayDialogStub = sinon.stub(Zotero.Integration, 'displayDialog');
		displayDialogStub.callsFake(async function (dialogName, prefs, io, windowType) {
			Zotero.debug(`Display dialog: ${dialogName}`, 2);
			var ioResult = dialogResults[dialogName.substring(dialogName.lastIndexOf('/')+1, dialogName.length-6)];
			if (typeof ioResult == 'function') {
				await ioResult(dialogName, io, windowType);
			} else {
				Object.assign(io, ioResult);
			}
		});
		
		addEditCitationSpy = sinon.spy(Zotero.Integration.Interface.prototype, 'addEditCitation');
		
		sinon.stub(Zotero.Integration.Progress.prototype, 'show');
	});
	
	after(function () {
		Zotero.Integration.Progress.prototype.show.restore();
		Zotero.Integration.getApplication.restore();
		displayDialogStub.restore();
		addEditCitationSpy.restore();
	});
	
	describe('Interface', function () {
		describe('#execCommand', function () {
			var setDocumentDataSpy;
			var docID = this.fullTitle();
			
			before(function () {
				setDocumentDataSpy = sinon.spy(DocumentPluginDummy.Document.prototype, 'setDocumentData');
			});
			
			afterEach(function () {
				setDocumentDataSpy.resetHistory();
			});
			
			after(function () {
				setDocumentDataSpy.restore();
			});
			
			it('should call doc.setDocumentData once', async function () {
				await execCommand('addEditCitation', docID);
				assert.isTrue(setDocumentDataSpy.calledOnce);
			});
			
			describe('when style used in the document does not exist', function () {
				var docID = this.fullTitle();
				var displayAlertStub;
				var style;
				before(function* () {
					displayAlertStub = sinon.stub(DocumentPluginDummy.Document.prototype, 'displayAlert').resolves(0);
				});
				
				beforeEach(async function () {
					// 🦉birds?
					style = {styleID: "http://www.example.com/csl/waterbirds", locale: 'en-US'};
					
					// Make sure style not in library
					try {
						Zotero.Styles.get(style.styleID).remove();
					} catch (e) {}
					await initDoc(docID, {style});
					displayDialogStub.resetHistory();
					displayAlertStub.resetHistory();
				});
				
				after(function* () {
					displayAlertStub.restore();
				});
			
				describe('when the style is not from a trusted source', function () {
					it('should download the style and if user clicks YES', async function () {
						var styleInstallStub = sinon.stub(Zotero.Styles, "install").resolves({
							styleTitle: 'Waterbirds',
							styleID: 'waterbirds'
						});
						var style = Zotero.Styles.get(styleID);
						var styleGetCalledOnce = false;
						var styleGetStub = sinon.stub(Zotero.Styles, 'get').callsFake(function () {
							if (!styleGetCalledOnce) {
								styleGetCalledOnce = true;
								return false;
							}
							return style;
						});
						displayAlertStub.resolves(1);
						await execCommand('addEditCitation', docID);
						assert.isTrue(displayAlertStub.calledOnce);
						assert.isFalse(displayDialogStub.calledWith(applications[docID].doc, 'chrome://zotero/content/integration/integrationDocPrefs.xul'));
						assert.isTrue(styleInstallStub.calledOnce);
						assert.isOk(Zotero.Styles.get(style.styleID));
						styleInstallStub.restore();
						styleGetStub.restore();
					});
					
					it('should prompt with the document preferences dialog if user clicks NO', async function () {
						displayAlertStub.resolves(0);
						await execCommand('addEditCitation', docID);
						assert.isTrue(displayAlertStub.calledOnce);
						// Prefs to select a new style
						assert.isTrue(displayDialogStub.calledTwice);
						assert.isNotOk(Zotero.Styles.get(style.styleID));
					});	
				});
					
				it('should download the style without prompting if it is from zotero.org', async function () {
					await initDoc(docID, {styleID: "http://www.zotero.org/styles/waterbirds", locale: 'en-US'});
					var styleInstallStub = sinon.stub(Zotero.Styles, "install").resolves({
						styleTitle: 'Waterbirds',
						styleID: 'waterbirds'
					});
					var style = Zotero.Styles.get(styleID);
					var styleGetCalledOnce = false;
					var styleGetStub = sinon.stub(Zotero.Styles, 'get').callsFake(function () {
						if (!styleGetCalledOnce) {
							styleGetCalledOnce = true;
							return false;
						}
						return style;
					});
					displayAlertStub.resolves(1);
					await execCommand('addEditCitation', docID);
					assert.isFalse(displayAlertStub.called);
					assert.isFalse(displayDialogStub.calledWith(applications[docID].doc, 'chrome://zotero/content/integration/integrationDocPrefs.xul'));
					assert.isTrue(styleInstallStub.calledOnce);
					assert.isOk(Zotero.Styles.get(style.styleID));
					styleInstallStub.restore();
					styleGetStub.restore();	
				});
			});
		});
		
		describe('#shouldAbortCommand', function () {
			it('should return false if no command is running', async function () {
				assert.isFalse(await Zotero.Integration.shouldAbortCommand());
			});

			it('should return false if an integration dialog is open but is pristine', async function () {
				await insertMultipleCitations.call(this);
				let docID = this.test.fullTitle();
				let citationDialogOpenedDeferred = Zotero.Promise.defer();
				let citationDialogCancelledDeferred = Zotero.Promise.defer();
				dialogResults.citationDialog = async function (dialogName, io) {
					Zotero.Integration.currentWindow = { isPristine: true, focus: () => 0, cancel: citationDialogCancelledDeferred.resolve };
					citationDialogOpenedDeferred.resolve();
					await citationDialogCancelledDeferred.promise;
					io._acceptDeferred.resolve(() => {});
					Zotero.Integration.currentWindow = null;
				};
				let firstCommandPromise = execCommand('addEditCitation', docID);
				await citationDialogOpenedDeferred.promise;
				assert.isFalse(await Zotero.Integration.shouldAbortCommand());
				await citationDialogCancelledDeferred.promise;
				await firstCommandPromise;
			});
			
			it('should display an alert and return true if an integration command without a dialog is running', async function () {
				let stub = sinon.stub();
				let promptService = Services.prompt;
				Services.prompt = { alert: stub };
				try {
					await insertMultipleCitations.call(this);
					let docID = this.test.fullTitle();
					let citationDialogOpenedDeferred = Zotero.Promise.defer();
					let citationDialogDeferred = Zotero.Promise.defer();
					dialogResults.citationDialog = async function (dialogName, io) {
						citationDialogOpenedDeferred.resolve();
						await citationDialogDeferred.promise;
						io._acceptDeferred.resolve(() => {});
					};
					let firstCommandPromise = execCommand('addEditCitation', docID);
					await citationDialogOpenedDeferred.promise;
					assert.isTrue(await Zotero.Integration.shouldAbortCommand());
					assert.isTrue(stub.called);
					citationDialogDeferred.resolve({});
					await firstCommandPromise;
				}
				finally {
					Services.prompt = promptService;
				}
			});

			it('should display a confirmation dialog if an integration dialog is open and not pristine', async function () {
				let stub = sinon.stub().returns(0);
				let promptService = Services.prompt;
				Services.prompt = { confirmEx: stub };
				try {
					await insertMultipleCitations.call(this);
					let docID = this.test.fullTitle();
					let citationDialogOpenedDeferred = Zotero.Promise.defer();
					let citationDialogCancelledDeferred = Zotero.Promise.defer();
					dialogResults.citationDialog = async function (dialogName, io, windowType) {
						Zotero.Integration.currentWindow = { isPristine: false, focus: () => 0, cancel: citationDialogCancelledDeferred.resolve };
						Zotero.Integration.currentWindowType = windowType;
						citationDialogOpenedDeferred.resolve();
						await citationDialogCancelledDeferred.promise;
						io._acceptDeferred.resolve(() => {});
						Zotero.Integration.currentWindow = null;
					};
					let firstCommandPromise = execCommand('addEditCitation', docID);
					await citationDialogOpenedDeferred.promise;
					assert.isFalse(await Zotero.Integration.shouldAbortCommand());
					assert.isTrue(stub.called);
					await citationDialogCancelledDeferred.promise;
					await firstCommandPromise;
				}
				finally {
					Services.prompt = promptService;
				}
			});
		});
		
		describe('#addEditCitation', function () {
			it('should insert citation if not in field', insertMultipleCitations);

			it('should edit citation if in citation field', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				var doc = applications[docID].doc;

				sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
				sinon.stub(doc, 'canInsertField').resolves(false);

				setAddEditItems(testItems.slice(3, 5));
				await execCommand('addEditCitation', docID);
				assert.equal(doc.fields.length, 2);
				var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
				assert.equal(citation.citationItems.length, 2);
				assert.equal(citation.citationItems[0].id, testItems[3].id);
			});
			
			it('should write an implicitly updated citation into the document', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				var doc = applications[docID].doc;

				testItems[3].setCreator(0, {creatorType: 'author', lastName: 'Smith', firstName: 'Robert'});
				testItems[3].setField('date', '2019-01-02');

				setAddEditItems(testItems[3]);
				await execCommand('addEditCitation', docID);
				assert.equal(doc.fields[2].text, "(Smith, 2019)");

				sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
				sinon.stub(doc, 'canInsertField').resolves(false);

				testItems[4].setCreator(0, {creatorType: 'author', lastName: 'Smith', firstName: 'Robert'});
				testItems[4].setField('date', '2019-01-01');

				setAddEditItems(testItems[4]);
				await execCommand('addEditCitation', docID);
				assert.equal(doc.fields.length, 3);
				assert.equal(doc.fields[0].text, "(Smith, 2019a)");
				assert.equal(doc.fields[2].text, "(Smith, 2019b)");
			});

			it('should place an implicitly updated citation correctly after multiple new insertions', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				var doc = applications[docID].doc;

				testItems[3].setCreator(0, {creatorType: 'author', lastName: 'Smith', firstName: 'Robert'});
				testItems[3].setField('date', '2019-01-02');

				setAddEditItems(testItems[3]);
				await execCommand('addEditCitation', docID);
				assert.equal(doc.fields[2].text, "(Smith, 2019)");

				sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
				sinon.stub(doc, 'canInsertField').resolves(false);

				doc.fields[1].code = doc.fields[0].code;
				doc.fields[1].text = doc.fields[0].text;

				testItems[4].setCreator(0, {creatorType: 'author', lastName: 'Smith', firstName: 'Robert'});
				testItems[4].setField('date', '2019-01-01');

				setAddEditItems(testItems[4]);
				await execCommand('addEditCitation', docID);
				assert.equal(doc.fields.length, 3);
				assert.equal(doc.fields[0].text, "(Smith, 2019a)");
				assert.equal(doc.fields[2].text, "(Smith, 2019b)");
			});

			it('should update bibliography if present', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				var doc = applications[docID].doc;
				
				let getCiteprocBibliographySpy =
					sinon.spy(Zotero.Integration.Bibliography.prototype, 'getCiteprocBibliography');
					
				await execCommand('addEditBibliography', docID);
				assert.isTrue(getCiteprocBibliographySpy.calledOnce);
				
				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 3);
				getCiteprocBibliographySpy.resetHistory();

				setAddEditItems(testItems[3]);
				await execCommand('addEditCitation', docID);
				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 4);

				getCiteprocBibliographySpy.restore();
			});

			it('should update bibliography sort order on change to item', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				var doc = applications[docID].doc;

				let getCiteprocBibliographySpy =
					sinon.spy(Zotero.Integration.Bibliography.prototype, 'getCiteprocBibliography');

				await execCommand('addEditBibliography', docID);
				assert.isTrue(getCiteprocBibliographySpy.calledOnce);

				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 3);
				getCiteprocBibliographySpy.resetHistory();

				sinon.stub(doc, 'cursorInField').resolves(doc.fields[1]);
				sinon.stub(doc, 'canInsertField').resolves(false);

				testItems[1].setCreator(0, {creatorType: 'author', name: 'Aaaaa'});
				testItems[1].setField('title', 'Bbbbb');

				setAddEditItems(testItems.slice(1, 3));
				await execCommand('addEditCitation', docID);

				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[0].entry_ids.length, 3);
				assert.equal(getCiteprocBibliographySpy.lastCall.returnValue[1][0], "Aaaaa. (n.d.). {\\i{}Bbbbb}.");

				getCiteprocBibliographySpy.restore();
			});
			
			describe('when original citation text has been modified', function () {
				var displayAlertStub;
				before(function* () {
					displayAlertStub = sinon.stub(DocumentPluginDummy.Document.prototype, 'displayAlert').resolves(0);
				});	
				beforeEach(function () {
					displayAlertStub.resetHistory();
				});
				after(function () {
					displayAlertStub.restore();
				});
				it('should keep modification if "Cancel" selected in editCitation triggered alert', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;

					doc.fields[0].text = "modified";
					sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
					sinon.stub(doc, 'canInsertField').resolves(false);

					await execCommand('addEditCitation', docID);
					assert.equal(doc.fields.length, 2);
					assert.equal(doc.fields[0].text, "modified");
				});
				it('should display citation dialog if "OK" selected in editCitation triggered alert', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;

					let origText = doc.fields[0].text;
					doc.fields[0].text = "modified";
					// Return OK
					displayAlertStub.resolves(1);
					sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
					sinon.stub(doc, 'canInsertField').resolves(false);
					setAddEditItems(testItems[0]);

					await execCommand('addEditCitation', docID);
					assert.isTrue(displayAlertStub.called);
					assert.equal(doc.fields.length, 2);
					assert.equal(doc.fields[0].text, origText);
				});
				it('should set dontUpdate: true if "yes" selected in refresh prompt', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;

					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
					assert.isNotOk(citation.properties.dontUpdate);
					doc.fields[0].text = "modified";
					// Return Yes
					displayAlertStub.resolves(1);

					await execCommand('refresh', docID);
					assert.isTrue(displayAlertStub.called);
					assert.equal(doc.fields.length, 2);
					assert.equal(doc.fields[0].text, "modified");
					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
					assert.isOk(citation.properties.dontUpdate);
				});
				it('should reset citation text if "no" selected in refresh prompt', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;

					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
					assert.isNotOk(citation.properties.dontUpdate);
					let origText = doc.fields[0].text;
					doc.fields[0].text = "modified";
					// Return No
					displayAlertStub.resolves(0);

					await execCommand('refresh', docID);
					assert.isTrue(displayAlertStub.called);
					assert.equal(doc.fields.length, 2);
					assert.equal(doc.fields[0].text, origText);
					var citation = await (new Zotero.Integration.CitationField(doc.fields[0], doc.fields[0].code)).unserialize();
					assert.isNotOk(citation.properties.dontUpdate);
				});
			});

			it('should detect items cited with Mendeley if they are imported into Zotero', async function () {
				var docID = this.test.fullTitle();
				if (!(docID in applications)) await initDoc(docID);
				var doc = applications[docID].doc;

				let testItem = await createDataObject('item', {libraryID: Zotero.Libraries.userLibraryID});
				testItem.setField('title', `Mendeley imported`);
				testItem.setCreator(0, {creatorType: 'author', name: `Mendeleev, Dmitri `});
				testItem.addRelation('mendeleyDB:documentUUID', 'e213167f-af42-4ff1-95e8-a9aa6b0b3e1b');
				await testItem.saveTx();

				setAddEditItems(testItem);
				await execCommand('addEditCitation', docID);

				let uri = doc.fields[0].code.match(/"uris":\[([^\]]*)]/)[1];
				doc.fields[0].code = doc.fields[0].code.replace(/"uris":\[[^\]]*]/, `"uris":["http://www.mendeley.com/documents/?uuid=e213167f-af42-4ff1-95e8-a9aa6b0b3e1b"]`);

				sinon.stub(doc, 'canInsertField').resolves(false);
				sinon.stub(doc, 'cursorInField').resolves(doc.fields[0]);
				await execCommand('addEditCitation', docID);
				assert.include(doc.fields[0].code, 'http://www.mendeley.com/documents/?uuid=e213167f-af42-4ff1-95e8-a9aa6b0b3e1b');
				assert.include(doc.fields[0].code, Zotero.URI.getItemURI(testItem));
			});
			
			describe('when there are copy-pasted citations', function () {
				it('should resolve duplicate citationIDs and mark both as new citations', async function () {
					var docID = this.test.fullTitle();
					if (!(docID in applications)) await initDoc(docID);
					var doc = applications[docID].doc;

					setAddEditItems(testItems[0]);
					await execCommand('addEditCitation', docID);
					assert.equal(doc.fields.length, 1);
					// Add a duplicate
					doc.fields.push(new DocumentPluginDummy.Field(doc));
					doc.fields[1].code = doc.fields[0].code;
					doc.fields[1].text = doc.fields[0].text;
					
					var originalUpdateDocument = Zotero.Integration.Session.prototype.updateDocument;
					var stubUpdateDocument = sinon.stub(Zotero.Integration.Session.prototype, 'updateDocument');
					try {
						var indicesLength;
						stubUpdateDocument.callsFake(function () {
							indicesLength = Object.keys(this.newIndices).length;
							return originalUpdateDocument.apply(this, arguments);
						});

						setAddEditItems(testItems[1]);
						await execCommand('addEditCitation', docID);
						assert.equal(indicesLength, 3);
					} finally {
						stubUpdateDocument.restore();
					}
				});
				
				it('should successfully process citations copied in from another doc', async function () {
					var docID = this.test.fullTitle();
					if (!(docID in applications)) await initDoc(docID);
					var doc = applications[docID].doc;

					setAddEditItems(testItems[0]);
					await execCommand('addEditCitation', docID);
					assert.equal(doc.fields.length, 1);
					doc.fields.push(new DocumentPluginDummy.Field(doc));
					// Add a "citation copied from somewhere else"
					// the content doesn't really matter, just make sure that the citationID is different
					var newCitationID = Zotero.Utilities.randomString();
					doc.fields[1].code = doc.fields[0].code;
					doc.fields[1].code = doc.fields[1].code.replace(/"citationID":"[A-Za-z0-9^"]*"/,
						`"citationID":"${newCitationID}"`);
					doc.fields[1].text = doc.fields[0].text;
					
					var originalUpdateDocument = Zotero.Integration.Session.prototype.updateDocument;
					var stubUpdateDocument = sinon.stub(Zotero.Integration.Session.prototype, 'updateDocument');
					try {
						var indices;
						stubUpdateDocument.callsFake(function () {
							indices = Object.keys(this.newIndices);
							return originalUpdateDocument.apply(this, arguments);
						});

						setAddEditItems(testItems[1]);
						await execCommand('addEditCitation', docID);
						assert.equal(indices.length, 2);
						assert.include(indices, '1');
						assert.include(indices, '2');
					} finally {
						stubUpdateDocument.restore();
					}
				});
				
				it('should successfully insert a citation after canceled citation insert', async function () {
					var docID = this.test.fullTitle();
					if (!(docID in applications)) await initDoc(docID);
					var doc = applications[docID].doc;

					setAddEditItems(testItems[0]);
					await execCommand('addEditCitation', docID);
					assert.equal(doc.fields.length, 1);
					doc.fields.push(new DocumentPluginDummy.Field(doc));
					// Add a "citation copied from somewhere else"
					// the content doesn't really matter, just make sure that the citationID is different
					var newCitationID = Zotero.Utilities.randomString();
					doc.fields[1].code = doc.fields[0].code;
					doc.fields[1].code = doc.fields[1].code.replace(/"citationID":"[A-Za-z0-9^"]*"/,
						`"citationID":"${newCitationID}"`);
					doc.fields[1].text = doc.fields[0].text;
					
					setAddEditItems([]);
					await execCommand('addEditCitation', docID);

					setAddEditItems(testItems[1]);
					await execCommand('addEditCitation', docID);
					assert.notEqual(doc.fields[2].code, "TEMP");
				});
			});
			
			describe('when delayCitationUpdates is set', function () {
				it('should insert a citation with wave underlining', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;
					var data = new Zotero.Integration.DocumentData(doc.data);
					data.prefs.delayCitationUpdates = true;
					doc.data = data.serialize();
					
					var setTextSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setText');
					setAddEditItems(testItems[3]);
					await execCommand('addEditCitation', docID);
					assert.isTrue(setTextSpy.lastCall.args[0].includes('\\uldash'));
					
					setTextSpy.restore();
				});
				
				it('should not write to any other fields besides the one being updated', async function () {
					await insertMultipleCitations.call(this);
					var docID = this.test.fullTitle();
					var doc = applications[docID].doc;
					var data = new Zotero.Integration.DocumentData(doc.data);
					data.prefs.delayCitationUpdates = true;
					doc.data = data.serialize();

					var setTextSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setText');
					var setCodeSpy = sinon.spy(DocumentPluginDummy.Field.prototype, 'setCode');
					
					setAddEditItems(testItems[3]);
					await execCommand('addEditCitation', docID);
					var field = setTextSpy.firstCall.thisValue;
					
					for (let i = 0; i < setTextSpy.callCount; i++) {
						assert.isTrue(await field.equals(setTextSpy.getCall(i).thisValue));
					}

					for (let i = 0; i < setCodeSpy.callCount; i++) {
						assert.isTrue(await field.equals(setCodeSpy.getCall(i).thisValue));
					}

					setTextSpy.restore();
					setCodeSpy.restore();
				})
			});
			
			describe('with retracted items', function () {
				it('should display a retraction warning for a retracted item in a document', async function () {
					var docID = this.test.fullTitle();
					await initDoc(docID);
					var doc = applications[docID].doc;
					setAddEditItems(testItems[0]);
					await execCommand('addEditCitation', docID);

					let stub1 = sinon.stub(Zotero.Retractions, 'isRetracted').returns(true);
					let stub2 = sinon.stub(Zotero.Retractions, 'shouldShowCitationWarning').returns(true);

					let promise = execCommand('refresh', docID);
					await waitForDialog();
					
					stub1.restore();
					stub2.restore();
					await promise;
				});
				
				it('should display a retraction warning for an embedded retracted item in a document', async function () {
					var docID = this.test.fullTitle();
					await initDoc(docID);
					var doc = applications[docID].doc;
					let testItem = await createDataObject('item', { libraryID: Zotero.Libraries.userLibraryID });
					testItem.setField('title', `embedded title`);
					testItem.setCreator(0, { creatorType: 'author', name: `Embedded Author` });
					setAddEditItems(testItem);
					await execCommand('addEditCitation', docID);
					await testItem.eraseTx();

					let stub = sinon.stub(Zotero.Retractions, 'getRetractionsFromJSON').resolves([0]);

					let promise = execCommand('refresh', docID);
					await waitForDialog();
					
					stub.restore();
					await promise;
				});
				
				it('should not display retraction warning when disabled for a retracted item', async function () {
					var docID = this.test.fullTitle();
					await initDoc(docID);
					var doc = applications[docID].doc;
					let testItem = await createDataObject('item', { libraryID: Zotero.Libraries.userLibraryID });
					testItem.setField('title', `title`);
					testItem.setCreator(0, { creatorType: 'author', name: `Author` });
					await Zotero.Retractions.disableCitationWarningsForItem(testItem);
					setAddEditItems(testItem);
					await execCommand('addEditCitation', docID);

					await execCommand('refresh', docID);
					
					await testItem.eraseTx();
				});
			});
		});
		
		describe('#addEditBibliography', function () {
			var docID = this.fullTitle();
			beforeEach(function* () {
				setAddEditItems(testItems[0]);
				yield initDoc(docID);
				yield execCommand('addEditCitation', docID);
			});
			
			it('should insert bibliography if no bibliography field present', async function () {
				displayDialogStub.resetHistory();
				await execCommand('addEditBibliography', docID);
				assert.isFalse(displayDialogStub.called);
				var biblPresent = false;
				for (let i = applications[docID].doc.fields.length-1; i >= 0; i--) {
					let field = await Zotero.Integration.Field.loadExisting(applications[docID].doc.fields[i]);
					if (field.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
						biblPresent = true;
						break;
					}
				}
				assert.isTrue(biblPresent);
			});
			
			it('should display the edit bibliography dialog if bibliography present', async function () {
				await execCommand('addEditBibliography', docID);
				displayDialogStub.resetHistory();
				await execCommand('addEditBibliography', docID);
				assert.isTrue(displayDialogStub.calledOnce);
				assert.isTrue(displayDialogStub.lastCall.args[0].includes('editBibliographyDialog'));
			});
		});
		
		describe('#refresh', function () {
			it ('should properly disambiguate author after editing in the database', async function () {
				var docID = this.test.fullTitle();
				let testItem1 = await createDataObject('item', {libraryID: Zotero.Libraries.userLibraryID});
				testItem1.setField('title', `title1`);
				testItem1.setCreator(0, {creatorType: 'author', firstName: "Foo", lastName: "Bar"});
				testItem1.setField('date', '2022-01-01');
				let testItem2 = await createDataObject('item', {libraryID: Zotero.Libraries.userLibraryID});
				testItem2.setField('title', `title2`);
				testItem2.setCreator(0, {creatorType: 'author', firstName: "Foo", lastName: "Bar"});
				testItem2.setField('date', '2022-01-01');
				setAddEditItems([testItem1, testItem2]);
				await initDoc(docID);
				await execCommand('addEditCitation', docID);
				assert.equal(applications[docID].doc.fields[0].text, '(Bar, 2022a, 2022b)');
				
				testItem2.setCreator(0, {creatorType: 'author', firstName: "Foo F", lastName: "Bar"});
				await execCommand('refresh', docID);
				assert.equal(applications[docID].doc.fields[0].text, '(F. Bar, 2022; F. F. Bar, 2022)');
				
				testItem2.setCreator(0, {creatorType: 'author', firstName: "Foo", lastName: "Bar"});
				await execCommand('refresh', docID);
				assert.equal(applications[docID].doc.fields[0].text, '(Bar, 2022a, 2022b)');
			});
			
			it ('should merge adjacent fields', async function () {
				await insertMultipleCitations.call(this);
				var docID = this.test.fullTitle();
				assert.equal(applications[docID].doc.fields.length, 2);
				
				applications[docID].doc.fields[0].adjacent = true;
				await execCommand('refresh', docID);
				assert.equal(applications[docID].doc.fields.length, 1);
			});
		});
	});
	
	describe("CitationField", function () {
		describe('#unserialize', function () {
			it('should recover All Caps corrupted citation field', async function () {
				// Simulate Mac Word "All Caps" formatting corrupting field code JSON.
				// All text (keys and string values) is uppercased; numbers/booleans unaffected.
				let testItem = testItems[0];
				let allCapsCode = 'ITEM CSL_CITATION '
					+ JSON.stringify({
						CITATIONID: "ABCD1234",
						PROPERTIES: {
							FORMATTEDCITATION: "(AUTHOR 2025)",
							PLAINCITATION: "(AUTHOR 2025)",
							NOTEINDEX: 0
						},
						CITATIONITEMS: [{
							ID: testItem.id,
							URIS: ["HTTP://ZOTERO.ORG/USERS/12345/ITEMS/XXXXXXXX"],
							ITEMDATA: {
								ID: testItem.id,
								TYPE: "ARTICLE-JOURNAL",
								TITLE: "SOME TITLE"
							}
						}],
						SCHEMA: "HTTPS://GITHUB.COM/CITATION-STYLE-LANGUAGE/SCHEMA/RAW/MASTER/CSL-CITATION.JSON"
					});
				
				let field = new DocumentPluginDummy.Field(
					new DocumentPluginDummy.Document()
				);
				field.code = allCapsCode;
				field.text = "(Author 2025)";
				
				let citationField = new Zotero.Integration.CitationField(field, allCapsCode);
				let citation = await citationField.unserialize();
				
				// citationID recovered
				assert.equal(citation.citationID, "ABCD1234");
				// properties recovered
				assert.equal(citation.properties.noteIndex, 0);
				// plainCitation and formattedCitation deleted to avoid changed-citation dialog
				assert.notProperty(citation.properties, 'plainCitation');
				assert.notProperty(citation.properties, 'formattedCitation');
				// citationItems recovered with integer id
				assert.equal(citation.citationItems.length, 1);
				assert.equal(citation.citationItems[0].id, testItem.id);
				// Corrupted URIs and itemData removed
				assert.notProperty(citation.citationItems[0], 'uris');
				assert.notProperty(citation.citationItems[0], 'itemData');
				assert.notProperty(citation.citationItems[0], 'itemdata');
			});
			
			it('should preserve non-string properties in All Caps corrupted citation', async function () {
				let testItem = testItems[0];
				let allCapsCode = 'ITEM CSL_CITATION '
					+ JSON.stringify({
						CITATIONID: "EFGH5678",
						PROPERTIES: {
							NOTEINDEX: 3,
							UNSORTED: true,
							DONTUPDDATE: true
						},
						CITATIONITEMS: [{
							ID: testItem.id,
							URIS: ["HTTP://ZOTERO.ORG/USERS/12345/ITEMS/XXXXXXXX"]
						}],
						SCHEMA: "HTTPS://GITHUB.COM/CITATION-STYLE-LANGUAGE/SCHEMA/RAW/MASTER/CSL-CITATION.JSON"
					});
				
				let field = new DocumentPluginDummy.Field(
					new DocumentPluginDummy.Document()
				);
				field.code = allCapsCode;
				field.text = "(Author 2025)";
				
				let citationField = new Zotero.Integration.CitationField(field, allCapsCode);
				let citation = await citationField.unserialize();
				
				assert.equal(citation.properties.noteIndex, 3);
				assert.isTrue(citation.properties.unsorted);
				// dontUpdate should NOT be preserved -- we need to update since
				// the presentation is corrupted
				assert.notProperty(citation.properties, 'dontUpdate');
			});
		});
	});
	
	describe("DocumentData", function () {
		it('should properly unserialize old XML document data', function () {
			var serializedXMLData = `<data data-version="3" zotero-version="5.0.SOURCE"><session id="F0NFmZ32"/><style id="${styleID}" hasBibliography="1" bibliographyStyleHasBeenSet="1"/><prefs><pref name="fieldType" value="ReferenceMark"/><pref name="automaticJournalAbbreviations" value="true"/><pref name="noteType" value="0"/></prefs></data>`;
			var data = new Zotero.Integration.DocumentData(serializedXMLData);
			var expectedData = {
				style: {
					styleID,
					locale: null,
					hasBibliography: true,
					bibliographyStyleHasBeenSet: true
				},
				prefs: {
					fieldType: 'ReferenceMark',
					automaticJournalAbbreviations: true,
					noteType: 0
				},
				sessionID: 'F0NFmZ32',
				zoteroVersion: '5.0.SOURCE',
				dataVersion: '3'
			};
			// Convert to JSON to remove functions from DocumentData object
			assert.equal(JSON.stringify(data), JSON.stringify(expectedData));
		});
		
		it('should properly unserialize JSON document data', function () {
			var expectedData = JSON.stringify({
				style: {
					styleID,
					locale: 'en-US',
					hasBibliography: true,
					bibliographyStyleHasBeenSet: true
				},
				prefs: {
					fieldType: 'ReferenceMark',
					automaticJournalAbbreviations: false,
					noteType: 0
				},
				sessionID: 'owl-sesh',
				zoteroVersion: '5.0.SOURCE',
				dataVersion: 4
			});
			var data = new Zotero.Integration.DocumentData(expectedData);
			// Convert to JSON to remove functions from DocumentData object
			assert.equal(JSON.stringify(data), expectedData);
		});
		
		it('should properly serialize document data to XML (data ver 3)', function () {
			sinon.spy(Zotero, 'debug');
			var data = new Zotero.Integration.DocumentData();
			data.sessionID = "owl-sesh";
			data.zoteroVersion = Zotero.version;
			data.dataVersion = 3;
			data.style = {
				styleID,
				locale: 'en-US',
				hasBibliography: false,
				bibliographyStyleHasBeenSet: true
			};
			data.prefs = {
				noteType: 1,
				fieldType: "Field",
				automaticJournalAbbreviations: true
			};
			
			var serializedData = data.serialize();
			// Make sure we serialized to XML here
			assert.equal(serializedData[0], '<');
			// Serialize and unserialize (above test makes sure unserialize works properly).
			var processedData = new Zotero.Integration.DocumentData(serializedData);
			
			// This isn't ideal, but currently how it works. Better serialization which properly retains types
			// coming with official 5.0 release.
			data.dataVersion = "3";

			// Convert to JSON to remove functions from DocumentData objects
			assert.equal(JSON.stringify(processedData), JSON.stringify(data));
			
			// Make sure we are not triggering debug traces in Utilities.htmlSpecialChars()
			assert.isFalse(Zotero.debug.calledWith(sinon.match.string, 1));
			Zotero.debug.restore();
		});
		
		it('should properly serialize document data to JSON (data ver 4)', function () {
			var data = new Zotero.Integration.DocumentData();
			// data version 4 triggers serialization to JSON
			// (e.g. when we've retrieved data from the doc and it was ver 4 already)
			data.dataVersion = 4;
			data.sessionID = "owl-sesh";
			data.style = {
				styleID,
				locale: 'en-US',
				hasBibliography: false,
				bibliographyStyleHasBeenSet: true
			};
			data.prefs = {
				noteType: 1,
				fieldType: "Field",
				automaticJournalAbbreviations: true
			};
			
			// Serialize and unserialize (above tests makes sure unserialize works properly).
			var processedData = new Zotero.Integration.DocumentData(data.serialize());

			// Added in serialization routine
			data.zoteroVersion = Zotero.version;
			
			// Convert to JSON to remove functions from DocumentData objects
			assert.deepEqual(JSON.parse(JSON.stringify(processedData)), JSON.parse(JSON.stringify(data)));
		});
	})
});
