/**
 * Tests the JSONFile object.
 */

"use strict";

// Globals
XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
                                  "resource://gre/modules/AsyncShutdown.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
                                  "resource://gre/modules/DownloadPaths.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                  "resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "JSONFile",
                                  "resource://gre/modules/JSONFile.jsm");

let gFileCounter = Math.floor(Math.random() * 1000000);

/**
 * Returns a reference to a temporary file, that is guaranteed not to exist, and
 * to have never been created before.
 *
 * @param aLeafName
 *        Suggested leaf name for the file to be created.
 *
 * @return nsIFile pointing to a non-existent file in a temporary directory.
 *
 * @note It is not enough to delete the file if it exists, or to delete the file
 *       after calling nsIFile.createUnique, because on Windows the delete
 *       operation in the file system may still be pending, preventing a new
 *       file with the same name to be created.
 */
function getTempFile(aLeafName) {
  // Prepend a serial number to the extension in the suggested leaf name.
  let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName);
  let leafName = base + "-" + gFileCounter + ext;
  gFileCounter++;

  // Get a file reference under the temporary directory for this test file.
  let file = FileUtils.getFile("TmpD", [leafName]);
  do_check_false(file.exists());

  do_register_cleanup(function() {
    if (file.exists()) {
      file.remove(false);
    }
  });

  return file;
}

const TEST_STORE_FILE_NAME = "test-store.json";

const TEST_DATA = {
  number: 123,
  string: "test",
  object: {
    prop1: 1,
    prop2: 2,
  },
};

// Tests

add_task(function* test_save_reload() {
  let storeForSave = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
  });

  yield storeForSave.load();

  do_check_true(storeForSave.dataReady);
  do_check_matches(storeForSave.data, {});

  Object.assign(storeForSave.data, TEST_DATA);

  yield new Promise((resolve) => {
    let save = storeForSave._save.bind(storeForSave);
    storeForSave._save = () => {
      save();
      resolve();
    };
    storeForSave.saveSoon();
  });

  let storeForLoad = new JSONFile({
    path: storeForSave.path,
  });

  yield storeForLoad.load();

  Assert.deepEqual(storeForLoad.data, TEST_DATA);
});

add_task(function* test_load_sync() {
  let storeForSave = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path
  });
  yield storeForSave.load();
  Object.assign(storeForSave.data, TEST_DATA);
  yield storeForSave._save();

  let storeForLoad = new JSONFile({
    path: storeForSave.path,
  });
  storeForLoad.ensureDataReady();

  Assert.deepEqual(storeForLoad.data, TEST_DATA);
});

add_task(function* test_load_with_dataPostProcessor() {
  let storeForSave = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path
  });
  yield storeForSave.load();
  Object.assign(storeForSave.data, TEST_DATA);
  yield storeForSave._save();

  let random = Math.random();
  let storeForLoad = new JSONFile({
    path: storeForSave.path,
    dataPostProcessor: (data) => {
      Assert.deepEqual(data, TEST_DATA);

      data.test = random;
      return data;
    },
  });

  yield storeForLoad.load();

  do_check_eq(storeForLoad.data.test, random);
});

add_task(function* test_load_with_dataPostProcessor_fails() {
  let store = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
    dataPostProcessor: () => {
      throw new Error("dataPostProcessor fails.");
    },
  });

  yield Assert.rejects(store.load(), /dataPostProcessor fails\./);

  do_check_false(store.dataReady);
});

add_task(function* test_load_sync_with_dataPostProcessor_fails() {
  let store = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
    dataPostProcessor: () => {
      throw new Error("dataPostProcessor fails.");
    },
  });

  Assert.throws(() => store.ensureDataReady(), /dataPostProcessor fails\./);

  do_check_false(store.dataReady);
});

/**
 * Loads data from a string in a predefined format.  The purpose of this test is
 * to verify that the JSON format used in previous versions can be loaded.
 */
add_task(function* test_load_string_predefined() {
  let store = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
  });

  let string =
    "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,\"prop2\":2}}";

  yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
                            { tmpPath: store.path + ".tmp" });

  yield store.load();

  Assert.deepEqual(store.data, TEST_DATA);
});

/**
 * Loads data from a malformed JSON string.
 */
add_task(function* test_load_string_malformed() {
  let store = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
  });

  let string = "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,";

  yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
                            { tmpPath: store.path + ".tmp" });

  yield store.load();

  // A backup file should have been created.
  do_check_true(yield OS.File.exists(store.path + ".corrupt"));
  yield OS.File.remove(store.path + ".corrupt");

  // The store should be ready to accept new data.
  do_check_true(store.dataReady);
  do_check_matches(store.data, {});
});

/**
 * Loads data from a malformed JSON string, using the synchronous initialization
 * path.
 */
add_task(function* test_load_string_malformed_sync() {
  let store = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
  });

  let string = "{\"number\":123,\"string\":\"test\",\"object\":{\"prop1\":1,";

  yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
                            { tmpPath: store.path + ".tmp" });

  store.ensureDataReady();

  // A backup file should have been created.
  do_check_true(yield OS.File.exists(store.path + ".corrupt"));
  yield OS.File.remove(store.path + ".corrupt");

  // The store should be ready to accept new data.
  do_check_true(store.dataReady);
  do_check_matches(store.data, {});
});

add_task(function* test_overwrite_data() {
  let storeForSave = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
  });

  let string = `{"number":456,"string":"tset","object":{"prop1":3,"prop2":4}}`;

  yield OS.File.writeAtomic(storeForSave.path, new TextEncoder().encode(string),
                            { tmpPath: storeForSave.path + ".tmp" });

  Assert.ok(!storeForSave.dataReady);
  storeForSave.data = TEST_DATA;
  Assert.ok(storeForSave.dataReady);
  Assert.equal(storeForSave.data, TEST_DATA);

  yield new Promise((resolve) => {
    let save = storeForSave._save.bind(storeForSave);
    storeForSave._save = () => {
      save();
      resolve();
    };
    storeForSave.saveSoon();
  });

  let storeForLoad = new JSONFile({
    path: storeForSave.path,
  });

  yield storeForLoad.load();

  Assert.deepEqual(storeForLoad.data, TEST_DATA);
});

add_task(function* test_beforeSave() {
  let store;
  let promiseBeforeSave = new Promise((resolve) => {
    store = new JSONFile({
      path: getTempFile(TEST_STORE_FILE_NAME).path,
      beforeSave: resolve,
      saveDelayMs: 250,
    });
  });

  store.saveSoon();

  yield promiseBeforeSave;
});

add_task(function* test_beforeSave_rejects() {
  let storeForSave = new JSONFile({
    path: getTempFile(TEST_STORE_FILE_NAME).path,
    beforeSave() {
      return Promise.reject(new Error("oops"));
    },
    saveDelayMs: 250,
  });

  let promiseSave = new Promise((resolve, reject) => {
    let save = storeForSave._save.bind(storeForSave);
    storeForSave._save = () => {
      save().then(resolve, reject);
    };
    storeForSave.saveSoon();
  });

  yield Assert.rejects(promiseSave, function(ex) {
    return ex.message == "oops";
  });
});

add_task(function* test_finalize() {
  let path = getTempFile(TEST_STORE_FILE_NAME).path;

  let barrier = new AsyncShutdown.Barrier("test-auto-finalize");
  let storeForSave = new JSONFile({
    path,
    saveDelayMs: 2000,
    finalizeAt: barrier.client,
  });
  yield storeForSave.load();
  storeForSave.data = TEST_DATA;
  storeForSave.saveSoon();

  let promiseFinalize = storeForSave.finalize();
  yield Assert.rejects(storeForSave.finalize(), /has already been finalized$/);
  yield promiseFinalize;
  do_check_false(storeForSave.dataReady);

  // Finalization removes the blocker, so waiting should not log an unhandled
  // error even though the object has been explicitly finalized.
  yield barrier.wait();

  let storeForLoad = new JSONFile({ path });
  yield storeForLoad.load();
  do_check_matches(storeForLoad.data, TEST_DATA);
});

add_task(function* test_finalize_on_shutdown() {
  let path = getTempFile(TEST_STORE_FILE_NAME).path;

  let barrier = new AsyncShutdown.Barrier("test-finalize-shutdown");
  let storeForSave = new JSONFile({
    path,
    saveDelayMs: 2000,
    finalizeAt: barrier.client,
  });
  yield storeForSave.load();
  storeForSave.data = TEST_DATA;
  // Arm the saver, then simulate shutdown and ensure the file is
  // automatically finalized.
  storeForSave.saveSoon();

  yield barrier.wait();
  // It's possible for `finalize` to reject when called concurrently with
  // shutdown. We don't distinguish between explicit `finalize` calls and
  // finalization on shutdown because we expect most consumers to rely on the
  // latter. However, this behavior can be safely changed if needed.
  yield Assert.rejects(storeForSave.finalize(), /has already been finalized$/);
  do_check_false(storeForSave.dataReady);

  let storeForLoad = new JSONFile({ path });
  yield storeForLoad.load();
  do_check_matches(storeForLoad.data, TEST_DATA);
});
