const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.import(
  "resource://gre/modules/FxAccountsCommon.js"
);
const { LoginRec } = ChromeUtils.importESModule(
  "resource://services-sync/engines/passwords.sys.mjs"
);
const { Service } = ChromeUtils.importESModule(
  "resource://services-sync/service.sys.mjs"
);

const LoginInfo = Components.Constructor(
  "@mozilla.org/login-manager/loginInfo;1",
  Ci.nsILoginInfo,
  "init"
);

const PropertyBag = Components.Constructor(
  "@mozilla.org/hash-property-bag;1",
  Ci.nsIWritablePropertyBag
);

async function cleanup(engine, server) {
  await engine._tracker.stop();
  await engine.wipeClient();
  Svc.Prefs.resetBranch("");
  Service.recordManager.clearCache();
  if (server) {
    await promiseStopServer(server);
  }
}

add_task(async function setup() {
  // Disable addon sync because AddonManager won't be initialized here.
  await Service.engineManager.unregister("addons");
  await Service.engineManager.unregister("extension-storage");
});

add_task(async function test_ignored_fields() {
  _("Only changes to syncable fields should be tracked");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  enableValidationPrefs();

  let login = await Services.logins.addLoginAsync(
    new LoginInfo(
      "https://example.com",
      "",
      null,
      "username",
      "password",
      "",
      ""
    )
  );
  login.QueryInterface(Ci.nsILoginMetaInfo); // For `guid`.

  engine._tracker.start();

  try {
    let nonSyncableProps = new PropertyBag();
    nonSyncableProps.setProperty("timeLastUsed", Date.now());
    nonSyncableProps.setProperty("timesUsed", 3);
    Services.logins.modifyLogin(login, nonSyncableProps);

    let noChanges = await engine.pullNewChanges();
    deepEqual(noChanges, {}, "Should not track non-syncable fields");

    let syncableProps = new PropertyBag();
    syncableProps.setProperty("username", "newuser");
    Services.logins.modifyLogin(login, syncableProps);

    let changes = await engine.pullNewChanges();
    deepEqual(
      Object.keys(changes),
      [login.guid],
      "Should track syncable fields"
    );
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_ignored_sync_credentials() {
  _("Sync credentials in login manager should be ignored");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  enableValidationPrefs();

  engine._tracker.start();

  try {
    let login = await Services.logins.addLoginAsync(
      new LoginInfo(
        FXA_PWDMGR_HOST,
        null,
        FXA_PWDMGR_REALM,
        "fxa-uid",
        "creds",
        "",
        ""
      )
    );

    let noChanges = await engine.pullNewChanges();
    deepEqual(noChanges, {}, "Should not track new FxA credentials");

    let props = new PropertyBag();
    props.setProperty("password", "newcreds");
    Services.logins.modifyLogin(login, props);

    noChanges = await engine.pullNewChanges();
    deepEqual(noChanges, {}, "Should not track changes to FxA credentials");
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_password_engine() {
  _("Basic password sync test");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("passwords");

  enableValidationPrefs();

  _("Add new login to upload during first sync");
  let newLogin;
  {
    let login = new LoginInfo(
      "https://example.com",
      "",
      null,
      "username",
      "password",
      "",
      ""
    );
    await Services.logins.addLoginAsync(login);

    let logins = Services.logins.findLogins("https://example.com", "", "");
    equal(logins.length, 1, "Should find new login in login manager");
    newLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);

    // Insert a server record that's older, so that we prefer the local one.
    let rec = new LoginRec("passwords", newLogin.guid);
    rec.formSubmitURL = newLogin.formActionOrigin;
    rec.httpRealm = newLogin.httpRealm;
    rec.hostname = newLogin.origin;
    rec.username = newLogin.username;
    rec.password = "sekrit";
    let remotePasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000;
    rec.timeCreated = remotePasswordChangeTime;
    rec.timePasswordChanged = remotePasswordChangeTime;
    collection.insert(
      newLogin.guid,
      encryptPayload(rec.cleartext),
      remotePasswordChangeTime / 1000
    );
  }

  _("Add login with older password change time to replace during first sync");
  let oldLogin;
  {
    let login = new LoginInfo(
      "https://mozilla.com",
      "",
      null,
      "us3r",
      "0ldpa55",
      "",
      ""
    );
    await Services.logins.addLoginAsync(login);

    let props = new PropertyBag();
    let localPasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000;
    props.setProperty("timePasswordChanged", localPasswordChangeTime);
    Services.logins.modifyLogin(login, props);

    let logins = Services.logins.findLogins("https://mozilla.com", "", "");
    equal(logins.length, 1, "Should find old login in login manager");
    oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
    equal(oldLogin.timePasswordChanged, localPasswordChangeTime);

    let rec = new LoginRec("passwords", oldLogin.guid);
    rec.hostname = oldLogin.origin;
    rec.formSubmitURL = oldLogin.formActionOrigin;
    rec.httpRealm = oldLogin.httpRealm;
    rec.username = oldLogin.username;
    // Change the password and bump the password change time to ensure we prefer
    // the remote one during reconciliation.
    rec.password = "n3wpa55";
    rec.usernameField = oldLogin.usernameField;
    rec.passwordField = oldLogin.usernameField;
    rec.timeCreated = oldLogin.timeCreated;
    rec.timePasswordChanged = Date.now();
    collection.insert(oldLogin.guid, encryptPayload(rec.cleartext));
  }

  await engine._tracker.stop();

  try {
    await sync_engine_and_validate_telem(engine, false);

    let newRec = collection.cleartext(newLogin.guid);
    equal(
      newRec.password,
      "password",
      "Should update remote password for newer login"
    );

    let logins = Services.logins.findLogins("https://mozilla.com", "", "");
    equal(
      logins[0].password,
      "n3wpa55",
      "Should update local password for older login"
    );
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_password_dupe() {
  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("passwords");

  let guid1 = Utils.makeGUID();
  let rec1 = new LoginRec("passwords", guid1);
  let guid2 = Utils.makeGUID();
  let cleartext = {
    formSubmitURL: "https://www.example.com",
    hostname: "https://www.example.com",
    httpRealm: null,
    username: "foo",
    password: "bar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Math.round(Date.now()),
    timePasswordChanged: Math.round(Date.now()),
  };
  rec1.cleartext = cleartext;

  _("Create remote record with same details and guid1");
  collection.insert(guid1, encryptPayload(rec1.cleartext));

  _("Create remote record with guid2");
  collection.insert(guid2, encryptPayload(cleartext));

  _("Create local record with same details and guid1");
  await engine._store.create(rec1);

  try {
    _("Perform sync");
    await sync_engine_and_validate_telem(engine, true);

    let logins = Services.logins.findLogins("https://www.example.com", "", "");

    equal(logins.length, 1);
    equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid2);
    equal(null, collection.payload(guid1));
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_updated_null_password_sync() {
  _("Ensure updated null login username is converted to a string");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("passwords");

  let guid1 = Utils.makeGUID();
  let guid2 = Utils.makeGUID();
  let remoteDetails = {
    formSubmitURL: "https://www.nullupdateexample.com",
    hostname: "https://www.nullupdateexample.com",
    httpRealm: null,
    username: null,
    password: "bar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };
  let localDetails = {
    formSubmitURL: "https://www.nullupdateexample.com",
    hostname: "https://www.nullupdateexample.com",
    httpRealm: null,
    username: "foo",
    password: "foobar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };

  _("Create remote record with same details and guid1");
  collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 }));

  try {
    _("Create local updated login with null password");
    await engine._store.update(Object.assign({}, localDetails, { id: guid2 }));

    _("Perform sync");
    await sync_engine_and_validate_telem(engine, false);

    let logins = Services.logins.findLogins(
      "https://www.nullupdateexample.com",
      "",
      ""
    );

    equal(logins.length, 1);
    equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1);
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_updated_undefined_password_sync() {
  _("Ensure updated undefined login username is converted to a string");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("passwords");

  let guid1 = Utils.makeGUID();
  let guid2 = Utils.makeGUID();
  let remoteDetails = {
    formSubmitURL: "https://www.undefinedupdateexample.com",
    hostname: "https://www.undefinedupdateexample.com",
    httpRealm: null,
    username: undefined,
    password: "bar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };
  let localDetails = {
    formSubmitURL: "https://www.undefinedupdateexample.com",
    hostname: "https://www.undefinedupdateexample.com",
    httpRealm: null,
    username: "foo",
    password: "foobar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };

  _("Create remote record with same details and guid1");
  collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 }));

  try {
    _("Create local updated login with undefined password");
    await engine._store.update(Object.assign({}, localDetails, { id: guid2 }));

    _("Perform sync");
    await sync_engine_and_validate_telem(engine, false);

    let logins = Services.logins.findLogins(
      "https://www.undefinedupdateexample.com",
      "",
      ""
    );

    equal(logins.length, 1);
    equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1);
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_new_null_password_sync() {
  _("Ensure new null login username is converted to a string");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let guid1 = Utils.makeGUID();
  let rec1 = new LoginRec("passwords", guid1);
  rec1.cleartext = {
    formSubmitURL: "https://www.example.com",
    hostname: "https://www.example.com",
    httpRealm: null,
    username: null,
    password: "bar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };

  try {
    _("Create local login with null password");
    await engine._store.create(rec1);

    _("Perform sync");
    await sync_engine_and_validate_telem(engine, false);

    let logins = Services.logins.findLogins("https://www.example.com", "", "");

    equal(logins.length, 1);
    notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null);
    notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined);
    equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, "");
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_new_undefined_password_sync() {
  _("Ensure new undefined login username is converted to a string");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let guid1 = Utils.makeGUID();
  let rec1 = new LoginRec("passwords", guid1);
  rec1.cleartext = {
    formSubmitURL: "https://www.example.com",
    hostname: "https://www.example.com",
    httpRealm: null,
    username: undefined,
    password: "bar",
    usernameField: "username-field",
    passwordField: "password-field",
    timeCreated: Date.now(),
    timePasswordChanged: Date.now(),
  };

  try {
    _("Create local login with undefined password");
    await engine._store.create(rec1);

    _("Perform sync");
    await sync_engine_and_validate_telem(engine, false);

    let logins = Services.logins.findLogins("https://www.example.com", "", "");

    equal(logins.length, 1);
    notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null);
    notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined);
    equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, "");
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_sync_password_validation() {
  // This test isn't in test_password_validator to avoid duplicating cleanup.
  _("Ensure that if a password validation happens, it ends up in the ping");

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  Svc.Prefs.set("engine.passwords.validation.interval", 0);
  Svc.Prefs.set("engine.passwords.validation.percentageChance", 100);
  Svc.Prefs.set("engine.passwords.validation.maxRecords", -1);
  Svc.Prefs.set("engine.passwords.validation.enabled", true);

  try {
    let ping = await wait_for_ping(() => Service.sync());

    let engineInfo = ping.engines.find(e => e.name == "passwords");
    ok(engineInfo, "Engine should be in ping");

    let validation = engineInfo.validation;
    ok(validation, "Engine should have validation info");
  } finally {
    await cleanup(engine, server);
  }
});

add_task(async function test_roundtrip_unknown_fields() {
  _(
    "Testing that unknown fields from other clients get roundtripped back to server"
  );

  let engine = Service.engineManager.get("passwords");

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("passwords");

  enableValidationPrefs();

  _("Add login with older password change time to replace during first sync");
  let oldLogin;
  {
    let login = new LoginInfo(
      "https://mozilla.com",
      "",
      null,
      "us3r",
      "0ldpa55",
      "",
      ""
    );
    Services.logins.addLogin(login);

    let props = new PropertyBag();
    let localPasswordChangeTime = Math.round(
      Date.now() - 1 * 60 * 60 * 24 * 1000
    );
    props.setProperty("timePasswordChanged", localPasswordChangeTime);
    Services.logins.modifyLogin(login, props);

    let logins = Services.logins.findLogins("https://mozilla.com", "", "");
    equal(logins.length, 1, "Should find old login in login manager");
    oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
    equal(oldLogin.timePasswordChanged, localPasswordChangeTime);

    let rec = new LoginRec("passwords", oldLogin.guid);
    rec.hostname = oldLogin.origin;
    rec.formSubmitURL = oldLogin.formActionOrigin;
    rec.httpRealm = oldLogin.httpRealm;
    rec.username = oldLogin.username;
    // Change the password and bump the password change time to ensure we prefer
    // the remote one during reconciliation.
    rec.password = "n3wpa55";
    rec.usernameField = oldLogin.usernameField;
    rec.passwordField = oldLogin.usernameField;
    rec.timeCreated = oldLogin.timeCreated;
    rec.timePasswordChanged = Math.round(Date.now());

    // pretend other clients have some snazzy new fields
    // we don't quite understand yet
    rec.cleartext.someStrField = "I am a str";
    rec.cleartext.someObjField = { newField: "I am a new field" };
    collection.insert(oldLogin.guid, encryptPayload(rec.cleartext));
  }

  await engine._tracker.stop();

  try {
    await sync_engine_and_validate_telem(engine, false);

    let logins = Services.logins.findLogins("https://mozilla.com", "", "");
    equal(
      logins[0].password,
      "n3wpa55",
      "Should update local password for older login"
    );
    let expectedUnknowns = JSON.stringify({
      someStrField: "I am a str",
      someObjField: { newField: "I am a new field" },
    });
    // Check that the local record has all unknown fields properly
    // stringified
    equal(logins[0].unknownFields, expectedUnknowns);

    // Check that the server has the unknown fields unfurled and on the
    // top-level record
    let serverRec = collection.cleartext(oldLogin.guid);
    equal(serverRec.someStrField, "I am a str");
    equal(serverRec.someObjField.newField, "I am a new field");
  } finally {
    await cleanup(engine, server);
  }
});
