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

var { Subprocess } = ChromeUtils.importESModule("resource://gre/modules/Subprocess.sys.mjs");
var { RemoteTranslate } = ChromeUtils.importESModule("chrome://zotero/content/RemoteTranslate.mjs");

var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs');
var { TranslatorTester } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/translate/testTranslators/translatorTester.mjs');
var { Test } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/translate/testTranslators/test.mjs');
var { ZoteroWebTranslationEnvironment } = ChromeUtils.importESModule('chrome://scaffold/content/zoteroWebTranslationEnvironment.mjs');

var { CompletionCopilot, registerCompletion } = ChromeUtils.importESModule('resource://zotero/monacopilot.mjs');

var lazy = {};
ChromeUtils.defineLazyGetter(lazy, 'shellPathPromise', () => {
	return Zotero.Utilities.Internal.subprocess(Services.env.get('SHELL'), ['-c', 'echo $PATH'])
		.then(s => s.trimEnd());
});

// Text to display in monaco editors when they are empty
const CODE_TAB_INFO = `1. Click the "+" toolbar button above to choose a template for the translator.
2. Use Run detect* and Run do* buttons in the toolbar to run your translator on the URL loaded in the Browser tab.
3. Use Help → List All Item Types and Help → List All Fields for Item Type for information on the item types and fields your translator can return.`;
const TESTS_TAB_INFO = ` After the translator is implemented in the Code tab, create tests from the Browser (web translator) and Test Input (import/search translators) tabs.`;

var Scaffold = new function () {
	var _browser;
	var _translatorsLoadedPromise;
	var _translatorProvider = null;
	var _loadedTranslatorPath = null;
	var _lastModifiedTime = 0;
	var _needRebuildTranslatorSuggestions = true;
	var _invalidateCodeLenses = null;
	var _copilot;
	var _prefsObserverID;
	
	var _editors = {};

	var _browserProgressListener = null;
	var _persistentCookieContext = null;

	var _propertyMap = {
		'textbox-translatorID': 'translatorID',
		'textbox-label': 'label',
		'textbox-creator': 'creator',
		'textbox-target': 'target',
		'textbox-minVersion': 'minVersion',
		'textbox-priority': 'priority',
		'textbox-target-all': 'targetAll',
		'textbox-hidden-prefs': 'hiddenPrefs'
	};

	/** @type {{
	 * 		test: Test;
	 * 		testString: string;
	 * 		updatedTestString?: string;
	 * 		status?: string;
	 * 		previews: ScaffoldItemPreview[];
	 * }[]} */
	var _testData = [];
	var _linesOfMetadata = 15;

	this.handleLoad = async function () {
		_browser = document.getElementById('browser');

		window.messageManager.addMessageListener('Scaffold:Load', ({ data }) => {
			document.getElementById("browser-url").value = data.url;
		});

		window.messageManager.loadFrameScript('chrome://scaffold/content/content.js', true);
		
		let browserUrl = document.getElementById("browser-url");
		browserUrl.addEventListener('keydown', (e) => {
			if (e.key == 'Enter') {
				this.loadURLInBrowser();
			}
		});
		// Progress listener to record when the webpage is loaded
		_browserProgressListener = {
			QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener", "nsISupportsWeakReference"]),
			onStateChange(webProgress, request, stateFlags, status) {
				let done = stateFlags & Ci.nsIWebProgressListener.STATE_STOP;
				// Hide the spinner when done
				if (done) {
					_setBrowserLoadingIndicator(false);
				}
			},
		};
		_browser.addProgressListener(_browserProgressListener, Ci.nsIWebProgress.NOTIFY_STATE_ALL);

		document.getElementById('tabpanels').addEventListener('select', event => this.handleTabSelect(event));
		document.getElementById('tabs').addEventListener('mousedown', (event) => {
			// Record if tab selection will happen due to a mouse click vs keyboard nav.
			if (event.clientX === 0 && event.clientY === 0) return;
			document.getElementById('tabs').setAttribute("clicked", true);
		}, true);
		// Record that click has happened for better focus-ring handling in the stylesheet
		document.addEventListener("mouseup", (_) => {
			document.getElementById('tabs').removeAttribute("clicked");
		});
		
		let lastTranslatorID = Zotero.Prefs.get('scaffold.lastTranslatorID');
		if (lastTranslatorID) {
			document.getElementById("textbox-translatorID").value = lastTranslatorID;
			document.getElementById("textbox-label").value = 'Loading…';
		}
		else {
			this.generateTranslatorID();
		}
		
		// Add List fields help menu entries for all other item types
		var types = Zotero.ItemTypes.getAll().map(t => t.name).sort();
		var morePopup = document.getElementById('mb-help-fields-more-popup');
		var primaryTypes = ['book', 'bookSection', 'conferencePaper', 'journalArticle', 'magazineArticle', 'newspaperArticle'];
		for (let type of types) {
			if (primaryTypes.includes(type)) continue;
			var menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', type);
			menuitem.addEventListener('command', () => {
				Scaffold.addTemplate('templateNewItem', type);
			});
			morePopup.appendChild(menuitem);
		}
		
		if (!Scaffold_Translators.getDirectory()) {
			if (!await this.promptForTranslatorsDirectory()) {
				window.close();
				return;
			}
		}

		var importWin = document.getElementById("editor-import").contentWindow;
		var codeWin = document.getElementById("editor-code").contentWindow;
		var testsWin = document.getElementById("editor-tests").contentWindow;

		await Promise.all([
			importWin.loadMonaco({ language: 'plaintext' }).then(({ monaco, editor }) => {
				_editors.importGlobal = monaco;
				_editors.import = editor;
			}),
			codeWin.loadMonaco({ language: 'javascript', emptyOverlayMessage: CODE_TAB_INFO }).then(({ monaco, editor }) => {
				_editors.codeGlobal = monaco;
				_editors.code = editor;
			}),
			testsWin.loadMonaco({
				language: 'json',
				// Tests might contain \u2028/\u2029 - don't pop up a warning
				unusualLineTerminators: 'off',
				emptyOverlayMessage: TESTS_TAB_INFO
			}).then(({ monaco, editor }) => {
				_editors.testsGlobal = monaco;
				_editors.tests = editor;
			}),
		]);

		this.initImportEditor();
		this.initCodeEditor();
		this.initTestsEditor();

		this.addEditorKeydownHandlers(_editors.import);
		this.addEditorKeydownHandlers(_editors.code);
		this.addEditorKeydownHandlers(_editors.tests);

		// Set font size from general pref
		Zotero.UIProperties.registerRoot(document.getElementById('scaffold-pane'));

		// Set font size of code editor
		var size = Zotero.Prefs.get("scaffold.fontSize");
		if (size) {
			this.setFontSize(size);
		}

		// Listen for Scaffold coming to the foreground and reload translators
		window.addEventListener('activate', () => this.reloadTranslators());

		Scaffold_Translators.setLoadListener({
			onLoadBegin: () => {
				document.getElementById('cmd_load').setAttribute('disabled', true);
			},

			onLoadComplete: () => {
				document.getElementById('cmd_load').removeAttribute('disabled');
				_needRebuildTranslatorSuggestions = true;
			}
		});
		
		_translatorsLoadedPromise = Scaffold_Translators.load();
		_translatorProvider = Scaffold_Translators.getProvider();
		
		if (lastTranslatorID) {
			this.load(lastTranslatorID).then((success) => {
				if (!success) {
					Zotero.Prefs.clear('scaffold.lastTranslatorID');
					this.newTranslator(true);
				}
			});
		}
		
		_prefsObserverID = Zotero.Prefs.registerObserver('scaffold.completions.mistralAPIKey', _handleAPIKeyChange);
		_handleAPIKeyChange();
	};
	
	this.handleUnload = function () {
		Zotero.Prefs.unregisterObserver(_prefsObserverID);
		if (_persistentCookieContext) {
			_persistentCookieContext.dispose();
			_persistentCookieContext = null;
		}
	};
	
	this.promptForTranslatorsDirectory = async function () {
		var ps = Services.prompt;
		var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
			+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING
			+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING;
		var index = ps.confirmEx(null,
			"Scaffold",
			"To set up Scaffold, select your development directory for Zotero translators.\n\n"
				+ "In most cases, this should be a git clone of the zotero/translators GitHub repository.",
			buttonFlags,
			"Choose Directory…",
			Zotero.getString('general.cancel'),
			"Open GitHub Repo", null, {}
		);
		// Revert to home directory
		if (index == 0) {
			let dir = await this.setTranslatorsDirectory();
			if (dir) {
				return true;
			}
		}
		else if (index == 2) {
			Zotero.launchURL('https://github.com/zotero/translators');
		}
		return false;
	};
	
	this.setTranslatorsDirectory = async function () {
		var fp = new FilePicker();
		var oldPath = Zotero.Prefs.get('scaffold.translatorsDir');
		if (oldPath) {
			fp.displayDirectory = oldPath;
		}
		fp.init(
			window,
			"Select Translators Directory",
			fp.modeGetFolder
		);
		fp.appendFilters(fp.filterAll);
		if (await fp.show() != fp.returnOK) {
			return false;
		}
		var path = PathUtils.normalize(fp.file);
		if (oldPath == path) {
			return false;
		}
		Zotero.Prefs.set('scaffold.translatorsDir', path);
		Scaffold_Translators.load(true); // async
		return path;
	};

	this.reloadTranslators = async function () {
		Zotero.debug('Reloading translators quietly');
		let { numLoaded, numDeleted } = await Scaffold_Translators.load(true);
		if (numLoaded) {
			_logOutput(`${numLoaded} ${Zotero.Utilities.pluralize(numLoaded, 'translator')} updated.`);
		}
		if (numDeleted) {
			_logOutput(`${numDeleted} ${Zotero.Utilities.pluralize(numDeleted, 'translator')} deleted.`);
		}

		let translatorID = document.getElementById('textbox-translatorID').value;
		let modifiedTime = Scaffold_Translators.getModifiedTime(translatorID);
		if (modifiedTime && modifiedTime > _lastModifiedTime) {
			_lastModifiedTime = modifiedTime;
			
			let ps = Services.prompt;
			let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
				+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
			var index = ps.confirmEx(null,
				"Scaffold",
				"Translator code changed externally. Discard unsaved changes and reload?",
				buttonFlags,
				Zotero.getString('general.no'),
				Zotero.getString('general.yes'),
				null, null, {}
			);
			if (index == 1) {
				await this.load(translatorID);
			}
		}
		
		_updateTitle();
	};

	this.initImportEditor = function () {
		let monaco = _editors.importGlobal, editor = _editors.import;
		// Nothing to do here
	};

	this.initCodeEditor = async function () {
		let monaco = _editors.codeGlobal, editor = _editors.code;
		
		// For some reason, even if we explicitly re-set the default model's language to JavaScript,
		// Monaco still treats it as TypeScript. Recreating the model manually fixes the issue.
		let model = monaco.editor.createModel('', 'javascript', monaco.Uri.parse('inmemory:///translator.js'));
		editor.setModel(model);

		editor.updateOptions({
			lineNumbers: num => num + _linesOfMetadata - 1,
		});

		monaco.languages.registerCodeLensProvider('javascript', this.createRunCodeLensProvider(monaco, editor));
		monaco.languages.registerHoverProvider('javascript', this.createHoverProvider(monaco, editor));
		monaco.languages.registerCompletionItemProvider('javascript', this.createCompletionProvider(monaco, editor));
		model.onDidChangeContent(() => this.updateModelMarkers());

		let tsLib = await Zotero.File.getContentsAsync(
			PathUtils.join(Scaffold_Translators.getDirectory(), 'index.d.ts'));
		let tsLibPath = 'ts:filename/index.d.ts';
		monaco.languages.typescript.javascriptDefaults.addExtraLib(tsLib, tsLibPath);
		// this would allow peeking:
		//   monaco.editor.createModel(tsLib, 'typescript', monaco.Uri.parse(tsLibPath));
		// but it doesn't currently seem to work

		registerCompletion(monaco, editor, {
			language: 'javascript',
			endpoint: 'dummy',
			async requestHandler({ body }) {
				if (!_copilot) {
					return { completion: null };
				}
				
				let metadata = _getMetadataObject();
				Object.assign(body.completionMetadata, {
					filename: `${metadata.label}.js [body]`,
					technologies: ['zotero/translators'],
					relatedFiles: [
						{
							path: 'index.d.ts',
							content: tsLib
						},
						{
							path: `${metadata.label}.js [metadata]`,
							content: JSON.stringify(metadata, null, '\t')
						},
						metadata.translatorType & Zotero.Translator.TRANSLATOR_TYPES.import && {
							path: `example_import.${metadata.target || 'dat'}`,
							content: _getImport()
						},
						metadata.translatorType & Zotero.Translator.TRANSLATOR_TYPES.search && {
							path: `example_search.json`,
							content: _getImport()
						},
						{
							path: _browser.currentURI.spec,
							content: await _browser.browsingContext.currentWindowGlobal.getActor('PageData')
								.sendQuery('documentHTML')
						}
					].filter(Boolean)
				});
				return _copilot.complete({ body });
			},
		});
	};

	this.initTestsEditor = function () {
		let monaco = _editors.testsGlobal, editor = _editors.tests;

		monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
			validate: true,
			allowComments: false,
			trailingCommas: false,
			schemaValidation: 'error'
		});

		editor.getModel().updateOptions({
			insertSpaces: false
		});

		editor.getModel().onDidChangeContent((_) => {
			this.populateTests();
		});

		editor.updateOptions({
			links: false,
			stickyScroll: { enabled: false }
		});

		monaco.languages.registerCodeLensProvider('json', this.createTestCodeLensProvider(monaco, editor));
	};

	this.createRunCodeLensProvider = function (monaco, editor) {
		let runMethod = editor.addCommand(0, (_ctx, method) => this.run(method), '');

		return {
			provideCodeLenses: (model, _token) => {
				let methodRe = '(async\\s+)?\\bfunction\\s+(detect|do)(Web|Import|Export|Search)\\s*\\(';
				let lenses = [];

				let matches = model.findMatches(
					methodRe,
					/* searchOnlyEditableRange: */ false,
					/* isRegex: */ true,
					/* matchCase: */ true,
					/* wordSeparators: */ null,
					/* captureMatches: */ true
				);

				for (let match of matches) {
					let line = match.matches[0];
					let methodName = line.match(/function\s+(\w*)/)[1];
					lenses.push({
						range: match.range,
						command: {
							id: runMethod,
							title: `Run ${methodName}`,
							arguments: [methodName]
						}
					});
				}

				return { lenses, dispose() {} };
			},
			resolveCodeLens: (_model, codeLens, _token) => codeLens
		};
	};

	this.createHoverProvider = function (monaco, _editor) {
		let uuidRe = `(["'])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\\1`;
		let types = Zotero.ItemTypes.getTypes().map(t => t.name);
		let itemTypeRe = `(["'])(${types.join('|')})\\1`;

		return {
			provideHover: (model, position) => {
				let lineRange = new monaco.Range(
					position.lineNumber,
					model.getLineMinColumn(position.lineNumber),
					position.lineNumber,
					model.getLineMaxColumn(position.lineNumber)
				);

				let matches = model.findMatches(
					uuidRe,
					/* searchScope: */ lineRange,
					/* isRegex: */ true,
					/* matchCase: */ true,
					/* wordSeparators: */ null,
					/* captureMatches: */ true
				);

				for (let uuidMatch of matches) {
					if (!uuidMatch.range.containsPosition(position)) continue;
					let translator = _translatorProvider.get(uuidMatch.matches[2]);

					if (translator) {
						let metadataJSON = JSON.stringify(translator.metadata, null, '\t');

						return {
							range: uuidMatch.range,
							contents: [
								{ value: `**${translator.label}**` },
								{ value: '```json\n' + metadataJSON + '\n```' }
							]
						};
					}
				}

				matches = model.findMatches(
					itemTypeRe,
					/* searchScope: */ lineRange,
					/* isRegex: */ true,
					/* matchCase: */ true,
					/* wordSeparators: */ null,
					/* captureMatches: */ true
				);

				for (let itemTypeMatch of matches) {
					if (!itemTypeMatch.range.containsPosition(position)) continue;

					let fieldsJSON = this.listFieldsForItemType(itemTypeMatch.matches[2]);
					return {
						range: itemTypeMatch.range,
						contents: [
							{ value: '```json\n' + fieldsJSON + '\n```' }
						]
					};
				}

				return null;
			}
		};
	};

	this.createTestCodeLensProvider = function (monaco, editor) {
		let runCommand = editor.addCommand(
			0,
			async (_ctx, testIndices) => {
				testIndices = testIndices || [..._loadTestsFromPane().keys()];
				await this.runTests(testIndices);
			},
			'');

		let applyUpdatesCommand = editor.addCommand(
			0,
			(_ctx, testIndices) => {
				testIndices = testIndices || [..._loadTestsFromPane().keys()];
				this.updateTests(testIndices);
			},
			'');

		return {
			onDidChange: (callback) => {
				_invalidateCodeLenses = callback;
				return { dispose() {} };
			},
			
			provideCodeLenses: (model, _token) => {
				let testsWithUpdates = new Set();
				for (let [testIndex, { updatedTestString }] of _testData.entries()) {
					if (updatedTestString) {
						testsWithUpdates.add(testIndex);
					}
				}
				
				let lenses = [];

				let firstChar = {
					startLineNumber: 1,
					startColumn: 1,
					endLineNumber: 1,
					endColumn: 1
				};
				if (testsWithUpdates.size) {
					lenses.push({
						range: firstChar,
						command: {
							id: runCommand,
							title: 'Run All'
						}
					});
				}
				
				if (testsWithUpdates.size) {
					lenses.push({
						range: firstChar,
						command: {
							id: applyUpdatesCommand,
							title: 'Apply All Updates'
						}
					});
				}

				for (let [testIndex, range] of _findTestObjectTops(monaco, model).entries()) {
					lenses.push({
						range: range,
						command: {
							id: runCommand,
							title: 'Run',
							arguments: [[testIndex]]
						}
					});

					if (testsWithUpdates.has(testIndex)) {
						lenses.push({
							range: range,
							command: {
								id: applyUpdatesCommand,
								title: 'Apply Updates',
								arguments: [[testIndex]]
							}
						});
					}
				}

				return { lenses, dispose() {} };
			},
			resolveCodeLens: (_model, codeLens, _token) => codeLens
		};
	};
	
	this.createCompletionProvider = function (monaco, editor) {
		let suggestions = null;
		return {
			provideCompletionItems(model, position) {
				let prefixText = model.getValueInRange({
					startLineNumber: position.lineNumber,
					startColumn: 1,
					endLineNumber: position.lineNumber,
					endColumn: position.column
				});
				if (/setTranslator\([^)]*$/.test(prefixText)) {
					let word = model.getWordUntilPosition(position);
					let range = {
						startLineNumber: position.lineNumber,
						endLineNumber: position.lineNumber,
						startColumn: word.startColumn,
						endColumn: word.endColumn
					};
					if (!suggestions || _needRebuildTranslatorSuggestions) {
						// Cache the suggestions minus the range field
						suggestions = [...Scaffold_Translators._translators.entries()].map(([id, meta]) => {
							return {
								label: `${meta.translator.label}: '${id}'`,
								kind: monaco.languages.CompletionItemKind.Constant,
								insertText: `'${id}'`
							};
						});
						_needRebuildTranslatorSuggestions = false;
					}
					// Add the range to each suggestion before returning
					return { suggestions: suggestions.map(s => ({ ...s, range })) };
				}
				return { suggestions: [] };
			}
		};
	};

	this.updateModelMarkers = Zotero.Utilities.debounce(async function () {
		let modelVersionId = _editors.code.getModel().getVersionId();
		let output = await runESLint();
		let markers = eslintOutputToModelMarkers(output, modelVersionId);
		_editors.codeGlobal.editor.setModelMarkers(_editors.code.getModel(), 'eslint', markers);
	}, 200);

	this.setFontSize = function (size) {
		var sizeWithPX = size + 'px';
		_editors.import.updateOptions({ fontSize: size + 1 }); // editor font needs to be a little bigger
		_editors.code.updateOptions({ fontSize: size + 1 });
		_editors.tests.updateOptions({ fontSize: size + 1 });
		document.getElementById("scaffold-pane").style.fontSize = sizeWithPX;
		if (size == 13) {
			// for the default value 13, clear the prefs
			Zotero.Prefs.clear('scaffold.fontSize');
		}
		else {
			Zotero.Prefs.set("scaffold.fontSize", size);
		}
	};

	this.increaseFontSize = function () {
		var currentSize = Zotero.Prefs.get("scaffold.fontSize") || 13;
		this.setFontSize(currentSize + 2);
	};
	this.decreaseFontSize = function () {
		var currentSize = Zotero.Prefs.get("scaffold.fontSize") || 13;
		this.setFontSize(currentSize - 2);
	};

	this.newTranslator = async function (skipSavePrompt) {
		if (!skipSavePrompt && _editors.code.getValue()) {
			let ps = Services.prompt;
			let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
				+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
			let label = document.getElementById('textbox-label').value;
			let index = ps.confirmEx(null,
				"Scaffold",
				`Do you want to save the changes you made to ${label}?`,
				buttonFlags,
				Zotero.getString('general.no'),
				Zotero.getString('general.yes'),
				null, null, {}
			);
			if (index == 1 && !await this.save()) {
				return;
			}
		}

		this.generateTranslatorID();
		document.getElementById('textbox-label').value = 'Untitled';
		document.getElementById('textbox-creator').value
			= document.getElementById('textbox-target').value
			= document.getElementById('textbox-target-all').value
			= document.getElementById('textbox-configOptions').value
			= document.getElementById('textbox-displayOptions').value
			= document.getElementById('textbox-hidden-prefs').value
			= '';
		document.getElementById('textbox-minVersion').value = '5.0';
		document.getElementById('textbox-priority').value = '100';
		document.getElementById('checkbox-import').checked = false;
		document.getElementById('checkbox-export').checked = false;
		document.getElementById('checkbox-web').checked = true;
		document.getElementById('checkbox-search').checked = false;
		// Use a sample regex as a default target
		document.getElementById("textbox-target").value = "^https?://www\\.example\\.org/";

		_editors.code.setValue('');
		_editors.tests.setValue('');

		this.populateTests();

		document.getElementById('textbox-label').focus();
		_showTab('metadata');
		
		_lastModifiedTime = new Date().getTime();
		_loadedTranslatorPath = null;
		
		_updateTitle();
	};

	/*
	 * load translator
	 */
	this.load = async function (translatorID) {
		await _translatorsLoadedPromise;

		var translator;
		if (translatorID === undefined) {
			var io = {};
			io.translatorProvider = _translatorProvider;
			io.url = io.rootUrl = _browser.currentURI.spec;
			window.openDialog("chrome://scaffold/content/load.xhtml",
				"_blank", "chrome,centerscreen,modal,resizable=no", io);
			translator = io.dataOut;
		}
		else {
			translator = _translatorProvider.get(translatorID);
		}

		// No translator was selected in the dialog.
		if (!translator) return false;

		for (let id in _propertyMap) {
			document.getElementById(id).value = translator[_propertyMap[id]] || "";
		}

		let rawCode = await _translatorProvider.getCodeForTranslator(translator);
		let { metadata, code, testCases } = _splitTranslator(rawCode);
		// Adjust the first line number
		_linesOfMetadata = metadata.split('\n').length;
		
		// Convert whitespace to tabs
		_editors.code.setValue(code);
		// Then go to line 1
		_editors.code.setPosition({ lineNumber: 1, column: 1 });
		
		// We don't use _writeTestsToPane here because we want to avoid _stringifyTests,
		// which assumes valid test data and will choke on/incorrectly "fix"
		// weird inputs that the user might want to fix manually.
		_writeToEditor(_editors.tests, testCases);
		// Clear selection
		_editors.tests.setSelection({
			startLineNumber: 1,
			endLineNumber: 1,
			startColumn: 1,
			endColumn: 1
		});
		// Set up the test running pane
		this.populateTests();

		// Set Test Input editor language based on translator metadata
		let language = 'plaintext';
		if (translator.translatorType & Zotero.Translator.TRANSLATOR_TYPES.import) {
			if (translator.target.includes('json')) {
				language = 'json';
			}
			else if (translator.target.includes('xml')) {
				language = 'xml';
			}
		}
		else if (translator.translatorType & Zotero.Translator.TRANSLATOR_TYPES.search) {
			language = 'json';
		}
		_editors.importGlobal.editor.setModelLanguage(_editors.import.getModel(), language);
		_editors.import.setPosition({ lineNumber: 1, column: 1 });
		
		// Reset configOptions and displayOptions before loading
		document.getElementById('textbox-configOptions').value = '';
		document.getElementById('textbox-displayOptions').value = '';

		if (translator.configOptions) {
			let configOptions = JSON.stringify(translator.configOptions);
			if (configOptions != '{}') {
				document.getElementById('textbox-configOptions').value = configOptions;
			}
		}
		if (translator.displayOptions) {
			let displayOptions = JSON.stringify(translator.displayOptions);
			if (displayOptions != '{}') {
				document.getElementById('textbox-displayOptions').value = displayOptions;
			}
		}

		// get translator type; might as well have some fun here
		var type = translator.translatorType;
		var types = ["import", "export", "web", "search"];
		for (var i = 2; i <= 16; i *= 2) {
			var mod = type % i;
			document.getElementById('checkbox-' + types.shift()).checked = !!mod;
			if (mod) type -= mod;
		}

		_loadedTranslatorPath = translator.path;
		_lastModifiedTime = new Date().getTime();
		
		Zotero.Prefs.set('scaffold.lastTranslatorID', translator.translatorID);
		
		_updateTitle();
		return true;
	};

	function _getMetadataObject() {
		var metadata = {
			translatorID: document.getElementById('textbox-translatorID').value,
			label: document.getElementById('textbox-label').value,
			creator: document.getElementById('textbox-creator').value,
			target: document.getElementById('textbox-target').value,
			minVersion: document.getElementById('textbox-minVersion').value,
			maxVersion: '',
			priority: parseInt(document.getElementById('textbox-priority').value)
		};
		
		// optional (hidden) metadata
		if (document.getElementById('textbox-target-all').value) {
			metadata.targetAll = document.getElementById('textbox-target-all').value;
		}
		if (document.getElementById('textbox-hidden-prefs').value) {
			metadata.hiddenPrefs = document.getElementById('textbox-hidden-prefs').value;
		}

		if (document.getElementById('textbox-configOptions').value) {
			metadata.configOptions = JSON.parse(document.getElementById('textbox-configOptions').value);
		}
		if (document.getElementById('textbox-displayOptions').value) {
			metadata.displayOptions = JSON.parse(document.getElementById('textbox-displayOptions').value);
		}

		// no option for this
		metadata.inRepository = true;

		metadata.translatorType = 0;
		if (document.getElementById('checkbox-import').checked) {
			metadata.translatorType += 1;
		}
		if (document.getElementById('checkbox-export').checked) {
			metadata.translatorType += 2;
		}
		if (document.getElementById('checkbox-web').checked) {
			metadata.translatorType += 4;
		}
		if (document.getElementById('checkbox-search').checked) {
			metadata.translatorType += 8;
		}
		
		if (document.getElementById('checkbox-web').checked) {
			// save browserSupport only for web tranlsators
			metadata.browserSupport = "gcsibv";
		}

		var date = new Date();
		metadata.lastUpdated = date.getUTCFullYear()
			+ "-" + Zotero.Utilities.lpad(date.getUTCMonth() + 1, '0', 2)
			+ "-" + Zotero.Utilities.lpad(date.getUTCDate(), '0', 2)
			+ " " + Zotero.Utilities.lpad(date.getUTCHours(), '0', 2)
			+ ":" + Zotero.Utilities.lpad(date.getUTCMinutes(), '0', 2)
			+ ":" + Zotero.Utilities.lpad(date.getUTCSeconds(), '0', 2);

		return metadata;
	}
	
	function _getCode() {
		var code = _editors.code.getValue();
		var tests = _editors.tests.getValue().trim();
		if (!tests || tests == '[]') tests = '[\n]'; // eslint wants a line break between the brackets

		code = code.trimEnd() + '\n\n/** BEGIN TEST CASES **/\nvar testCases = ' + tests + '\n/** END TEST CASES **/';
		return code;
	}

	/*
	 * save translator to database
	 */
	this.save = async function (updateZotero) {
		var metadata = _getMetadataObject();
		var code = _getCode();
		if (metadata.label === "Untitled") {
			_logOutput("Can't save an untitled translator.");
			return;
		}
		
		let newPath = _translatorProvider.getSavePath(metadata);
		if (_loadedTranslatorPath && newPath !== _loadedTranslatorPath) {
			try {
				await Zotero.File.removeIfExists(_loadedTranslatorPath);
			}
			catch (e) {
				Zotero.logError(e);
			}
			_loadedTranslatorPath = newPath;
			
			_logOutput(`Renamed to ${PathUtils.filename(newPath)}.`);
		}
		
		await _translatorProvider.save(metadata, code);
		
		if (updateZotero) {
			await Zotero.Translators.save(metadata, code);
			await Zotero.Translators.reinit();
		}

		_lastModifiedTime = new Date().getTime();

		await this.reloadTranslators();
	};

	/**
	 * If an editor is focused, trigger `editorTrigger` in it.
	 * Otherwise, run `fallbackCommand`.
	 */
	this.trigger = function (editorTrigger, fallbackCommand) {
		let activeEditor = _editors[_getActiveEditorName()];
		if (activeEditor) {
			activeEditor.trigger('Scaffold.trigger', editorTrigger);
		}
		else {
			// editMenuOverlay.js
			goDoCommand(fallbackCommand);
		}
	};

	this.handleTabSelect = function (event) {
		if (event.target.tagName != 'tabpanels') {
			return;
		}

		var tabs = document.getElementById('tabs');
		var tab = tabs.selectedItem.id.match(/^tab-(.+)$/)[1];
		let tabPanel = tabs.tabbox.selectedPanel;
		// The select event's default behavior is to focus the selected tab.
		// we don't want to prevent *all* of the event's default behavior,
		// but we do want to focus an element inside of tabpanel instead of the tab
		// (unless tabs are being navigated via keyboard)
		// so this stupid hack focuses the desired element after skipping a tick
		if (tabs.hasAttribute('clicked')) {
			setTimeout(() => {
				let toFocus = tabPanel.querySelector('[focus-on-tab-select]');
				if (toFocus) {
					toFocus.focus();
					// Activate editor that is being focused, if any
					if (toFocus.id.startsWith('editor-')) {
						_editors[tab].focus();
					}
				}
				else {
					// If no specific element set, just tab into the panel
					setTimeout(() => {
						Services.focus.moveFocus(window, tabs.selectedItem, Services.focus.MOVEFOCUS_FORWARD, 0);
					});
				}
			});
		}
		let codeTabBroadcaster = document.getElementById('code-tab-only');
		if (tab == 'code') {
			codeTabBroadcaster.removeAttribute('disabled');
		}
		else {
			codeTabBroadcaster.setAttribute('disabled', true);
		}
		
		if (tab == 'tests') {
			this.handleTestingListboxSelect();
		}
	};

	this.handleTestingContextMenuShowing = function () {
		let listbox = document.getElementById('testing-listbox');
		let selectedItems = Array.from(listbox.selectedItems);
		if (!selectedItems.length) return;
		let selectedIndices = selectedItems.map(item => listbox.getIndexOfItem(item));

		let editImport = document.getElementById('testing-editImport');
		let openURL = document.getElementById('testing-openURL');
		let applyUpdates = document.getElementById('testing-applyUpdates');
		if (selectedItems.length > 1) {
			editImport.disabled = true;
			openURL.disabled = true;
		}
		else {
			let { test } = _testData[selectedIndices[0]];
			editImport.disabled = test.type === 'web';
			openURL.disabled = test.type !== 'web';
		}
		applyUpdates.disabled = selectedIndices.some(idx => !_testData[idx].updatedTestString);
	};
	
	this.handleTestingListboxSelect = function () {
		let listbox = document.querySelector('#testing-listbox');
		let itemPreviews = document.querySelector('#item-previews');
		let previews = [];
		for (let item of listbox.selectedItems) {
			let index = listbox.getIndexOfItem(item);
			let testData = _testData[index];
			previews.push(...testData.previews);
		}
		itemPreviews.setPreviews(previews);
	};
	
	this.handleTestingListboxDblClick = function () {
		this.runSelectedTests();
	};

	// Add special keydown handling for the editors
	this.addEditorKeydownHandlers = function (editor) {
		let doc = editor.getDomNode().ownerDocument;
		// On shift-tab from the start of the first line, tab out of the editor.
		// Use capturing listener, since Shift-Tab keydown events do not propagate to the document.
		doc.addEventListener("keydown", (event) => {
			if (event.key == "Tab" && event.shiftKey) {
				let position = editor.getPosition();
				if (position.column == 1 && position.lineNumber == 1) {
					Services.focus.moveFocus(window, event.target, Services.focus.MOVEFOCUS_BACKWARD, 0);
					event.preventDefault();
				}
			}
		}, true);
	};


	this.listFieldsForItemType = function (itemType) {
		var outputObject = {};
		outputObject.itemType = Zotero.ItemTypes.getName(itemType);
		var typeID = Zotero.ItemTypes.getID(itemType);
		var fieldList = Zotero.ItemFields.getItemTypeFields(typeID);
		for (let field of fieldList) {
			var key = Zotero.ItemFields.getName(field);
			let fieldLocalizedName = Zotero.ItemFields.getLocalizedString(field);
			outputObject[key] = fieldLocalizedName;
		}
		var creatorList = Zotero.CreatorTypes.getTypesForItemType(typeID);
		var creators = [];
		for (let creatorType of creatorList) {
			creators.push({ firstName: "", lastName: "", creatorType: creatorType.name, fieldMode: 1 });
		}
		outputObject.creators = creators;
		outputObject.attachments = [{ url: "", document: {}, title: "", mimeType: "", snapshot: false }];
		outputObject.tags = [{ tag: "" }];
		outputObject.notes = [{ note: "" }];
		outputObject.seeAlso = [];
		return JSON.stringify(outputObject, null, '\t');
	};
	
	/*
	 * add template code
	 */
	this.addTemplate = async function (template, second) {
		switch (template) {
			case "templateNewItem":
				document.getElementById('output').value = this.listFieldsForItemType(second);
				break;
			case "templateAllTypes":
				var typeNames = Zotero.ItemTypes.getTypes().map(t => t.name);
				document.getElementById('output').value = JSON.stringify(typeNames, null, '\t');
				break;
			default: {
				//newWeb, scrapeEM, scrapeRIS, scrapeBibTeX, scrapeMARC
				//These names in the XUL file have to match the file names in template folder.
				let value = Zotero.File.getContentsFromURL(`chrome://scaffold/content/templates/${template}.js`);
				value = value.replace('$$YEAR$$', new Date().getFullYear());
				let cursorOffset = value.indexOf('$$CURSOR$$');
				value = value.replace('$$CURSOR$$', '');

				var selection = _editors.code.getSelection();
				var id = { major: 1, minor: 1 };
				var op = { identifier: id, range: selection, text: value, forceMoveMarkers: true };
				_editors.code.executeEdits("addTemplate", [op]);

				if (cursorOffset != -1) {
					_editors.code.setPosition(_editors.code.getModel().getPositionAt(cursorOffset));
				}

				break;
			}
		}
	};

	/**
	 * Load the typed URL in the browser tab.
	 */
	this.loadURLInBrowser = async function () {
		let url = document.getElementById("browser-url").value;
		Zotero.debug('Scaffold: Loading URL in browser: ' + url);

		_setBrowserLoadingIndicator(true);

		_browser.fixupAndLoadURIString(url, {
			triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal()
		});
	};

	/**
	 * Check if there is a webpage loaded in the browser tab when translator type is 'web'.
	 * If not, alert the user and navigate to the browser tab.
	 * @returns {boolean} True if a webpage is loaded for web translators, false otherwise.
	 */
	this.ensureWebpageLoadedIfNeeded = function () {
		if (_browser.currentURI.spec == 'about:blank' && document.getElementById('checkbox-web').checked) {
			Services.prompt.alert(null, "Webpage Not Loaded", "You need to load a webpage in the Browser tab first.");
			_showTab('browser');
			document.getElementById('browser-url').focus();
			return false;
		}
		return true;
	};

	/*
	 * run translator
	 */
	this.run = async function (functionToRun) {
		if (document.getElementById('textbox-label').value == 'Untitled') {
			_logOutput("Translator title not set");
			return;
		}

		_clearOutput();

		// Handle generic call run('detect'), run('do')
		if (functionToRun == "detect" || functionToRun == "do") {
			if (document.getElementById('checkbox-web').checked
				&& _browser.currentURI.spec != 'about:blank') {
				functionToRun += 'Web';
			}
			else if (document.getElementById('checkbox-import').checked
				&& _editors.import.getValue().trim()) {
				functionToRun += 'Import';
			}
			else if (document.getElementById('checkbox-export').checked
				&& functionToRun == 'do') {
				functionToRun += 'Export';
			}
			else if (document.getElementById('checkbox-search').checked
				&& _editors.import.getValue().trim()) {
				functionToRun += 'Search';
			}
			else {
				// If no function can be properly run, alert the user
				// if the translator type is web and no webpage is loaded
				if (!this.ensureWebpageLoadedIfNeeded()) return;
				_logOutput('No appropriate detect/do function to run');
				return;
			}
		}

		_logOutput(`Running ${functionToRun}`);
		
		let input = await _getInput(functionToRun);

		if (functionToRun.endsWith('Export')) {
			let numItems = Zotero.getActiveZoteroPane().getSelectedItems().length;
			_logOutput(`Exporting ${numItems} item${numItems == 1 ? '' : 's'} selected in library`);
			await _run(functionToRun, input, _selectItems, () => {}, _getTranslatorsHandler(functionToRun), _myExportDone);
		}
		else {
			await _run(functionToRun, input, _selectItems, _myItemDone, _getTranslatorsHandler(functionToRun));
		}
		
		_logOutput('Done');
	};

	/*
	 * run translator in given mode with given input
	 */
	async function _run(functionToRun, input, selectItems, itemDone, detectHandler, done) {
		let translate;
		let isRemoteWeb = false;
		if (functionToRun == "detectWeb" || functionToRun == "doWeb") {
			translate = new RemoteTranslate({ disableErrorReporting: true });
			isRemoteWeb = true;
			if (!_testTargetRegex(input)) {
				_logOutput("Target did not match " + _getCurrentURI(input));
				if (done) {
					done();
				}
				return;
			}
			await translate.setBrowser(input);
		}
		else if (functionToRun == "detectImport" || functionToRun == "doImport") {
			translate = new Zotero.Translate.Import();
			translate.setString(input);
		}
		else if (functionToRun == "doExport") {
			translate = new Zotero.Translate.Export();
			translate.setItems(input);
		}
		else if (functionToRun == "detectSearch" || functionToRun == "doSearch") {
			translate = new Zotero.Translate.Search();
			translate.setSearch(input);
		}
		translate.setTranslatorProvider(_translatorProvider);
		translate.setHandler("error", _error);
		translate.setHandler("debug", _debug);
		if (done) {
			translate.setHandler("done", done);
		}

		// get translator
		var translator = _getTranslatorFromPane();
		if (functionToRun.startsWith('detect')) {
			if (isRemoteWeb) {
				try {
					translate.setTranslator(translator);
					detectHandler(translate, await translate.detect());
				}
				finally {
					translate.dispose();
				}
			}
			else {
				// don't let target prevent translator from operating
				translator.target = null;
				// generate sandbox
				translate.setHandler("translators", detectHandler);
				// internal hack to call detect on this translator
				translate._potentialTranslators = [translator];
				translate._foundTranslators = [];
				translate._currentState = "detect";
				translate._detect();
			}
		}
		else if (isRemoteWeb) {
			try {
				translate.setHandler("select", selectItems);
				translate.setTranslator(translator);
				let items = await translate.translate({ libraryID: false });
				if (items) {
					for (let item of items) {
						itemDone(translate, item);
					}
				}
			}
			finally {
				translate.dispose();
			}
		}
		else {
			// don't let the detectCode prevent the translator from operating
			translator.detectCode = null;
			translate.setTranslator(translator);
			translate.setHandler("select", selectItems);
			translate.clearHandlers("itemDone");
			translate.clearHandlers("collectionDone");
			translate.setHandler("itemDone", itemDone);
			translate.setHandler("collectionDone", function (obj, collection) {
				_logOutput("Collection: " + collection.name + ", " + collection.children.length + " items");
			});
			translate.translate({
				// disable saving to database
				libraryID: false
			});
		}
	}

	this.runTranslatorOrTests = async function () {
		if (document.getElementById('tabs').selectedItem.id == 'tab-tests'
			&& document.activeElement.id == 'testing-listbox') {
			this.runSelectedTests();
		}
		else {
			this.run('do');
		}
	};

	/*
	 * generate translator GUID
	 */
	this.generateTranslatorID = function () {
		document.getElementById("textbox-translatorID").value = _generateGUID();
	};
	
	/**
	 * Test target regular expression against document URL and log the result
	 */
	this.logTargetRegex = async function () {
		if (!this.ensureWebpageLoadedIfNeeded()) return;
		_logOutput(_testTargetRegex(_browser));
	};
	
	/**
	 * Test target regular expression against document URL and return the result
	 */
	function _testTargetRegex(browser) {
		var url = _getCurrentURI(browser);
		
		try {
			var targetRe = new RegExp(document.getElementById('textbox-target').value, "i");
		}
		catch (e) {
			_logOutput("Regex parse error:\n" + JSON.stringify(e, null, "\t"));
		}
		
		return targetRe.test(url);
	}
	
	/*
	 * called to select items
	 */
	function _selectItems(obj, itemList, callback) {
		var io = { dataIn: itemList, dataOut: null };
		window.openDialog("chrome://scaffold/content/select.xhtml",
			"_blank", "chrome,modal,centerscreen,resizable=yes", io);
		callback(io.dataOut);
	}

	/*
	 * called if an error occurs
	 */
	function _error(obj, error) {
		_logOutput(String(error));
	}

	/*
	 * logs translator output (instead of logging in the console)
	 */
	function _debug(obj, string) {
		_logOutput(string);
	}

	/*
	 * logs item output
	 */
	function _myItemDone(obj, jsonItem) {
		let itemPreviews = document.querySelector('#item-previews');
		itemPreviews.addItemPair(jsonItem, null);
	}

	/*
	 * logs string output
	 */
	function _myExportDone({ string }, worked) {
		if (worked) {
			Zotero.debug("Export successful");
			_logOutput("Returned string:\n" + string);
		}
		else {
			Zotero.debug("Export failed");
		}
	}

	/*
	 * returns a 'translators' handler that prints information from detectCode to window
	 */
	function _getTranslatorsHandler(fnName) {
		return (obj, translators) => {
			if (translators && translators.length != 0) {
				if (translators[0].itemType === true) {
					_logOutput(`${fnName} matched`);
				}
				else {
					_logOutput(`${fnName} returned type "${translators[0].itemType}"`);
				}
			}
			else {
				_logOutput(`${fnName} did not match`);
			}
		};
	}

	/*
	 * logs debug info (instead of console)
	 */
	function _logOutput(string) {
		var date = new Date();
		var output = document.getElementById('output');

		if (typeof string != "string") {
			string = Zotero.Utilities.varDump(string);
		}

		// Put off actually building the log message and appending it to the console until the next animation frame
		// so as not to slow down translation with repeated layout recalculations triggered by appending text
		// and accessing scrollHeight
		// requestAnimationFrame() callbacks are guaranteed to be called in the order they were set
		requestAnimationFrame(() => {
			if (output.value) output.value += "\n";
			output.value += Zotero.Utilities.lpad(date.getHours(), '0', 2)
				+ ":" + Zotero.Utilities.lpad(date.getMinutes(), '0', 2)
				+ ":" + Zotero.Utilities.lpad(date.getSeconds(), '0', 2)
				+ " " + string.replace(/\n/g, "\n         ");
			// move to end
			output.scrollTop = output.scrollHeight;
		});
	}

	/*
	 * gets import text for import translator
	 */
	function _getImport() {
		var text = _editors.import.getValue();
		return text;
	}

	/*
	 * gets items to export for export translator
	 */
	function _getExport() {
		return Zotero.getActiveZoteroPane().getSelectedItems();
	}

	/*
	 * gets search JSON object for search translator
	 */
	function _getSearch() {
		return JSON.parse(_getImport());
	}

	/*
	 * gets appropriate input for the given type/method
	 */
	async function _getInput(typeOrMethod) {
		typeOrMethod = typeOrMethod.toLowerCase();
		if (typeOrMethod.endsWith('web')) {
			return _browser;
		}
		else if (typeOrMethod.endsWith('import')) {
			return _getImport();
		}
		else if (typeOrMethod.endsWith('export')) {
			return _getExport();
		}
		else if (typeOrMethod.endsWith('search')) {
			return _getSearch();
		}
		return null;
	}

	/*
	 * transfers metadata to the translator object
	 * Replicated from translator.js
	 */
	function _metaToTranslator(translator, metadata) {
		var props = ["translatorID",
			"translatorType",
			"label",
			"creator",
			"target",
			"minVersion",
			"maxVersion",
			"priority",
			"lastUpdated",
			"inRepository",
			"configOptions",
			"displayOptions",
			"browserSupport",
			"targetAll",
			"hiddenPrefs"];
		for (var i = 0; i < props.length; i++) {
			translator[props[i]] = metadata[props[i]];
		}
		
		if (!translator.configOptions) translator.configOptions = {};
		if (!translator.displayOptions) translator.displayOptions = {};
		if (!translator.browserSupport) translator.browserSupport = "g";
	}

	/*
	 * gets translator data from the metadata pane
	 */
	function _getTranslatorFromPane() {
		//create a barebones translator
		var translator = {};
		var metadata = _getMetadataObject(true);

		//copy metadata into the translator object
		_metaToTranslator(translator, metadata);

		metadata = JSON.stringify(metadata, null, "\t") + ";\n";

		translator.code = metadata + "\n" + _getCode();

		// make sure translator gets run in browser in Zotero >2.1
		if (Zotero.Translator.RUN_MODE_IN_BROWSER) {
			translator.runMode = Zotero.Translator.RUN_MODE_IN_BROWSER;
		}

		return translator;
	}

	/*
	 * loads the translator's tests from the translator code
	 */
	function _splitTranslator(code) {
		let metadata = code.substr(0, code.indexOf('"lastUpdated"') + 50)
			.match(/^\s*\{[\S\s]*?}\s*?[\r\n]+/)[0];
		code = code.substring(metadata.length);

		var testStart = code.indexOf("/** BEGIN TEST CASES **/");
		var testEnd = code.indexOf("/** END TEST CASES **/");

		let testCases;
		if (testStart !== -1 && testEnd !== -1) {
			testCases = code.substring(testStart + 24, testEnd);
			code = code.substring(0, testStart).trimEnd();
		}
		else {
			testCases = 'var testCases = [];';
		}

		testCases = testCases.replace(/^\s*var testCases = /, '').trim();
		// The JSON parser doesn't like final semicolons
		if (testCases.endsWith(';')) {
			testCases = testCases.slice(0, -1);
		}

		try {
			testCases = JSON.stringify(JSON.parse(testCases), null, '\t');
		}
		catch (e) {
			Zotero.logError(e);
		}
		
		code = normalizeWhitespace(code);
		
		return { metadata, code, testCases };
	}

	/*
	 * loads the translator's tests from the pane
	 */
	function _loadTestsFromPane() {
		try {
			return JSON.parse(_editors.tests.getValue().trim() || '[]')
				.map(test => new Test(test));
		}
		catch (e) {
			return null;
		}
	}

	/**
	 * Write text to an editor, overwriting its current value.
	 * This operation can be undone.
	 */
	function _writeToEditor(editor, text) {
		editor.executeEdits('_writeToEditor', [{
			range: editor.getModel().getFullModelRange(),
			text
		}]);
	}

	/*
	 * writes tests back into the translator
	 */
	function _writeTestsToPane(tests) {
		_writeToEditor(_editors.tests, _stringifyTests(tests));
	}
	
	/* stringifies an array of tests
	 * Output is the same as JSON.stringify (with pretty print), except that
	 * Zotero.Item objects are stringified in a deterministic manner (mostly):
	 *   * Certain important fields are placed at the top of the object
	 *   * Certain less-frequently used fields are placed at the bottom
	 *   * Remaining fields are sorted alphabetically
	 *   * tags are always sorted alphabetically
	 *   * Some fields, like those inside creator objects, notes, etc. are not sorted
	 */
	function _stringifyTests(value, level) {
		if (value instanceof Test) {
			value = value.toJSON();
		}
		
		function processRow(key, value) {
			let val = _stringifyTests(value, level + 1);
			if (val === undefined) return undefined;
			
			val = val.replace(/\n/g, "\n\t");
			return JSON.stringify('' + key) + ': ' + val;
		}

		if (!level) level = 0;
		
		if (typeof (value) == 'function' || typeof (value) == 'undefined' || value === null) {
			return level ? undefined : '';
		}
		
		if (typeof (value) !== 'object') return JSON.stringify(value, null, "\t");
		
		if (Array.isArray(value)) {
			let str = '[';
			for (let i = 0; i < value.length; i++) {
				let val = _stringifyTests(value[i], level + 1);
				
				if (val === undefined) val = 'undefined';
				else val = val.replace(/\n/g, "\n\t"); // Indent
				
				str += (i ? ',' : '') + "\n\t" + val;
			}
			return str + (str.length > 1 ? "\n]" : ']');
		}
		
		if (!value.itemType) {
			// Not a Zotero.Item object
			let str = '{';
						
			if (level < 2 && value.items) {
				// Test object. Arrange properties in set order
				let order = ['type', 'url', 'input', 'defer', 'detectedItemType', 'items'];
				for (let i = 0; i < order.length; i++) {
					let val = processRow(order[i], value[order[i]]);
					if (val === undefined) continue;
					str += (str.length > 1 ? ',' : '') + '\n\t' + val;
				}
			}
			else {
				for (let i in value) {
					let val = processRow(i, value[i]);
					if (val === undefined) continue;
					str += (str.length > 1 ? ',' : '') + '\n\t' + val;
				}
			}
			
			return str + (str.length > 1 ? "\n}" : '}');
		}
		
		// Zotero.Item object
		const topFields = ['itemType',
			'title',
			'caseName',
			'nameOfAct',
			'subject',
			'creators',
			'date',
			'dateDecided',
			'issueDate',
			'dateEnacted'];
		const bottomFields = ['attachments', 'tags', 'notes', 'seeAlso'];
		let otherFields = Object.keys(value);
		let presetFields = topFields.concat(bottomFields);
		for (let i = 0; i < presetFields.length; i++) {
			let j = otherFields.indexOf(presetFields[i]);
			if (j == -1) continue;
			
			otherFields.splice(j, 1);
		}
		let fields = topFields.concat(otherFields.sort()).concat(bottomFields);
		
		let str = '{';
		for (let i = 0; i < fields.length; i++) {
			let rawVal = value[fields[i]];
			if (!rawVal) continue;
			
			let val;
			if (fields[i] == 'tags') {
				val = _stringifyTests(rawVal.sort(), level + 1);
			}
			else {
				val = _stringifyTests(rawVal, level + 1);
			}
			
			if (val === undefined) continue;
			
			val = val.replace(/\n/g, "\n\t");
			str += (str.length > 1 ? ',' : '') + "\n\t" + JSON.stringify(fields[i]) + ': ' + val;
		}
		
		return str + "\n}";
	}
	
	/*
	 * adds a new test from the current input/translator
	 * web or import only for now
	 */
	this.saveTestFromCurrent = async function (type) {
		_logOutput(`Creating ${type} test...`);

		if (type === 'web') {
			if (!this.ensureWebpageLoadedIfNeeded()) return;
		}

		try {
			let test = await this.constructTestFromCurrent(type);
			_writeTestsToPane([..._loadTestsFromPane(), test]);
		}
		catch (e) {
			Zotero.logError(e);
			_logOutput('Creation failed');
			return;
		}

		_showTab('tests');
		let listbox = document.getElementById('testing-listbox');
		listbox.selectedIndex = listbox.getRowCount() - 1;
		listbox.focus();
	};

	this.constructTestFromCurrent = async function (type) {
		_clearOutput();
		if ((type === "web" && !document.getElementById('checkbox-web').checked)
			|| (type === "import" && !document.getElementById('checkbox-import').checked)
			|| (type === "search" && !document.getElementById('checkbox-search').checked)) {
			_logOutput(`Translator does not support ${type} tests`);
			throw new Error();
		}

		if (type == 'export') {
			throw new Error(`Test of type export cannot be created`);
		}

		let input = await _getInput(type);
		if (input === _browser) {
			input = _getCurrentURI(_browser);
		}
		
		let testDraft = new Test({ type, input, items: [] });
		let [{ updatedTest: test }] = await Array.fromAsync(
			this.runTestsInternal([testDraft])
		);
		
		// If we didn't get an item the first time, try again with defer: true
		if (!test) {
			testDraft.defer = true;
			let [{ updatedTest }] = await Array.fromAsync(
				this.runTestsInternal([testDraft])
			);
			test = updatedTest;
		}
		
		// But give up after that
		if (!test) {
			if (Services.prompt.confirm(
				null,
				'Detection Failed',
				`Add test ensuring that detection always fails on this ${type === 'web' ? 'page' : 'input'}?`
			)) {
				testDraft.detectedItemType = false;
				testDraft.defer = false;
				return testDraft.toJSON();
			}
			else {
				throw new Error('Not creating expected-fail test');
			}
		}
		
		return test.toJSON();
	};

	/*
	 * populate tests pane and url options in browser pane
	 */
	this.populateTests = function () {
		let tests = _loadTestsFromPane();
		let validateTestsBroadcaster = document.getElementById('validate-tests');
		if (tests === null) {
			validateTestsBroadcaster.setAttribute('disabled', true);
			return;
		}
		else {
			validateTestsBroadcaster.removeAttribute('disabled');
		}

		let listbox = document.getElementById("testing-listbox");

		for (let [testIndex, test] of tests.entries()) {
			let testString = _stringifyTests(test, 1);
			let testData = _testData[testIndex];
			if (!testData || testData.testString !== testString) {
				_testData[testIndex] = testData = {
					test,
					testString,
					previews: [],
				};
			}
			testData.test = test;
			testData.testString = testString;
			// Remove updatedTestString if updates have been applied
			if (testData.updatedTestString === testString) {
				delete testData.updatedTestString;
			}
			let needsUpdate = !!testData.updatedTestString;

			let item = testIndex < listbox.getRowCount()
				? listbox.getItemAtIndex(testIndex)
				: document.createXULElement('richlistitem');
			item.replaceChildren();

			let inputCell = document.createXULElement('hbox');
			inputCell.classList.add('cell', 'col-input');

			let input = document.createXULElement('label');
			input.append(getTestLabel(test));
			inputCell.append(input);
			item.append(inputCell);

			let statusCell = document.createXULElement('hbox');
			statusCell.classList.add('cell', 'col-status');
			statusCell.classList.toggle('needs-update', needsUpdate);
			let statusLabel = document.createXULElement('label');
			statusLabel.classList.add('status');

			let statusText = _testData.find(test => test.testString === testString)?.status ?? '';
			if (needsUpdate) {
				let totalStats = { added: 0, removed: 0 };
				for (let preview of testData.previews) {
					let statsHere = preview.diffStats;
					totalStats.added += statsHere.added;
					totalStats.removed += statsHere.removed;
				}
	
				let textElem = document.createElement('span');
				textElem.classList.add('text');
				textElem.textContent = statusText;
				let addedElem = document.createElement('span');
				addedElem.classList.add('added');
				addedElem.textContent = `+${totalStats.added}`;
				let removedElem = document.createElement('span');
				removedElem.classList.add('removed');
				removedElem.textContent = `-${totalStats.removed}`;
	
				statusLabel.replaceChildren(textElem, addedElem, removedElem);
			}
			else {
				statusLabel.textContent = statusText;
			}

			statusCell.append(statusLabel);
			let updateButton = document.createXULElement('toolbarbutton');
			updateButton.classList.add('update');
			updateButton.setAttribute('tooltiptext', 'Update Test');
			updateButton.addEventListener('command', () => this.updateTests([testIndex]));
			statusCell.append(updateButton);
			item.append(statusCell);

			let deferCell = document.createXULElement('hbox');
			deferCell.classList.add('cell', 'col-defer');
			let defer = document.createXULElement('checkbox');
			if (typeof test.defer === 'number' && test.defer) {
				defer.setAttribute('indeterminate', 'true');
			}
			else {
				defer.checked = test.defer;
			}
			defer.setAttribute('native', 'true');
			defer.addEventListener('command', () => {
				test.defer = defer.checked;
				_writeTestsToPane(tests);
			});
			deferCell.append(defer);
			item.append(deferCell);

			if (!item.parentElement) {
				listbox.append(item);
			}
		}
		
		// Remove unused _testData entries and listbox rows
		_testData = _testData.slice(0, tests.length);
		while (listbox.getRowCount() > tests.length) {
			listbox.getItemAtIndex(listbox.getRowCount() - 1).remove();
		}
		
		// Update UI that depends on the selection
		this.handleTestingListboxSelect();
	};

	/*
	 * Delete selected test(s)
	 */
	this.deleteSelectedTests = function () {
		var listbox = document.getElementById("testing-listbox");
		var indicesToRemove = [...listbox.selectedItems].map(item => listbox.getIndexOfItem(item));

		let tests = _loadTestsFromPane();
		tests = tests.filter((_, i) => !indicesToRemove.includes(i));
		_writeTestsToPane(tests);

		this.populateTests();
	};

	/*
	 * Load the import input for the first selected test in the import pane,
	 * from the UI.
	 */
	this.editImportFromTest = function () {
		var listbox = document.getElementById("testing-listbox");
		var { test } = _testData[listbox.selectedIndex];
		if (test.input === undefined) {
			_logOutput("Can't edit input of a non-import/search test.");
		}
		_writeToEditor(_editors.import,
			test.type == 'import'
				? test.input
				: JSON.stringify(test.input, null, '\t'));
		_editors.import.setSelection({
			startLineNumber: 1,
			endLineNumber: 1,
			startColumn: 1,
			endColumn: 1
		});
		_showTab('import');
	};
	
	/*
	 * Copy the url or data of the first selected test to the clipboard.
	 */
	this.copyToClipboard = function () {
		var listbox = document.getElementById("testing-listbox");
		var { test } = _testData[listbox.selectedIndex];
		var input = test.input; // URL or input object
		if (typeof input !== 'string') {
			input = JSON.stringify(input, null, '\t');
		}
		Zotero.Utilities.Internal.copyTextToClipboard(input);
	};
	
	/**
	 * Open the url of the first selected test in the browser (Browser tab or
	 * the system's default browser).
	 * @param {boolean} openExternally whether to open in the default browser
	**/
	this.openURL = function (openExternally) {
		var listbox = document.getElementById("testing-listbox");
		var { test } = _testData[listbox.selectedIndex];
		var url = test.url;
		if (openExternally) {
			Zotero.launchURL(url);
		}
		else {
			_browser.loadURI(Services.io.newURI(url), {
				triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal()
			});
			_showTab('browser');
		}
	};
	
	this.runTests = async function (testIndices) {
		let itemPreviews = document.querySelector('#item-previews');
		let testDatas = testIndices.map(index => _testData[index]);

		for (let testData of testDatas) {
			testData.status = 'Running';
			delete testData.updatedTestString;
		}
		this.populateTests();

		let numTests = testDatas.length;
		let testIndex = 0;
		_logOutput(`Running ${numTests} ${Zotero.Utilities.pluralize(numTests, 'test')}`);
		for await (let { test, status, reason, updatedTest } of this.runTestsInternal(testDatas.map(d => d.test))) {
			let statusText;
			let needsUpdate = false;
			
			let logPrefix = `Test ${testIndex + 1}/${numTests}: `;
			if (status === 'success') {
				statusText = 'Succeeded';
				_logOutput(logPrefix + statusText);
			}
			else {
				statusText = reason;
				needsUpdate = !!updatedTest;
				_logOutput(logPrefix + statusText);
			}

			// Create previews as long as we got new item data,
			// even if there weren't substantive changes
			let previews = [];
			if (Array.isArray(test.items) && Array.isArray(updatedTest?.items)) {
				for (let i = 0; i < test.items.length || i < updatedTest.items.length; i++) {
					let preItem = i < test.items.length ? test.items[i] : {};
					let postItem = i < updatedTest.items.length ? updatedTest.items[i] : {};
					previews.push(itemPreviews.createPreviewForItemPair(preItem, postItem));
				}
			}

			let testData = testDatas[testIndex];
			testData.status = statusText;
			testData.previews = previews;
			if (needsUpdate) {
				testData.updatedTestString = _stringifyTests(updatedTest.toJSON(), 1);
			}
			else {
				delete testData.updatedTestString;
			}
			this.populateTests();

			testIndex++;
		}
		
		_invalidateCodeLenses?.();
	};

	this.runSelectedTests = async function () {
		let listbox = document.getElementById('testing-listbox');
		let selectedItems = [...listbox.selectedItems];
		await this.runTests(
			[...selectedItems].map(item => listbox.getIndexOfItem(item))
		);
	};

	this.runTestsInternal = async function* (tests) {
		_clearOutput();

		let rememberCookies = document.getElementById('checkbox-remember-cookies').checked;
		let cookieContext;
		if (rememberCookies) {
			if (!_persistentCookieContext) {
				_persistentCookieContext = Zotero.HTTP.newCookieContext();
			}
			cookieContext = _persistentCookieContext;
		}
		else {
			cookieContext = Zotero.HTTP.newCookieContext();
		}

		let tester = new TranslatorTester(_getTranslatorFromPane(), {
			translatorProvider: _translatorProvider,
			cookieSandbox: cookieContext.id,
			debug: _logOutput,
			webTranslationEnvironment: new ZoteroWebTranslationEnvironment(),
		});

		try {
			for (let test of tests) {
				yield { test, ...await tester.run(test) };
			}
		}
		finally {
			if (!rememberCookies) {
				cookieContext.dispose();
			}
		}
	};

	this.updateTests = function (testIndices) {
		let tests = _loadTestsFromPane();
		for (let testIndex of testIndices) {
			let testData = _testData[testIndex];
			let updatedTestString = testData.updatedTestString;
			if (!updatedTestString) {
				continue;
			}
			
			let updatedTest = JSON.parse(updatedTestString);
			tests[testIndex] = updatedTest;
			testData.test = new Test(updatedTest);
			testData.testString = _stringifyTests(updatedTest, 1);
			delete testData.updatedTestString;
			testData.status = 'Updated';
		}
		_writeTestsToPane(tests);
		_logOutput('Tests updated.');
	};
	
	this.updateSelectedTests = async function () {
		let listbox = document.getElementById('testing-listbox');
		let selectedItems = [...listbox.selectedItems];
		this.updateTests(
			[...selectedItems].map(item => listbox.getIndexOfItem(item))
		);
	};

	this.populateLinterMenu = function () {
		let status = 'Path: ' + getDefaultESLintPath();
		let toggle = Zotero.Prefs.get('scaffold.eslint.enabled') ? 'Disable' : 'Enable';
		document.getElementById('menu_eslintStatus').label = status;
		document.getElementById('menu_toggleESLint').label = toggle;
	};

	this.toggleESLint = async function () {
		Zotero.Prefs.set('scaffold.eslint.enabled', !Zotero.Prefs.get('scaffold.eslint.enabled'));
		await getESLintPath();
	};

	this.showTabNumbered = function (tabNumber) {
		let tabBox = document.getElementById('left-tabbox');
		let numTabs = tabBox.querySelectorAll('tabs > tab').length;
		if (tabNumber > numTabs) {
			tabNumber = numTabs;
		}

		tabBox.selectedIndex = tabNumber - 1;
	};
	
	/*
	 * Normalize whitespace to the Zotero norm of tabs
	 */
	function normalizeWhitespace(text) {
		return text.replace(/^[ \t]+/gm, function (str) {
			return str.replace(/ {4}/g, "\t");
		});
	}

	/*
	 * Clear output pane
	 */
	function _clearOutput() {
		document.getElementById('output').value = '';
		let itemPreviews = document.querySelector('#item-previews');
		itemPreviews.clearPreviews();
	}

	/**
	 * Toggle the indicator of browser loading by hiding the load button
	 * and displaying the spinner, or vice versa.
	 */
	function _setBrowserLoadingIndicator(isLoading) {
		if (isLoading) {
			document.getElementById("load-url-button").hidden = true;
			document.getElementById("browser-loading").setAttribute("status", "animate");
		}
		else {
			document.getElementById("load-url-button").hidden = false;
			document.getElementById("browser-loading").removeAttribute("status");
		}
	}

	/*
	 * generates an RFC 4122 compliant random GUID
	 */
	function _generateGUID() {
		var guid = "";
		for (var i = 0; i < 16; i++) {
			var bite = Math.floor(Math.random() * 255);

			if (i == 4 || i == 6 || i == 8 || i == 10) {
				guid += "-";

				// version
				if (i == 6) bite = bite & 0x0f | 0x40;
				// variant
				if (i == 8) bite = bite & 0x3f | 0x80;
			}
			var str = bite.toString(16);
			guid += str.length == 1 ? '0' + str : str;
		}
		return guid;
	}

	function _getCurrentURI(browser) {
		return Zotero.Proxies.proxyToProper(browser.currentURI.spec);
	}

	function _findTestObjectTops(monaco, model) {
		let tokenization = monaco.editor.tokenize(model.getValue(), 'json');

		let arrayLevel = 0;
		let objectLevel = 0;

		let ranges = [];

		for (let line in tokenization) {
			line = +line; // string keys
			for (let token of tokenization[line]) {
				if (token.type == 'delimiter.array.json' || token.type == 'delimiter.bracket.json') {
					let range = {
						startLineNumber: line + 1,
						startColumn: token.offset + 1,
						endLineNumber: line + 1,
						endColumn: token.offset + 2
					};
					let bracket = model.getValueInRange(range);

					if (bracket == '[') {
						arrayLevel++;
					}
					else if (bracket == ']') {
						arrayLevel--;
						continue; // we only want to record opening brackets
					}
					else if (bracket == '{') {
						objectLevel++;
					}
					else if (bracket == '}') {
						objectLevel--;
						continue;
					}

					if (arrayLevel == 1 && objectLevel == 1) {
						ranges.push(range);
					}
				}
			}
		}

		return ranges;
	}

	function getDefaultESLintPath() {
		return PathUtils.join(Scaffold_Translators.getDirectory(), 'node_modules', '.bin', 'eslint');
	}

	async function getESLintPath() {
		if (!Zotero.Prefs.get('scaffold.eslint.enabled')) {
			return null;
		}

		let eslintPath = getDefaultESLintPath();

		while (!(await IOUtils.exists(eslintPath))) {
			let ps = Services.prompt;
			let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
				+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING
				+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_CANCEL;

			let index = ps.confirmEx(null,
				"Scaffold",
				"Zotero uses ESLint to enable code suggestions and error checking, "
					+ "but it wasn't found in the selected translators directory.\n\n"
					+ "You can install it from the command line:\n\n"
					+ `  cd '${Scaffold_Translators.getDirectory()}'\n`
					+ "  npm install\n\n",
				buttonFlags,
				"Try Again",
				"Disable Error Checking",
				null, null, {}
			);
			if (index == 1) {
				Zotero.Prefs.set('scaffold.eslint.enabled', false);
				return null;
			}
			else if (index == 2) {
				return null;
			}
		}
		return eslintPath;
	}

	let eslintSubprocess = null;
	async function runESLint() {
		let eslintPath = await getESLintPath();
		if (!eslintPath) return [];

		try {
			let metadata = _getMetadataObject();
			let code = _getCode();
			let translatorString = _translatorProvider.stringify(metadata, code);
			
			let subprocessOptions = {
				command: eslintPath,
				arguments: [
					'--format',
					'json',
					'--stdin',
					'--stdin-filename',
					_translatorProvider.getSavePath(metadata)
				],
			};
			
			// ESLint needs to find node on the PATH, but macOS doesn't forward
			// the login shell's PATH to GUI processes by default. There's a
			// launchctl command that fixes it, but we can't expect people
			// to do that. Pass the login shell's PATH as a workaround.
			if (Zotero.isMac) {
				subprocessOptions.environment = { PATH: await lazy.shellPathPromise };
				subprocessOptions.environmentAppend = true;
			}
			
			let proc = await Subprocess.call(subprocessOptions);
			if (eslintSubprocess) {
				eslintSubprocess.kill(0);
			}
			eslintSubprocess = proc;
			
			await proc.stdin.write(translatorString);
			await proc.stdin.close();
			let lintOutput = '';
			let chunk;
			while ((chunk = await proc.stdout.readString())) {
				lintOutput += chunk;
			}
			proc.kill(); // Shouldn't be necessary, but make sure we don't leak
			return JSON.parse(lintOutput);
		}
		catch (e) {
			if (!(e instanceof SyntaxError)) {
				Zotero.logError(e);
			}
		}
		return [];
	}

	function eslintOutputToModelMarkers(output, modelVersionId) {
		let result = output[0];
		if (!result) return [];

		return result.messages.map(message => ({
			startLineNumber: message.line - _linesOfMetadata + 1,
			startColumn: message.column,
			endLineNumber: message.endLine - _linesOfMetadata + 1,
			endColumn: message.endColumn,
			message: message.message,
			severity: message.severity * 4,
			source: 'ESLint',
			code: message.ruleId,
			modelVersionId,
		}));
	}

	function getTestLabel(test) {
		switch (test.type) {
			case 'import':
				return test.input.substr(0, 80);
			case 'web':
				return test.url;
			case 'search':
				return JSON.stringify(test.input).substr(0, 80);
			default:
				return `Unknown type: ${test.type}`;
		}
	}

	function _showTab(tab) {
		document.getElementById('tabs').selectedItem = document.getElementById(`tab-${tab}`);
	}

	function _getActiveEditorName() {
		let activeElement = document.activeElement;
		if (activeElement && activeElement.id && activeElement.id.startsWith('editor-')) {
			return activeElement.id.substring(7);
		}
		return null;
	}
	
	async function _getGitBranchName() {
		let gitPath = await Subprocess.pathSearch('git');
		if (!gitPath) return null;
		
		let dir = Scaffold_Translators.getDirectory();
		if (!dir) return null;
		
		let proc = await Subprocess.call({
			command: gitPath,
			arguments: ['rev-parse', '--abbrev-ref', 'HEAD'],
			workdir: dir,
		});
		let output = '';
		let chunk;
		while ((chunk = await proc.stdout.readString())) {
			output += chunk;
		}
		return output.trim();
	}
	
	async function _updateTitle() {
		let title = 'Scaffold';

		let label = document.getElementById('textbox-label').value;
		if (label) {
			title += ' - ' + label;
		}
		
		try {
			let branch = await _getGitBranchName();
			if (branch) {
				title += ' (' + branch + ')';
			}
		}
		catch (e) {
			Zotero.logError(e);
		}
		
		document.title = title;
	}
	
	function _handleAPIKeyChange() {
		let apiKey = Zotero.Prefs.get('scaffold.completions.mistralAPIKey');
		if (apiKey) {
			_copilot = new CompletionCopilot(apiKey, {
				provider: 'mistral',
				model: 'codestral'
			});
		}
		else {
			_copilot = null;
		}
	}
};

window.addEventListener('load', e => e.target === document && Scaffold.handleLoad(e));
window.addEventListener('unload', e => e.target === document && Scaffold.handleUnload(e));
