describe("Zotero.File", function () {
	describe("#getContentsAsync()", function () {
		it("should handle an empty file", async function () {
			var path = OS.Path.join(getTestDataDirectory().path, "empty");
			assert.equal(((await Zotero.File.getContentsAsync(path))), "");
		})
		
		it("should handle an extended character", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "charsets", "utf8.txt")
			);
			assert.lengthOf(contents, 3);
			assert.equal(contents, "A\u72acB");
		})
		
		it("should handle an extended Windows-1252 character", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "charsets", "windows1252.txt"),
				"windows-1252"
			);
			assert.lengthOf(contents, 3);
			assert.equal(contents, "\u201C\u00E9\u201D");
		})
		
		it("should handle a GBK character", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "charsets", "gbk.txt"),
				"gbk"
			);
			assert.lengthOf(contents, 9);
			assert.equal(contents, "这是一个测试文件。");
		})
		
		it("should handle an invalid character", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "charsets", "invalid.txt")
			);
			assert.lengthOf(contents, 3);
			assert.equal(contents, "A" + Zotero.File.REPLACEMENT_CHARACTER + "B");
		})
		
		it("should respect maxLength", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "test.txt"),
				false,
				6
			);
			assert.lengthOf(contents, 6);
			assert.equal(contents, "Zotero");
		});
		
		it("should get a file from a file: URI", async function () {
			var contents = await Zotero.File.getContentsAsync(
				OS.Path.toFileURI(OS.Path.join(getTestDataDirectory().path, "test.txt"))
			);
			assert.isTrue(contents.startsWith('Zotero'));
		});
	})
	
	describe("#getBinaryContentsAsync()", function () {
		var magicPNG = ["89", "50", "4e", "47", "0d", "0a", "1a", "0a"].map(x => parseInt(x, 16));
		
		it("should return a binary string", async function () {
			var contents = await Zotero.File.getBinaryContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "test.png")
			);
			assert.isAbove(contents.length, magicPNG.length);
			for (let i = 0; i < magicPNG.length; i++) {
				assert.equal(magicPNG[i], contents.charCodeAt(i));
			}
		});
		
		it("should take a file:// URI", async function () {
			var file = OS.Path.join(getTestDataDirectory().path, "test.png");
			var uri = PathUtils.toFileURI(file);
			
			var contents = await Zotero.File.getBinaryContentsAsync(uri);
			assert.isAbove(contents.length, magicPNG.length);
			for (let i = 0; i < magicPNG.length; i++) {
				assert.equal(magicPNG[i], contents.charCodeAt(i));
			}
		});
		
		it("should respect maxLength", async function () {
			var contents = await Zotero.File.getBinaryContentsAsync(
				OS.Path.join(getTestDataDirectory().path, "test.png"),
				magicPNG.length
			);
			assert.lengthOf(contents, magicPNG.length)
			for (let i = 0; i < contents.length; i++) {
				assert.equal(magicPNG[i], contents.charCodeAt(i));
			}
		});
	});
	
	describe("#putContentsAsync()", function () {
		it("should save a text string", async function () {
			var tmpDir = await getTempDirectory();
			var destFile = OS.Path.join(tmpDir, 'test');
			var str = 'A';
			await Zotero.File.putContentsAsync(destFile, str);
			assert.equal(await Zotero.File.getContentsAsync(destFile), str);
		});
		
		it("should save a Blob", async function () {
			var srcFile = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
			var tmpDir = await getTempDirectory();
			var destFile = OS.Path.join(tmpDir, 'test.pdf');
			
			var blob = await File.createFromFileName(srcFile);
			await Zotero.File.putContentsAsync(destFile, blob);
			
			var destContents = await Zotero.File.getBinaryContentsAsync(destFile);
			assert.equal(
				await Zotero.File.getBinaryContentsAsync(srcFile),
				destContents
			);
			
			assert.equal(destContents.substr(0, 4), '%PDF');
		});
		
		it("should save via .tmp file", async function () {
			var tmpDir = await getTempDirectory();
			var destFile = OS.Path.join(tmpDir, 'test.txt')
			var tmpFile = destFile + ".tmp";
			await Zotero.File.putContentsAsync(tmpFile, 'A');
			assert.isTrue(await OS.File.exists(tmpFile));
			await Zotero.File.putContentsAsync(destFile, 'B');
			assert.isFalse(await OS.File.exists(tmpFile));
			// Make sure .tmp file was deleted
			assert.isFalse(await OS.File.exists(tmpFile + '.tmp'));
		});
	});
	
	
	describe("#rename()", function () {
		it("should rename a file", async function () {
			var tmpDir = await getTempDirectory();
			var sourceFile = OS.Path.join(tmpDir, 'a');
			var destFile = OS.Path.join(tmpDir, 'b');
			await Zotero.File.putContentsAsync(sourceFile, '');
			await Zotero.File.rename(sourceFile, 'b');
			assert.isTrue(await OS.File.exists(destFile));
		});
		
		// Only relevant on a case-insensitive filesystem
		it("should rename a file with a case-only change (Mac)", async function () {
			var tmpDir = await getTempDirectory();
			var sourceFile = OS.Path.join(tmpDir, 'a');
			var destFile = OS.Path.join(tmpDir, 'A');
			await Zotero.File.putContentsAsync(sourceFile, 'foo');
			var newFilename = await Zotero.File.rename(sourceFile, 'A');
			assert.equal(newFilename, 'A');
			assert.equal(await Zotero.File.getContentsAsync(destFile), 'foo');
		});
		
		it("should overwrite an existing file if `overwrite` is true", async function () {
			var tmpDir = await getTempDirectory();
			var sourceFile = OS.Path.join(tmpDir, 'a');
			var destFile = OS.Path.join(tmpDir, 'b');
			await Zotero.File.putContentsAsync(sourceFile, 'a');
			await Zotero.File.putContentsAsync(destFile, 'b');
			await Zotero.File.rename(sourceFile, 'b', { overwrite: true });
			assert.isTrue(await OS.File.exists(destFile));
			assert.equal(await Zotero.File.getContentsAsync(destFile), 'a');
		});
		
		it("should get a unique name if target file exists and `unique` is true", async function () {
			var tmpDir = await getTempDirectory();
			var sourceFile = OS.Path.join(tmpDir, 'a');
			var destFile = OS.Path.join(tmpDir, 'b');
			await Zotero.File.putContentsAsync(sourceFile, 'a');
			await Zotero.File.putContentsAsync(destFile, 'b');
			var newFilename = await Zotero.File.rename(sourceFile, 'b', { unique: true });
			var realDestFile = OS.Path.join(tmpDir, newFilename);
			assert.equal(newFilename, 'b 2');
			assert.isTrue(await OS.File.exists(realDestFile));
			assert.equal(await Zotero.File.getContentsAsync(realDestFile), 'a');
		});
	});
	
	
	describe("#getClosestDirectory()", function () {
		it("should return directory for file that exists", async function () {
			var tmpDir = await getTempDirectory();
			var closest = await Zotero.File.getClosestDirectory(tmpDir);
			assert.equal(closest, tmpDir);
		});
		
		it("should return parent directory for missing file", async function () {
			var tmpDir = await getTempDirectory();
			var closest = await Zotero.File.getClosestDirectory(OS.Path.join(tmpDir, 'a'));
			assert.equal(closest, tmpDir);
		});
		
		it("should find an existing directory three levels up from a missing file", async function () {
			var tmpDir = await getTempDirectory();
			var closest = await Zotero.File.getClosestDirectory(OS.Path.join(tmpDir, 'a', 'b', 'c'));
			assert.equal(closest, tmpDir);
		});
		
		it("should return false for a path that doesn't exist at all", async function () {
			assert.isFalse(await Zotero.File.getClosestDirectory('/a/b/c'));
		});
	});
	
	
	describe("#moveToUnique", function () {
		it("should move a file to a unique filename", async function () {
			var tmpDir = Zotero.getTempDirectory().path;
			var sourceFile = OS.Path.join(tmpDir, "1");
			var tmpTargetDir = OS.Path.join(tmpDir, "targetDirectory")
			var targetFile = OS.Path.join(tmpTargetDir, "file.txt");
			await OS.File.makeDir(tmpTargetDir);
			await Zotero.File.putContentsAsync(sourceFile, "");
			await Zotero.File.putContentsAsync(targetFile, "");
			var newFile = await Zotero.File.moveToUnique(sourceFile, targetFile);
			assert.equal(OS.Path.join(tmpTargetDir, 'file-1.txt'), newFile);
		});
	});
	
	
	describe("#isAPFS()", function () {
		it("should return true on macOS for the temp directory", function () {
			if (!Zotero.isMac) this.skip();
			assert.isTrue(Zotero.File.isAPFS(Zotero.getTempDirectory().path));
		});

		it("should return false on non-Mac platforms", function () {
			if (Zotero.isMac) this.skip();
			assert.isFalse(Zotero.File.isAPFS(Zotero.getTempDirectory().path));
		});
	});

	describe("#copyFile()", function () {
		it("should copy a file", async function () {
			let tmpDir = await getTempDirectory();
			let source = OS.Path.join(tmpDir, "source.txt");
			let target = OS.Path.join(tmpDir, "target.txt");
			await Zotero.File.putContentsAsync(source, "Hello world");

			await Zotero.File.copyFile(source, target);

			assert.isTrue(await OS.File.exists(target));
			assert.equal(await Zotero.File.getContentsAsync(target), "Hello world");
		});

		it("should copy a file to a different directory", async function () {
			let tmpDir = await getTempDirectory();
			let subDir = OS.Path.join(tmpDir, "sub");
			await OS.File.makeDir(subDir);
			let source = OS.Path.join(tmpDir, "source.txt");
			let target = OS.Path.join(subDir, "target.txt");
			await Zotero.File.putContentsAsync(source, "Hello world");

			await Zotero.File.copyFile(source, target);

			assert.isTrue(await OS.File.exists(target));
			assert.equal(await Zotero.File.getContentsAsync(target), "Hello world");
			// Source should still exist
			assert.isTrue(await OS.File.exists(source));
		});
	});


	describe("#copyDirectory()", function () {
		it("should copy all files within a directory", async function () {
			var tmpDir = Zotero.getTempDirectory().path;
			var tmpCopyDir = OS.Path.join(tmpDir, "copyDirectory")
			var source = OS.Path.join(tmpCopyDir, "1");
			var target = OS.Path.join(tmpCopyDir, "2");
			await OS.File.makeDir(source, {
				from: tmpDir
			});
			
			await Zotero.File.putContentsAsync(OS.Path.join(source, "A"), "Test 1");
			await Zotero.File.putContentsAsync(OS.Path.join(source, "B"), "Test 2");
			
			await OS.File.removeDir(target, {
				ignoreAbsent: true
			});
			
			await Zotero.File.copyDirectory(source, target);
			
			assert.equal(
				((await Zotero.File.getContentsAsync(OS.Path.join(target, "A")))),
				"Test 1"
			);
			assert.equal(
				((await Zotero.File.getContentsAsync(OS.Path.join(target, "B")))),
				"Test 2"
			);
		})
		
		it("should copy subfolders", async function () {
			// file1
			// subdir/file2
			var tmpDir = await getTempDirectory()
			var source = OS.Path.join(tmpDir, "1");
			await OS.File.makeDir(OS.Path.join(source, 'subdir'), {
				from: tmpDir
			});
			Zotero.File.putContents(Zotero.File.pathToFile(OS.Path.join(source, 'file1')), 'abc');
			Zotero.File.putContents(Zotero.File.pathToFile(OS.Path.join(source, 'subdir', 'file2')), 'def');
			
			var target = OS.Path.join(tmpDir, "2");
			await OS.File.makeDir(target);
			
			await Zotero.File.copyDirectory(source, target);
			
			var targetFile1 = OS.Path.join(target, 'file1');
			var targetFile2 = OS.Path.join(target, 'subdir', 'file2');
			assert.isTrue(await OS.File.exists(targetFile1));
			assert.isTrue(await OS.File.exists(targetFile2));
			assert.equal(Zotero.File.getContents(targetFile1), 'abc');
			assert.equal(Zotero.File.getContents(targetFile2), 'def');
		});
	})
	
	describe("#createDirectoryIfMissing()", function () {
		it("should throw error on broken symlink", async function () {
			if (Zotero.isWin) {
				this.skip();
			};
			
			var tmpPath = await getTempDirectory();
			var destPath = OS.Path.join(tmpPath, 'missing');
			var linkPath = OS.Path.join(tmpPath, 'link');
			await OS.File.unixSymLink(destPath, linkPath);
			
			assert.throws(() => Zotero.File.createDirectoryIfMissing(linkPath), /^Broken symlink/);
		});
	});
	
	describe("#createDirectoryIfMissingAsync()", function () {
		it("should throw error on broken symlink", async function () {
			if (Zotero.isWin) {
				this.skip();
			};
			
			var tmpPath = await getTempDirectory();
			var destPath = OS.Path.join(tmpPath, 'missing');
			var linkPath = OS.Path.join(tmpPath, 'link');
			await OS.File.unixSymLink(destPath, linkPath);
			
			var e = await getPromiseError(Zotero.File.createDirectoryIfMissingAsync(linkPath));
			assert.ok(e);
			assert.match(e.message, /^Broken symlink/);
		});
		
		it("should handle 'from' in options", async function () {
			var tmpPath = await getTempDirectory();
			var path = OS.Path.join(tmpPath, 'a', 'b');
			await Zotero.File.createDirectoryIfMissingAsync(path, { from: tmpPath });
			assert.isTrue(await OS.File.exists(path));
		});
	});
	
	describe("#directoryContains()", function () {
		it("should return true for file within folder ending in slash", function () {
			assert.isTrue(Zotero.File.directoryContains('/foo/', '/foo/bar'));
		});
		
		it("should return true for file within folder not ending in slash", function () {
				assert.isTrue(Zotero.File.directoryContains('/foo/', '/foo/bar'));
		});
		
		it("should return true for file within subfolder", function () {
				assert.isTrue(Zotero.File.directoryContains('/foo/', '/foo/bar/qux'));
		});
		
		it("should return false for subfolder with same name within another folder", function () {
				assert.isFalse(Zotero.File.directoryContains('/foo', '/bar/foo'));
		});
		
		it("should return false for sibling folder that starts with the same string", function () {
			assert.isFalse(Zotero.File.directoryContains('/foo', '/foobar'));
		});
	});
	
	describe("#zipDirectory()", function () {
		it("should compress a directory recursively", async function () {
			var tmpPath = Zotero.getTempDirectory().path;
			var path = OS.Path.join(tmpPath, Zotero.Utilities.randomString());
			await OS.File.makeDir(path, { unixMode: 0o755 });
			await Zotero.File.putContentsAsync(OS.Path.join(path, '.zotero-ft-cache'), '');
			await Zotero.File.putContentsAsync(OS.Path.join(path, 'a.txt'), 'A');
			// Create subdirectory
			var subPath = OS.Path.join(path, 'sub');
			await OS.File.makeDir(subPath, { unixMode: 0o755 });
			await Zotero.File.putContentsAsync(OS.Path.join(subPath, 'b.txt'), 'B');
			
			var zipFile = OS.Path.join(tmpPath, 'test.zip');
			await Zotero.File.zipDirectory(path, zipFile);
			
			var zr = Components.classes["@mozilla.org/libjar/zip-reader;1"]
				.createInstance(Components.interfaces.nsIZipReader);
			zr.open(Zotero.File.pathToFile(zipFile));
			var entries = zr.findEntries('*');
			var files = {};
			var is = Components.classes['@mozilla.org/scriptableinputstream;1']
				.createInstance(Components.interfaces.nsIScriptableInputStream);
			while (entries.hasMore()) {
				let entryPointer = entries.getNext();
				let entry = zr.getEntry(entryPointer);
				let inputStream = zr.getInputStream(entryPointer);
				is.init(inputStream);
				files[entryPointer] = is.read(entry.realSize);
			}
			zr.close();
			
			assert.notProperty(files, '.zotero-ft-cache');
			assert.propertyVal(files, 'a.txt', 'A');
			assert.propertyVal(files, 'sub/b.txt', 'B');
		});
	});
	
	
	describe("#truncateFileName()", function () {
		it("should drop extension if longer than limit", function () {
			var filename = "lorem.json";
			var shortened = Zotero.File.truncateFileName(filename, 5);
			assert.equal(shortened, "lorem");
		});
		
		it("should use byte length rather than character length", function () {
			var filename = "\uD83E\uDD92abcdefgh.pdf";
			var shortened = Zotero.File.truncateFileName(filename, 10);
			assert.equal(shortened, "\uD83E\uDD92ab.pdf");
		});
		
		it("should remove characters, not bytes", function () {
			// Emoji would put length over limit, so it should be removed completely
			var filename = "abcé\uD83E\uDD92.pdf";
			var shortened = Zotero.File.truncateFileName(filename, 10);
			assert.equal(shortened, "abcé.pdf");
		});
		
		it("should replace single multi-byte character with underscore if longer than maxLength", function () {
			// Emoji would put length over limit, so it should be replaced with _
			var filename = "\uD83E\uDD92.pdf";
			var shortened = Zotero.File.truncateFileName(filename, 5);
			assert.equal(shortened, "_.pdf");
		});
		
		// The optimal behavior would probably be to remove the entire character sequence, but I'm
		// not sure we can do that without an emoji library, so just make sure we're removing whole
		// characters without corrupting anything.
		it("should cruelly break apart families", function () {
			var family = [
				"\uD83D\uDC69", // woman (4)
				"\uD83C\uDFFE", // skin tone (4)
				"\u200D", // zero-width joiner (3)
				"\uD83D\uDC68", // man (4)
				"\uD83C\uDFFE", // skin tone (4)
				"\u200D", // zero-width joiner (3)
				"\uD83D\uDC67", // girl (4)
				"\uD83C\uDFFE", // skin tone (4)
				"\u200D", // zero-width joiner (3)
				"\uD83D\uDC66", // boy (4)
				"\uD83C\uDFFE" // skin tone (4)
			].join("");
			
			var filename = "abc" + family + ".pdf";
			var limit = 3 // 'abc'
				+ 4 + 4 + 3
				+ 4 + 4 + 3
				+ 4; // ext
			// Add some extra bytes to make sure we don't corrupt an emoji character
			limit += 2;
			var shortened = Zotero.File.truncateFileName(filename, limit);
			assert.equal(
				shortened,
				"abc"
					+ "\uD83D\uDC69"
					+ "\uD83C\uDFFE"
					+ "\u200D"
					+ "\uD83D\uDC68"
					+ "\uD83C\uDFFE"
					+ "\u200D"
					+ ".pdf"
			);
		});
	});
	
	
	describe("#checkFileAccessError()", function () {
		it("should catch OS.File access-denied errors", async function () {
			// We can't modify a real OS.File.Error, but we also don't do an instanceof check in
			// checkFileAccessError, so just set the expected properties.
			var e = {
				operation: 'open',
				becauseAccessDenied: true,
				path: '/tmp/test'
			};
			try {
				Zotero.File.checkFileAccessError(e, e.path, 'create');
			}
			catch (e) {
				if (e instanceof Zotero.Error) {
					return;
				}
				throw e;
			}
			throw new Error("Error not thrown");
		});
	});

	describe('#download()', function () {
		const sizeInMB = 16; // size of the generated text file
		let port, httpd, baseURL;

		before(async function () {
			// Real HTTP server
			var { HttpServer } = ChromeUtils.importESModule("chrome://remote/content/server/httpd.sys.mjs");;
			port = 16213;
			httpd = new HttpServer();
			baseURL = `http://127.0.0.1:${port}`;
			httpd.start(port);
			httpd.registerPathHandler(
				'/file1.txt',
				{
					handle: function (request, response) {
						const text1KB = Array.from({ length: 64 }, _ => "lorem ipsum foo\n").join('');
						const text16MB = Array.from({ length: 1024 * sizeInMB }, _ => text1KB).join('');
						response.setStatusLine(null, 200, "OK");
						response.setHeader('Content-Type', 'text/plain', false);
						response.write(text16MB);
					}
				}
			);
		});

		after(function* () {
			var defer = Zotero.Promise.defer();
			httpd.stop(() => defer.resolve());
			yield defer.promise;
		});

		it("should download a file", async function () {
			const url = `${baseURL}/file1.txt`;
			const path = OS.Path.join(Zotero.getTempDirectory().path, 'zotero.txt');
			await Zotero.File.download(url, path);
			const fileSize = (await OS.File.stat(path)).size;
			assert.equal(fileSize, 1024 * 1024 * sizeInMB);
		});

		it("should concurrently download three large files", async function () {
			const url = `${baseURL}/file1.txt`;
			
			const { ConcurrentCaller } = ChromeUtils.importESModule("resource://zotero/concurrentCaller.mjs");
			var caller = new ConcurrentCaller({
				numConcurrent: 3,
			});

			let failed = false;

			const fetchFile = async (srcUrl, targetPath) => {
				try {
					await Zotero.File.download(srcUrl, targetPath);
				}
				catch (e) {
					failed = true;
					throw e;
				}
			};

			
			caller.add(() => fetchFile(url, OS.Path.join(Zotero.getTempDirectory().path, 'zotero-1.txt')));
			caller.add(() => fetchFile(url, OS.Path.join(Zotero.getTempDirectory().path, 'zotero-2.txt')));
			caller.add(() => fetchFile(url, OS.Path.join(Zotero.getTempDirectory().path, 'zotero-3.txt')));

			await caller.runAll();

			assert.isFalse(failed);
			for (let i = 1; i < 4; i++) {
				const path = OS.Path.join(Zotero.getTempDirectory().path, `zotero-${i}.txt`);
				const fileSize = (await OS.File.stat(path)).size;
				assert.equal(fileSize, 1024 * 1024 * sizeInMB);
			}
		});

		it("should extract a file from xpi", async function () {
			const url = `jar:file://${getTestDataDirectory().path}/fake.xpi!/test.txt`;
			const path = OS.Path.join(Zotero.getTempDirectory().path, 'xpi-extracted.txt');
			await Zotero.File.download(url, path);
			const contents = await Zotero.File.getContentsAsync(path);
			assert.equal(contents, 'Hello Zotero\n');
		});
	});

	describe("#normalizeToUnix()", function () {
		it("should normalize a Unix-style path", async function () {
			assert.equal(Zotero.File.normalizeToUnix('/path/to/directory/'), '/path/to/directory');
		});

		it("should normalize '.' and '..'", async function () {
			assert.equal(Zotero.File.normalizeToUnix('/path/./to/some/../file'), '/path/to/file');
		});

		it("should replace backslashes with forward slashes and trim trailing", async function () {
			assert.equal(Zotero.File.normalizeToUnix('C:\\Zotero\\Some\\Directory\\'), 'C:/Zotero/Some/Directory');
		});
	});
})
