describe("Zotero.Sync.Storage.FileChangeWatcher", function () {
	let watcher = Zotero.Sync.Storage.FileChangeWatcher;

	// Pref keys used by the watcher
	let PREF_FSEVENTS_EVENT_ID = "sync.storage.watcher.fsEventsEventID";
	let PREF_FSEVENTS_STORAGE_PATH = "sync.storage.watcher.fsEventsStoragePath";

	function clearWatcherPrefs() {
		Zotero.Prefs.clear(PREF_FSEVENTS_EVENT_ID);
		Zotero.Prefs.clear(PREF_FSEVENTS_STORAGE_PATH);
	}

	// The Set returned by getChangedItemKeys() is created in the XPCOM
	// context, so instanceof Set doesn't work across contexts. Use
	// duck-typing instead.
	function assertIsSet(val, msg) {
		assert.isNotNull(val, msg);
		assert.isFunction(val.has, msg);
		assert.isNumber(val.size, msg);
	}

	describe("FSEvents backend (macOS)", function () {
		before(async function () {
			if (!Zotero.isMac) {
				this.skip();
			}
			// Ensure the storage directory exists (it may not yet in
			// the test environment)
			let storageDir = PathUtils.join(
				Zotero.DataDirectory.dir, "storage"
			);
			await IOUtils.makeDirectory(storageDir, {
				ignoreExisting: true
			});
			// Close any watcher initialized at startup
			watcher.close();
			clearWatcherPrefs();
		});

		afterEach(function () {
			watcher.close();
			clearWatcherPrefs();
		});

		after(function () {
			// Re-init the watcher so it's available for normal operation
			// after the test suite
			watcher.init();
		});

		it("should initialize successfully", function () {
			watcher.init();
			assert.isTrue(watcher.available);
			assert.equal(watcher._backend, 'fsevents');
			assert.ok(watcher._storageRoot, "Storage root should be set");
		});

		it("should return null on first call with no saved event ID", function () {
			watcher.init();
			let result = watcher.getChangedItemKeys();
			assert.isNull(result, "First call should return null");
			// Should have saved a baseline event ID
			let savedId = Zotero.Prefs.get(PREF_FSEVENTS_EVENT_ID);
			assert.isString(savedId);
			assert.notEqual(savedId, "0");
		});

		it("should return an empty set when no files have changed", function () {
			watcher.init();
			// Establish baseline
			watcher.getChangedItemKeys();

			// Second call -- no changes
			let result = watcher.getChangedItemKeys();
			assertIsSet(result);
			assert.equal(result.size, 0);
		});

		it("should detect a modified attachment file", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');

			try {
				watcher.init();
				// Establish baseline
				watcher.getChangedItemKeys();

				// Modify the file
				let path = await item.getFilePathAsync();
				await Zotero.File.putContentsAsync(
					path, Zotero.Utilities.randomString()
				);

				// Wait for FSEvents to register
				await Zotero.Promise.delay(1000);

				let result = watcher.getChangedItemKeys();
				assertIsSet(result);
				assert.isTrue(
					result.has(item.key),
					"Should contain the key of the modified item"
				);
			}
			finally {
				await item.eraseTx();
			}
		});

		it("should detect changes across multiple items", async function () {
			this.timeout(15000);

			let item1 = await importFileAttachment('test.png');
			let item2 = await importTextAttachment();
			let item3 = await importFileAttachment('test.png');

			try {
				watcher.init();
				// Wait for FSEvents from item creation to flush
				// before establishing baseline
				await Zotero.Promise.delay(1000);
				watcher.getChangedItemKeys();

				// Modify only items 1 and 3
				let path1 = await item1.getFilePathAsync();
				await Zotero.File.putContentsAsync(
					path1, Zotero.Utilities.randomString()
				);
				let path3 = await item3.getFilePathAsync();
				await Zotero.File.putContentsAsync(
					path3, Zotero.Utilities.randomString()
				);

				await Zotero.Promise.delay(1000);

				let result = watcher.getChangedItemKeys();
				assertIsSet(result);
				assert.isTrue(result.has(item1.key),
					"Should contain key of first modified item");
				assert.isFalse(result.has(item2.key),
					"Should not contain key of unmodified item");
				assert.isTrue(result.has(item3.key),
					"Should contain key of second modified item");
			}
			finally {
				await item1.eraseTx();
				await item2.eraseTx();
				await item3.eraseTx();
			}
		});

		it("should not report the same changes twice", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');

			try {
				watcher.init();
				watcher.getChangedItemKeys();

				// Modify file
				let path = await item.getFilePathAsync();
				await Zotero.File.putContentsAsync(
					path, Zotero.Utilities.randomString()
				);

				await Zotero.Promise.delay(1000);

				// First query picks up the change
				let result1 = watcher.getChangedItemKeys();
				assertIsSet(result1);
				assert.isTrue(result1.has(item.key));

				// Second query should be empty
				let result2 = watcher.getChangedItemKeys();
				assertIsSet(result2);
				assert.equal(result2.size, 0,
					"Should not report the same change twice");
			}
			finally {
				await item.eraseTx();
			}
		});

		it("should detect a new file added to a storage directory", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');

			try {
				watcher.init();
				watcher.getChangedItemKeys();

				// Add a new file to the item's storage directory
				let storageDir = Zotero.Attachments.getStorageDirectory(item).path;
				let newFile = PathUtils.join(storageDir, "extra.txt");
				await Zotero.File.putContentsAsync(newFile, "extra content");

				await Zotero.Promise.delay(1000);

				let result = watcher.getChangedItemKeys();
				assertIsSet(result);
				assert.isTrue(result.has(item.key),
					"Should detect new file in storage directory");
			}
			finally {
				await item.eraseTx();
			}
		});

		it("should return null when storage path has changed", function () {
			// Simulate a previous run with a different storage path
			Zotero.Prefs.set(PREF_FSEVENTS_STORAGE_PATH, "/some/other/path/");
			Zotero.Prefs.set(PREF_FSEVENTS_EVENT_ID, "12345");

			watcher.init();

			// The saved event ID should have been cleared
			let result = watcher.getChangedItemKeys();
			assert.isNull(result,
				"Should return null after storage path change");
		});

		it("should persist event ID across close/init cycles", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');

			try {
				watcher.init();
				// Establish baseline
				watcher.getChangedItemKeys();

				let savedId = Zotero.Prefs.get(PREF_FSEVENTS_EVENT_ID);
				assert.ok(savedId, "Event ID should be saved");

				// Close and re-init (simulating restart)
				watcher.close();
				watcher.init();

				// Should not return null -- saved event ID should be used
				let result = watcher.getChangedItemKeys();
				assertIsSet(result,
					"Should return a Set (not null) after re-init "
					+ "with saved event ID");
			}
			finally {
				await item.eraseTx();
			}
		});

		it("should detect changes that happened while watcher was closed", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');

			try {
				watcher.init();
				watcher.getChangedItemKeys();

				// Close the watcher
				watcher.close();

				// Modify file while watcher is closed
				let path = await item.getFilePathAsync();
				await Zotero.File.putContentsAsync(
					path, Zotero.Utilities.randomString()
				);

				await Zotero.Promise.delay(1000);

				// Re-init and query -- FSEvents journal should have
				// the change even though the watcher was closed
				watcher.init();
				let result = watcher.getChangedItemKeys();
				assertIsSet(result);
				assert.isTrue(result.has(item.key),
					"Should detect changes that occurred while "
					+ "watcher was closed");
			}
			finally {
				await item.eraseTx();
			}
		});

		it("should only report valid 8-char item keys", async function () {
			this.timeout(15000);

			watcher.init();
			watcher.getChangedItemKeys();

			// Create a non-standard directory name in the storage root
			let storageRoot = watcher._storageRoot;
			let invalidDir = PathUtils.join(storageRoot, "not-a-key");
			await IOUtils.makeDirectory(invalidDir, { ignoreExisting: true });
			let testFile = PathUtils.join(invalidDir, "test.txt");
			await IOUtils.writeUTF8(testFile, "test");

			await Zotero.Promise.delay(1000);

			try {
				let result = watcher.getChangedItemKeys();
				assertIsSet(result);
				// Should not contain the invalid directory name
				for (let key of result) {
					assert.match(key, /^[A-Z0-9]{8}$/,
						"All returned keys should match the 8-char "
						+ "pattern");
				}
			}
			finally {
				await IOUtils.remove(invalidDir, { recursive: true });
			}
		});
	});

	describe("integration with storageEngine", function () {
		before(async function () {
			if (!Zotero.isMac) {
				this.skip();
			}
			let storageDir = PathUtils.join(
				Zotero.DataDirectory.dir, "storage"
			);
			await IOUtils.makeDirectory(storageDir, {
				ignoreExisting: true
			});
			watcher.close();
			clearWatcherPrefs();
		});

		afterEach(function () {
			watcher.close();
			clearWatcherPrefs();
		});

		after(function () {
			watcher.init();
		});

		it("should be checked before falling back to legacy scanning", async function () {
			this.timeout(15000);

			let item = await importFileAttachment('test.png');
			let hash = await item.attachmentHash;
			let mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 1000;
			await OS.File.setDates(
				(await item.getFilePathAsync()), null, mtime
			);

			// Mark as synced
			item.attachmentSyncedModificationTime = mtime;
			item.attachmentSyncedHash = hash;
			item.attachmentSyncState = "in_sync";
			await item.saveTx({ skipAll: true });

			try {
				watcher.init();
				// Establish baseline
				watcher.getChangedItemKeys();

				// Modify the file
				let path = await item.getFilePathAsync();
				await OS.File.setDates(path);
				await Zotero.File.putContentsAsync(
					path, Zotero.Utilities.randomString()
				);

				await Zotero.Promise.delay(1000);

				// Verify the watcher reports the change
				let changedKeys = watcher.getChangedItemKeys();
				assertIsSet(changedKeys);
				assert.isTrue(changedKeys.has(item.key));

				// Map keys to itemIDs as storageEngine does
				let libraryID = Zotero.Libraries.userLibraryID;
				let itemIDs = await Zotero.DB.columnQueryAsync(
					"SELECT itemID FROM items WHERE libraryID=?"
						+ " AND key IN ("
						+ Array.from(changedKeys).map(() => '?')
							.join(',')
						+ ")",
					[libraryID, ...changedKeys]
				);
				assert.include(itemIDs, item.id);

				// checkForUpdatedFiles should flag it for upload
				let changed = await Zotero.Sync.Storage.Local
					.checkForUpdatedFiles(libraryID, itemIDs);
				assert.isTrue(changed);
				assert.equal(
					item.attachmentSyncState,
					Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD
				);
			}
			finally {
				await item.eraseTx();
			}
		});
	});

	function resetWatcherState() {
		watcher._snapshotTaken = false;
		watcher._scannedLibraries = new Set();
		watcher._lastFullScan = {};
		Zotero.Prefs.clear(watcher._SCANNED_LIBRARIES_PREF);
	}

	function markLibrariesScanned(...libraryIDs) {
		for (let libraryID of libraryIDs) {
			watcher._scannedLibraries.add(libraryID);
			watcher._lastFullScan[libraryID] = Date.now();
		}
	}

	describe("snapshots", function () {
		var savedAvailable, savedBackend;
		var stub;

		beforeEach(function () {
			savedAvailable = watcher.available;
			savedBackend = watcher._backend;
			watcher.available = true;
			resetWatcherState();
		});

		afterEach(function () {
			if (stub) {
				stub.restore();
				stub = null;
			}
			watcher.available = savedAvailable;
			watcher._backend = savedBackend;
			resetWatcherState();
		});

		it("should require a full scan of a library that hasn't been scanned", async function () {
			stub = sinon.stub(watcher, 'getChangedItemKeys').returns(new Set());
			markLibrariesScanned(1);
			await watcher.snapshot();
			assert.isFalse(watcher.needsFullScan(1, true));
			assert.isTrue(watcher.needsFullScan(2, true));
			// Once a full scan has been recorded, watcher results can be relied on
			watcher.recordFullScan(2);
			assert.isFalse(watcher.needsFullScan(2, true));
		});

		it("should require a full scan of every library after a backend fallback", async function () {
			stub = sinon.stub(watcher, 'getChangedItemKeys').returns(null);
			markLibrariesScanned(1, 2);
			await watcher.snapshot();
			assert.isTrue(watcher.needsFullScan(1, true));
			assert.isTrue(watcher.needsFullScan(2, true));
		});

		it("should prune scan records for deleted libraries", async function () {
			stub = sinon.stub(watcher, 'getChangedItemKeys').returns(new Set());
			let userLibraryID = Zotero.Libraries.userLibraryID;
			markLibrariesScanned(userLibraryID, 99999);
			await watcher.snapshot();
			assert.isFalse(watcher.needsFullScan(userLibraryID, true));
			assert.isTrue(watcher.needsFullScan(99999, true));
		});

		it("should require a full scan on a manual sync with a live watcher", async function () {
			stub = sinon.stub(watcher, 'getChangedItemKeys').returns(new Set());
			markLibrariesScanned(1);
			await watcher.snapshot();
			watcher._backend = 'rdcw';
			assert.isTrue(watcher.needsFullScan(1, false));
			assert.isFalse(watcher.needsFullScan(1, true));
			// The FSEvents journal is trusted on manual syncs
			watcher._backend = 'fsevents';
			assert.isFalse(watcher.needsFullScan(1, false));
		});
	});

	describe("multi-library file syncs", function () {
		var apiKey = Zotero.Utilities.randomString(24);
		var server, httpd, port, baseURL;

		beforeEach(async function () {
			Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
			server = sinon.fakeServer.create();
			server.autoRespond = true;

			({ httpd, port } = await startHTTPServer());
			baseURL = `http://localhost:${port}/`;

			await Zotero.Users.setCurrentUserID(1);
			await Zotero.Users.setCurrentUsername("testuser");
		});

		afterEach(async function () {
			Zotero.HTTP.mock = null;
			await new Promise(resolve => httpd.stop(resolve));
			resetWatcherState();
		});

		function makeEngine(libraryID) {
			const { ConcurrentCaller } = ChromeUtils.importESModule(
				"resource://zotero/concurrentCaller.mjs"
			);
			var caller = new ConcurrentCaller(1);
			caller.setLogger(msg => Zotero.debug(msg));

			var client = new Zotero.Sync.APIClient({
				baseURL,
				apiVersion: ZOTERO_CONFIG.API_VERSION,
				apiKey,
				caller,
				background: true
			});

			return new Zotero.Sync.Storage.Engine({
				libraryID,
				controller: new Zotero.Sync.Storage.Mode.ZFS({
					apiClient: client
				}),
				background: true,
				stopOnError: false
			});
		}

		it("should detect an externally modified group file when My Library syncs first", async function () {
			var group = await createGroup();
			group.libraryVersion = 5;
			await group.saveTx();
			group.storageVersion = 5;
			await group.saveTx();

			var item = await importFileAttachment('test.png', { libraryID: group.libraryID });

			// Mark as in sync, with the file mtime in the past
			var path = await item.getFilePathAsync();
			var mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 2000;
			await OS.File.setDates(path, null, mtime);
			item.attachmentSyncedModificationTime = mtime;
			item.attachmentSyncedHash = await item.attachmentHash;
			item.attachmentSyncState = "in_sync";
			await item.saveTx({ skipAll: true });

			// Modify the file externally
			await Zotero.File.putContentsAsync(path, Zotero.Utilities.randomString());

			// Simulate a watcher backend that reports the group file change on the first
			// drain and nothing after that
			var savedAvailable = watcher.available;
			var stub = sinon.stub(watcher, 'getChangedItemKeys');
			stub.returns(new Set());
			stub.onFirstCall().returns(new Set([item.key]));
			watcher.available = true;
			// Simulate the steady state, with both libraries previously scanned
			resetWatcherState();
			markLibrariesScanned(Zotero.Libraries.userLibraryID, group.libraryID);

			var spy = sinon.spy(Zotero.Sync.Storage.Local, 'checkForUpdatedFiles');
			try {
				// Drain events once per session and then file-sync My Library first and the
				// group second, as the sync runner does
				await watcher.snapshot();
				await makeEngine(Zotero.Libraries.userLibraryID).start();
				await makeEngine(group.libraryID).start();
			}
			finally {
				stub.restore();
				spy.restore();
				watcher.available = savedAvailable;
			}

			// The group library should have been checked with the changed item rather than
			// via a full scan -- checkForUpdatedFiles() drains the passed array, so just
			// check that one was passed
			var call = spy.getCalls().find(c => c.args[0] == group.libraryID);
			assert.ok(call, "checkForUpdatedFiles() should be called for the group library");
			assert.isArray(
				call.args[1],
				"checkForUpdatedFiles() should be passed the changed group items"
			);
			assert.equal(
				item.attachmentSyncState,
				Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD,
				"Group file modified on disk should be marked for upload"
			);

			await group.eraseTx();
		});
	});

	describe("ReadDirectoryChangesW backend (Windows)", function () {
		before(async function () {
			if (!Zotero.isWin) {
				this.skip();
			}
			let storageDir = PathUtils.join(
				Zotero.DataDirectory.dir, "storage"
			);
			await IOUtils.makeDirectory(storageDir, {
				ignoreExisting: true
			});
			watcher.close();
		});

		afterEach(function () {
			watcher.close();
		});

		after(function () {
			watcher.init();
		});

		it("should initialize successfully");
		it("should return an empty set when no files have changed");
		it("should detect a modified attachment file");
		it("should detect changes across multiple items");
		it("should not report the same changes twice");
		it("should detect a new file added to a storage directory");
		it("should re-arm overlapped I/O after draining notifications");
		it("should return null on buffer overflow and re-arm");
		it("should only report valid 8-char item keys");
	});

	describe("inotify backend (Linux)", function () {
		before(async function () {
			if (!Zotero.isLinux) {
				this.skip();
			}
			let storageDir = PathUtils.join(
				Zotero.DataDirectory.dir, "storage"
			);
			await IOUtils.makeDirectory(storageDir, {
				ignoreExisting: true
			});
			watcher.close();
			clearWatcherPrefs();
		});

		afterEach(function () {
			watcher.close();
			clearWatcherPrefs();
		});

		after(function () {
			watcher.init();
		});

		it("should initialize successfully");
		it("should return an empty set when no files have changed");
		it("should detect a modified attachment file");
		it("should detect changes across multiple items");
		it("should not report the same changes twice");
		it("should detect a new file added to a storage directory");
		it("should auto-watch newly created storage subdirectories");
		it("should only report valid 8-char item keys");
	});

	describe("fallback behavior", function () {
		it("should return null from getChangedItemKeys() when unavailable", function () {
			let savedAvailable = watcher.available;
			let savedBackend = watcher._backend;
			watcher.available = false;
			watcher._backend = null;
			try {
				let result = watcher.getChangedItemKeys();
				assert.isNull(result);
			}
			finally {
				watcher.available = savedAvailable;
				watcher._backend = savedBackend;
			}
		});

		it("should set available to false after close()", function () {
			if (!Zotero.isMac) {
				this.skip();
			}
			watcher.init();
			assert.isTrue(watcher.available);
			watcher.close();
			assert.isFalse(watcher.available);
			assert.isNull(watcher._backend);
			// Re-init for other tests
			clearWatcherPrefs();
			watcher.init();
		});
	});
});
