describe("FileRenamingDialog", function () {
	let win;
	let group;

	before(async function () {
		group = await getGroup();
		win = await loadWindow("chrome://zotero/content/fileRenamingDialog.xhtml");
	});

	after(function () {
		if (win) {
			win.close();
		}
	});

	describe("#handleSettingsChange()", function () {
		// Drives handleSettingsChange for a given library/template with SyncedSettings.set stubbed,
		// returning the values the template setting would have been synced with
		function collectTemplateSyncs(libraryID, template) {
			let dialog = win.FileRenamingDialog;
			dialog.libraryPicker.value = String(libraryID);
			dialog._currentLibraryID = libraryID;
			dialog.loadSettingsForLibrary(libraryID);
			dialog.settingsEl.formatTemplate = template;

			let sandbox = sinon.createSandbox();
			let setStub = sandbox.stub(Zotero.SyncedSettings, "set").resolves();
			let clearStub = sandbox.stub(Zotero.SyncedSettings, "clear").resolves();
			sandbox.stub(Zotero.Prefs, "set");
			try {
				dialog.handleSettingsChange({
					detail: {
						autoRenameEnabled: true,
						enabledFileTypes: 'application/pdf',
						renameLinkedEnabled: false,
						formatTemplate: template
					}
				});
				return {
					otherSynced: setStub.getCalls().some(c => c.args[1] !== 'attachmentRenameTemplate'),
					templateValues: setStub.getCalls()
						.filter(c => c.args[1] === 'attachmentRenameTemplate')
						.map(c => c.args[2]),
					templateCleared: clearStub.getCalls()
						.some(c => c.args[1] === 'attachmentRenameTemplate')
				};
			}
			finally {
				sandbox.restore();
			}
		}

		it("should not push an invalid template to a group's synced settings", function () {
			let invalid = collectTemplateSyncs(group.libraryID, '{{ title');
			assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced");
			assert.isTrue(invalid.otherSynced, "other group settings should still be synced");

			let valid = collectTemplateSyncs(group.libraryID, '{{ title }}');
			assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced");
		});

		it("should clear a group's template setting when the template is emptied", function () {
			for (let empty of ['', '   ']) {
				let result = collectTemplateSyncs(group.libraryID, empty);
				assert.deepEqual(result.templateValues, [], `empty template ${JSON.stringify(empty)} must not be synced`);
				assert.isTrue(result.templateCleared, "empty template must be cleared from a group's synced settings");
				assert.isTrue(result.otherSynced, "other group settings should still be synced");
			}
		});

		it("should not push an invalid template to the user library's synced settings", function () {
			let invalid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title');
			assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced");

			let valid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title }}');
			assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced");
		});
	});

	describe("invalid template prompt", function () {
		const INVALID_TEMPLATE = '{{ title';

		// Loads the given library and applies a template change through the dialog's change
		// handler (with persistence stubbed by the caller), leaving the dialog dirty
		function makeDirty(libraryID, template) {
			let dialog = win.FileRenamingDialog;
			dialog.libraryPicker.value = String(libraryID);
			dialog._currentLibraryID = libraryID;
			dialog.loadSettingsForLibrary(libraryID);
			dialog.settingsEl.formatTemplate = template;
			dialog.handleSettingsChange({
				detail: {
					autoRenameEnabled: dialog.settingsEl.autoRenameEnabled,
					enabledFileTypes: dialog.settingsEl.enabledFileTypes,
					renameLinkedEnabled: dialog.settingsEl.renameLinkedEnabled,
					formatTemplate: template
				}
			});
			return dialog;
		}

		function setupStubs(sandbox, confirmIndex) {
			return {
				set: sandbox.stub(Zotero.SyncedSettings, 'set').resolves(),
				clear: sandbox.stub(Zotero.SyncedSettings, 'clear').resolves(),
				prefSet: sandbox.stub(Zotero.Prefs, 'set'),
				confirm: sandbox.stub(Zotero.Prompt, 'confirm').returns(confirmIndex)
			};
		}

		function resetStubHistory(stubs) {
			stubs.set.resetHistory();
			stubs.clear.resetHistory();
			stubs.prefSet.resetHistory();
		}

		function templateWrites(stubs) {
			return {
				sets: stubs.set.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate'),
				clears: stubs.clear.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate')
			};
		}

		function restoreDialog() {
			let dialog = win.FileRenamingDialog;
			dialog._forceClose = false;
			dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
			dialog._currentLibraryID = Zotero.Libraries.userLibraryID;
			dialog.loadSettingsForLibrary(Zotero.Libraries.userLibraryID);
		}

		it("should keep the window open and not touch the template setting on cancel", async function () {
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 1);
			let origClose = win.close;
			let closeSpy = sinon.spy();
			win.close = closeSpy;
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				let event = { preventDefault: sinon.spy() };
				await dialog.handleWindowClose(event);
				assert.isTrue(event.preventDefault.called, 'close must be blocked');
				assert.isFalse(closeSpy.called, 'window must stay open on cancel');
				assert.equal(stubs.confirm.callCount, 1, 'prompt shown once');
				assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
				assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
				let { sets, clears } = templateWrites(stubs);
				assert.lengthOf(sets, 0, 'template must not be synced on cancel');
				assert.lengthOf(clears, 0, 'template must not be cleared on cancel');
			}
			finally {
				win.close = origClose;
				sandbox.restore();
				restoreDialog();
			}
		});

		it("should clear the group's template setting and close on reset", async function () {
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 0);
			let origClose = win.close;
			let closeSpy = sinon.spy();
			win.close = closeSpy;
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				let event = { preventDefault: sinon.spy() };
				await dialog.handleWindowClose(event);
				assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
				assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
				assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline');
				let { sets, clears } = templateWrites(stubs);
				assert.lengthOf(sets, 0, 'template must not be synced on reset');
				assert.lengthOf(clears, 1, 'template setting must be cleared');
				assert.equal(clears[0].args[0], group.libraryID);
				assert.isFalse(
					stubs.prefSet.calledWith('autoRenameFiles.done', sinon.match.any),
					'user-library done pref must not change for a group reset'
				);
				assert.isTrue(closeSpy.called, 'window must close after reset');
			}
			finally {
				win.close = origClose;
				sandbox.restore();
				restoreDialog();
			}
		});

		it("should clear the user library's template setting and recompute the done pref on reset", async function () {
			let userLibraryID = Zotero.Libraries.userLibraryID;
			// Make the baseline deterministic: default template stored, no rename pending
			let origTemplate = Zotero.SyncedSettings.get(userLibraryID, 'attachmentRenameTemplate');
			let origDone = Zotero.Prefs.get('autoRenameFiles.done');
			await Zotero.SyncedSettings.clear(userLibraryID, 'attachmentRenameTemplate');
			Zotero.Prefs.set('autoRenameFiles.done', true);

			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 0);
			let origClose = win.close;
			let closeSpy = sinon.spy();
			win.close = closeSpy;
			try {
				let dialog = makeDirty(userLibraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				let event = { preventDefault: sinon.spy() };
				await dialog.handleWindowClose(event);
				assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
				assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
				assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline');
				let { sets, clears } = templateWrites(stubs);
				assert.lengthOf(sets, 0, 'template must not be synced on reset');
				assert.lengthOf(clears, 1, 'template setting must be cleared');
				assert.equal(clears[0].args[0], userLibraryID);
				// The reset lands back on the baseline, so no rename is pending
				assert.isTrue(
					stubs.prefSet.calledWith('autoRenameFiles.done', true),
					'done pref must be recomputed against the baseline, not hard-set to false'
				);
				assert.isTrue(closeSpy.called, 'window must close after reset');
			}
			finally {
				win.close = origClose;
				sandbox.restore();
				if (origTemplate) {
					await Zotero.SyncedSettings.set(userLibraryID, 'attachmentRenameTemplate', origTemplate);
				}
				Zotero.Prefs.set('autoRenameFiles.done', origDone);
				restoreDialog();
			}
		});

		it("should prompt when an invalid template blurs directly to Done", async function () {
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 1);
			let origClose = win.close;
			let closeSpy = sinon.spy();
			win.close = closeSpy;
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				dialog.settingsEl.handleTemplateBlur();
				await dialog._handleDoneClick();
				assert.isFalse(closeSpy.called, 'window must stay open on cancel');
				assert.equal(stubs.confirm.callCount, 1, 'only the invalid-template prompt is shown');
				let { sets, clears } = templateWrites(stubs);
				assert.lengthOf(sets, 0);
				assert.lengthOf(clears, 0);
			}
			finally {
				win.close = origClose;
				sandbox.restore();
				restoreDialog();
			}
		});

		it("should stay on the current library when switching libraries and the user cancels", async function () {
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 1);
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
				await dialog.handleLibraryChange();
				assert.equal(dialog.libraryPicker.value, String(group.libraryID), 'picker must revert to the previous library');
				assert.equal(dialog._currentLibraryID, group.libraryID, 'current library must not change');
				let { sets, clears } = templateWrites(stubs);
				assert.lengthOf(sets, 0);
				assert.lengthOf(clears, 0);
			}
			finally {
				sandbox.restore();
				restoreDialog();
			}
		});

		it("should reset the previous library's template and switch libraries on reset", async function () {
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 0);
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
				await dialog.handleLibraryChange();
				let { clears } = templateWrites(stubs);
				assert.lengthOf(clears, 1, 'previous library template setting must be cleared');
				assert.equal(clears[0].args[0], group.libraryID);
				assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after reset');
				assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID));
			}
			finally {
				sandbox.restore();
				restoreDialog();
			}
		});

		it("should prompt to rename files after resetting the template on Done and skip the preview when declined", async function () {
			await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline');
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 0);
			stubs.confirm.onSecondCall().returns(1);
			let dialog = win.FileRenamingDialog;
			let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true);
			let origClose = win.close;
			let closeSpy = sinon.spy();
			win.close = closeSpy;
			try {
				makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				await dialog._handleDoneClick();
				assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown');
				let { clears } = templateWrites(stubs);
				assert.lengthOf(clears, 1, 'template setting must be cleared');
				assert.isFalse(previewStub.called, 'declining the rename must not open the preview');
				assert.isTrue(closeSpy.called, 'window must close after the prompts');
			}
			finally {
				win.close = origClose;
				sandbox.restore();
				await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate');
				restoreDialog();
			}
		});

		it("should prompt to rename files after resetting the template when switching libraries", async function () {
			await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline');
			let sandbox = sinon.createSandbox();
			let stubs = setupStubs(sandbox, 0);
			let dialog = win.FileRenamingDialog;
			let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true);
			try {
				makeDirty(group.libraryID, INVALID_TEMPLATE);
				resetStubHistory(stubs);
				dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
				await dialog.handleLibraryChange();
				assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown');
				assert.isTrue(previewStub.calledOnceWith(group.libraryID), 'rename preview must open for the previous library');
				assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after the prompts');
				assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID));
			}
			finally {
				sandbox.restore();
				await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate');
				restoreDialog();
			}
		});

		it("should only prompt when settings changed and the template is invalid", function () {
			let sandbox = sinon.createSandbox();
			setupStubs(sandbox, 1);
			try {
				let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
				assert.isTrue(dialog._shouldPromptInvalidTemplate(), 'changed + invalid must prompt');

				makeDirty(group.libraryID, '{{ title }}{{ year }}');
				assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'changed + valid must not prompt');

				dialog.loadSettingsForLibrary(group.libraryID);
				dialog.settingsEl.formatTemplate = INVALID_TEMPLATE;
				assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'unchanged + invalid must not prompt');
			}
			finally {
				sandbox.restore();
				restoreDialog();
			}
		});
	});

	describe("template preview", function () {
		it("should keep the last valid preview while typing and show an error on blur", function () {
			let dialog = win.FileRenamingDialog;
			let settingsEl = dialog.settingsEl;
			let preview = settingsEl.querySelector('#file-renaming-format-preview');
			let libraryID = dialog._currentLibraryID;
			let sandbox = sinon.createSandbox();
			sandbox.stub(Zotero.SyncedSettings, 'set').resolves();
			sandbox.stub(Zotero.SyncedSettings, 'clear').resolves();
			sandbox.stub(Zotero.Prefs, 'set');
			try {
				settingsEl.formatTemplate = '{{ title }}';
				settingsEl.updatePreview();
				let firstValidPreview = preview.innerText;

				settingsEl.formatTemplate = '{{ title';
				settingsEl.handleTemplateInput();
				assert.equal(preview.innerText, firstValidPreview, 'last valid preview retained');
				assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview remains visible');
				assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error remains hidden');

				settingsEl.formatTemplate = '{{ title }}-updated';
				settingsEl.handleTemplateInput();
				let secondValidPreview = preview.innerText;
				assert.notEqual(secondValidPreview, firstValidPreview, 'preview updates when valid again');

				settingsEl.formatTemplate = '{{ title';
				settingsEl.handleTemplateInput();
				assert.equal(preview.innerText, secondValidPreview, 'latest valid preview retained');
				settingsEl.handleTemplateBlur();
				assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden on blur');
				assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown on blur');
			}
			finally {
				sandbox.restore();
				dialog.loadSettingsForLibrary(libraryID);
			}
		});

		it("should clear a stale syntax-error message when the template is cleared to whitespace", function () {
			let settingsEl = win.FileRenamingDialog.settingsEl;
			// Show the invalid state (updatePreview has no change side effects)
			settingsEl.formatTemplate = '{{ title';
			settingsEl.updatePreview();
			assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown while invalid');
			assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden while invalid');
			// Deleting down to whitespace must clear the stale error and restore the preview
			settingsEl.formatTemplate = '   ';
			settingsEl.handleTemplateInput();
			assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error cleared for whitespace');
			assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview restored for whitespace');
		});

		it("should validate the template as the engine renders it, ignoring newlines inside a tag", function () {
			let settingsEl = win.FileRenamingDialog.settingsEl;
			// The engine strips newlines before validating/rendering, so the dialog must judge the
			// same normalized string -- a newline splitting `endif` here must not read as invalid
			settingsEl.formatTemplate = '{{if title}}a{{end\nif}}';
			assert.isTrue(settingsEl.templateValid, 'newline inside a tag must not read as invalid');
		});

		it("should validate the template at most once per keystroke", function () {
			let settingsEl = win.FileRenamingDialog.settingsEl;
			settingsEl.formatTemplate = '{{ title }}{{ year }}';
			let sandbox = sinon.createSandbox();
			// Stub persistence so the change handler runs without touching settings
			sandbox.stub(Zotero.SyncedSettings, 'set').resolves();
			sandbox.stub(Zotero.SyncedSettings, 'clear').resolves();
			sandbox.stub(Zotero.Prefs, 'set');
			let spy = sandbox.spy(settingsEl, '_validateTemplate');
			try {
				settingsEl.handleTemplateInput();
				assert.isAtMost(spy.callCount, 1, 'template should be parsed at most once per keystroke');
			}
			finally {
				sandbox.restore();
			}
		});
	});
});
