/**
 * Helpers for password manager mochitest-plain tests.
 */

/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */

const { LoginTestUtils } = SpecialPowers.ChromeUtils.importESModule(
  "resource://testing-common/LoginTestUtils.sys.mjs"
);
const Services = SpecialPowers.Services;

// Setup LoginTestUtils to report assertions to the mochitest harness.
LoginTestUtils.setAssertReporter(
  SpecialPowers.wrapCallback((err, message, stack) => {
    SimpleTest.record(!err, err ? err.message : message, null, stack);
  })
);

const { LoginHelper } = SpecialPowers.ChromeUtils.importESModule(
  "resource://gre/modules/LoginHelper.sys.mjs"
);

const { LENGTH: GENERATED_PASSWORD_LENGTH, REGEX: GENERATED_PASSWORD_REGEX } =
  LoginTestUtils.generation;
const LOGIN_FIELD_UTILS = LoginTestUtils.loginField;
const TESTS_DIR = "/tests/toolkit/components/passwordmgr/test/";

/**
 * Recreate a DOM tree using the outerHTML to ensure that any event listeners
 * and internal state for the elements are removed.
 */
function recreateTree(element) {
  // eslint-disable-next-line no-self-assign
  element.outerHTML = element.outerHTML;
}

function _checkArrayValues(actualValues, expectedValues, msg) {
  is(
    actualValues.length,
    expectedValues.length,
    "Checking array values: " + msg
  );
  for (let i = 0; i < expectedValues.length; i++) {
    is(actualValues[i], expectedValues[i], msg + " Checking array entry #" + i);
  }
}

/**
 * Check autocomplete popup results to ensure that expected
 * *labels* are being shown correctly as items in the popup.
 */
function checkAutoCompleteResults(actualValues, expectedValues, hostname, msg) {
  if (hostname === null) {
    _checkArrayValues(actualValues, expectedValues, msg);
    return;
  }

  isnot(
    actualValues.length,
    0,
    "There should be items in the autocomplete popup: " +
      JSON.stringify(actualValues)
  );

  // Check the footer first.
  let footerResult = actualValues[actualValues.length - 1];
  is(footerResult, "Manage Passwords", "the footer text is shown correctly");

  if (actualValues.length == 1) {
    is(
      expectedValues.length,
      0,
      "If only the footer is present then there should be no expectedValues"
    );
    info("Only the footer is present in the popup");
    return;
  }

  // Check the rest of the autocomplete item values.
  _checkArrayValues(actualValues.slice(0, -1), expectedValues, msg);
}

/**
 * Wait for autocomplete popup to get closed
 * @return {Promise} resolving when the AC popup is closed
 */
async function untilAutocompletePopupClosed() {
  return SimpleTest.promiseWaitForCondition(async () => {
    const popupState = await getPopupState();
    return !popupState.open;
  }, "Wait for autocomplete popup to be closed");
}

function getIframeBrowsingContext(window, iframeNumber = 0) {
  let bc = SpecialPowers.wrap(window).windowGlobalChild.browsingContext;
  return SpecialPowers.unwrap(bc.children[iframeNumber]);
}

/**
 * Set input values via setUserInput to emulate user input
 * and distinguish them from declarative or script-assigned values
 */
function setUserInputValues(parentNode, selectorValues, userInput = true) {
  for (let [selector, newValue] of Object.entries(selectorValues)) {
    info(`setUserInputValues, selector: ${selector}`);
    try {
      let field = SpecialPowers.wrap(parentNode.querySelector(selector));
      if (field.value == newValue) {
        // we don't get an input event if the new value == the old
        field.value += "#";
      }
      if (userInput) {
        field.setUserInput(newValue);
      } else {
        field.value = newValue;
      }
    } catch (ex) {
      info(ex.message);
      info(ex.stack);
      ok(
        false,
        `setUserInputValues: Couldn't set value of field: ${ex.message}`
      );
    }
  }
}

/**
 * @param {Function} [aFilterFn = undefined] Function to filter out irrelevant submissions.
 * @return {Promise} resolving when a relevant form submission was processed.
 */
function getSubmitMessage(aFilterFn = undefined) {
  info("getSubmitMessage");
  return new Promise(resolve => {
    PWMGR_COMMON_PARENT.addMessageListener(
      "formSubmissionProcessed",
      function processed(...args) {
        if (aFilterFn && !aFilterFn(...args)) {
          // This submission isn't the one we're waiting for.
          return;
        }

        info("got formSubmissionProcessed");
        PWMGR_COMMON_PARENT.removeMessageListener(
          "formSubmissionProcessed",
          processed
        );
        resolve(args[0]);
      }
    );
  });
}

/**
 * @return {Promise} resolves when a onPasswordEditedOrGenerated message is received at the parent
 */
function getPasswordEditedMessage() {
  info("getPasswordEditedMessage");
  return new Promise(resolve => {
    PWMGR_COMMON_PARENT.addMessageListener(
      "passwordEditedOrGenerated",
      function listener(...args) {
        info("got passwordEditedOrGenerated");
        PWMGR_COMMON_PARENT.removeMessageListener(
          "passwordEditedOrGenerated",
          listener
        );
        resolve(args[0]);
      }
    );
  });
}

/**
 * Create a login form and insert into contents dom (identified by id
 * `content`). If the form (identified by its number) is already present in the
 * dom, it gets replaced.
 *
 * @param {number} [num = 1] - number of the form, used as id, eg `form1`
 * @param {string} [action = ""] - action attribute of the form
 * @param {string} [autocomplete  = null] - forms autocomplete attribute. Default is none
 * @param {object} [username = {}] - object describing attributes to the username field:
 * @param {string} [username.id = null] - id of the field
 * @param {string} [username.name = "uname"] - name attribute
 * @param {string} [username.type = "text"] - type of the field
 * @param {string} [username.value = null] - initial value of the field
 * @param {string} [username.autocomplete = null] - autocomplete attribute
 * @param {object} [password = {}] - an object describing attributes to the password field. If falsy, do not create a password field
 * @param {string} [password.id = null] - id of the field
 * @param {string} [password.name = "pword"] - name attribute
 * @param {string} [password.type = "password"] - type of the field
 * @param {string} [password.value = null] - initial value of the field
 * @param {string} [password.label = null] - if present, wrap field in a label containing its value
 * @param {string} [password.autocomplete = null] - autocomplete attribute
 *
 * @return {HTMLDomElement} the form
 */
function createLoginForm({
  num = 1,
  action = "",
  autocomplete = null,
  username = {},
  password = {},
} = {}) {
  username.name ||= "uname";
  username.type ||= "text";
  username.id ||= null;
  username.value ||= null;
  username.autocomplete ||= null;

  password.name ||= "pword";
  password.type ||= "password";
  password.id ||= null;
  password.value ||= null;
  password.label ||= null;
  password.autocomplete ||= null;
  password.readonly ||= null;
  password.disabled ||= null;

  info(
    `Creating login form ${JSON.stringify({ num, action, username, password })}`
  );

  const form = document.createElement("form");
  form.id = `form${num}`;
  form.action = action;
  form.onsubmit = () => false;

  if (autocomplete != null) {
    form.setAttribute("autocomplete", autocomplete);
  }

  const usernameInput = document.createElement("input");

  usernameInput.type = username.type;
  usernameInput.name = username.name;

  if (username.id != null) {
    usernameInput.id = username.id;
  }
  if (username.value != null) {
    usernameInput.value = username.value;
  }
  if (username.autocomplete != null) {
    usernameInput.setAttribute("autocomplete", username.autocomplete);
  }

  form.appendChild(usernameInput);

  if (password) {
    const passwordInput = document.createElement("input");

    passwordInput.type = password.type;
    passwordInput.name = password.name;

    if (password.id != null) {
      passwordInput.id = password.id;
    }
    if (password.value != null) {
      passwordInput.value = password.value;
    }
    if (password.autocomplete != null) {
      passwordInput.setAttribute("autocomplete", password.autocomplete);
    }
    if (password.readonly != null) {
      passwordInput.setAttribute("readonly", password.readonly);
    }
    if (password.disabled != null) {
      passwordInput.setAttribute("disabled", password.disabled);
    }

    if (password.label != null) {
      const passwordLabel = document.createElement("label");
      passwordLabel.innerText = password.label;
      passwordLabel.appendChild(passwordInput);
      form.appendChild(passwordLabel);
    } else {
      form.appendChild(passwordInput);
    }
  }

  const submitButton = document.createElement("button");
  submitButton.type = "submit";
  submitButton.name = "submit";
  submitButton.innerText = "Submit";
  form.appendChild(submitButton);

  const content = document.getElementById("content");

  const oldForm = document.getElementById(form.id);
  if (oldForm) {
    content.replaceChild(form, oldForm);
  } else {
    content.appendChild(form);
  }

  return form;
}

/**
 * Check for expected username/password in form.
 * @see `checkForm` below for a similar function.
 */
function checkLoginForm(
  usernameField,
  expectedUsername,
  passwordField,
  expectedPassword
) {
  let formID = usernameField.parentNode.id;
  is(
    usernameField.value,
    expectedUsername,
    "Checking " + formID + " username is: " + expectedUsername
  );
  is(
    passwordField.value,
    expectedPassword,
    "Checking " + formID + " password is: " + expectedPassword
  );
}

/**
 * Check repeatedly for a while to see if a particular condition still applies.
 * This function checks the return value of `condition` repeatedly until either
 * the condition has a falsy return value, or `retryTimes` is exceeded.
 */

function ensureCondition(
  condition,
  errorMsg = "Condition did not last.",
  retryTimes = 10
) {
  return new Promise((resolve, reject) => {
    let tries = 0;
    let conditionFailed = false;
    let interval = setInterval(async function () {
      try {
        const conditionPassed = await condition();
        conditionFailed ||= !conditionPassed;
      } catch (e) {
        ok(false, e + "\n" + e.stack);
        conditionFailed = true;
      }
      if (conditionFailed || tries >= retryTimes) {
        ok(!conditionFailed, errorMsg);
        clearInterval(interval);
        if (conditionFailed) {
          reject(errorMsg);
        } else {
          resolve();
        }
      }
      tries++;
    }, 100);
  });
}

/**
 * Wait a while to ensure login form stays filled with username and password
 * @see `checkLoginForm` below for a similar function.
 * @returns a promise, resolving when done
 *
 * TODO: eventually get rid of this time based check, and transition to an
 * event based approach. See Bug 1811142.
 * Filling happens by `_fillForm()` which can report it's decision and we can
 * wait for it. One of the options is to have `didFillFormAsync()` from
 * https://phabricator.services.mozilla.com/D167214#change-3njWgUgqswws
 */
function ensureLoginFormStaysFilledWith(
  usernameField,
  expectedUsername,
  passwordField,
  expectedPassword
) {
  return ensureCondition(() => {
    return (
      Object.is(usernameField.value, expectedUsername) &&
      Object.is(passwordField.value, expectedPassword)
    );
  }, `Ensuring form ${usernameField.parentNode.id} stays filled with "${expectedUsername}:${expectedPassword}"`);
}

function checkLoginFormInFrame(
  iframeBC,
  usernameFieldId,
  expectedUsername,
  passwordFieldId,
  expectedPassword
) {
  return SpecialPowers.spawn(
    iframeBC,
    [usernameFieldId, expectedUsername, passwordFieldId, expectedPassword],
    (
      usernameFieldIdF,
      expectedUsernameF,
      passwordFieldIdF,
      expectedPasswordF
    ) => {
      let usernameField =
        this.content.document.getElementById(usernameFieldIdF);
      let passwordField =
        this.content.document.getElementById(passwordFieldIdF);

      let formID = usernameField.parentNode.id;
      Assert.equal(
        usernameField.value,
        expectedUsernameF,
        "Checking " + formID + " username is: " + expectedUsernameF
      );
      Assert.equal(
        passwordField.value,
        expectedPasswordF,
        "Checking " + formID + " password is: " + expectedPasswordF
      );
    }
  );
}

async function checkUnmodifiedFormInFrame(bc, formNum) {
  return SpecialPowers.spawn(bc, [formNum], formNumF => {
    let form = this.content.document.getElementById(`form${formNumF}`);
    ok(form, "Locating form " + formNumF);

    for (var i = 0; i < form.elements.length; i++) {
      var ele = form.elements[i];

      // No point in checking form submit/reset buttons.
      if (ele.type == "submit" || ele.type == "reset") {
        continue;
      }

      is(
        ele.value,
        ele.defaultValue,
        "Test to default value of field " + ele.name + " in form " + formNumF
      );
    }
  });
}

/**
 * Check a form for expected values even if it is in a different top level window
 * or process. If an argument is null, a field's expected value will be the default
 * value.
 *
 * Similar to the checkForm helper, but it works across (cross-origin) frames.
 *
 * <form id="form#">
 * checkLoginFormInFrameWithElementValues(#, "foo");
 */
async function checkLoginFormInFrameWithElementValues(
  browsingContext,
  formNum,
  ...values
) {
  return SpecialPowers.spawn(
    browsingContext,
    [formNum, values],
    function checkFormWithElementValues(formNumF, valuesF) {
      let [val1F, val2F, val3F] = valuesF;
      let doc = this.content.document;
      let e;
      let form = doc.getElementById("form" + formNumF);
      ok(form, "Locating form " + formNumF);

      let numToCheck = arguments.length - 1;

      if (!numToCheck--) {
        return;
      }
      e = form.elements[0];
      if (val1F == null) {
        is(
          e.value,
          e.defaultValue,
          "Test default value of field " + e.name + " in form " + formNumF
        );
      } else {
        is(
          e.value,
          val1F,
          "Test value of field " + e.name + " in form " + formNumF
        );
      }

      if (!numToCheck--) {
        return;
      }

      e = form.elements[1];
      if (val2F == null) {
        is(
          e.value,
          e.defaultValue,
          "Test default value of field " + e.name + " in form " + formNumF
        );
      } else {
        is(
          e.value,
          val2F,
          "Test value of field " + e.name + " in form " + formNumF
        );
      }

      if (!numToCheck--) {
        return;
      }
      e = form.elements[2];
      if (val3F == null) {
        is(
          e.value,
          e.defaultValue,
          "Test default value of field " + e.name + " in form " + formNumF
        );
      } else {
        is(
          e.value,
          val3F,
          "Test value of field " + e.name + " in form " + formNumF
        );
      }
    }
  );
}

/**
 * Check a form for expected values. If an argument is null, a field's
 * expected value will be the default value.
 *
 * <form id="form#">
 * checkForm(#, "foo");
 */
function checkForm(formNum, val1, val2, val3) {
  var e,
    form = document.getElementById("form" + formNum);
  ok(form, "Locating form " + formNum);

  var numToCheck = arguments.length - 1;

  if (!numToCheck--) {
    return;
  }
  e = form.elements[0];
  if (val1 == null) {
    is(
      e.value,
      e.defaultValue,
      "Test default value of field " + e.name + " in form " + formNum
    );
  } else {
    is(e.value, val1, "Test value of field " + e.name + " in form " + formNum);
  }

  if (!numToCheck--) {
    return;
  }
  e = form.elements[1];
  if (val2 == null) {
    is(
      e.value,
      e.defaultValue,
      "Test default value of field " + e.name + " in form " + formNum
    );
  } else {
    is(e.value, val2, "Test value of field " + e.name + " in form " + formNum);
  }

  if (!numToCheck--) {
    return;
  }
  e = form.elements[2];
  if (val3 == null) {
    is(
      e.value,
      e.defaultValue,
      "Test default value of field " + e.name + " in form " + formNum
    );
  } else {
    is(e.value, val3, "Test value of field " + e.name + " in form " + formNum);
  }
}

/**
 * Check a form for unmodified values from when page was loaded.
 *
 * <form id="form#">
 * checkUnmodifiedForm(#);
 */
function checkUnmodifiedForm(formNum) {
  var form = document.getElementById("form" + formNum);
  ok(form, "Locating form " + formNum);

  for (var i = 0; i < form.elements.length; i++) {
    var ele = form.elements[i];

    // No point in checking form submit/reset buttons.
    if (ele.type == "submit" || ele.type == "reset") {
      continue;
    }

    is(
      ele.value,
      ele.defaultValue,
      "Test to default value of field " + ele.name + " in form " + formNum
    );
  }
}

/**
 * Wait for the document to be ready and any existing password fields on
 * forms to be processed.
 *
 * @param existingPasswordFieldsCount the number of password fields
 * that begin on the test page.
 */
function registerRunTests(existingPasswordFieldsCount = 0, callback) {
  return new Promise(resolve => {
    function onDOMContentLoaded() {
      var form = document.createElement("form");
      form.id = "observerforcer";
      var username = document.createElement("input");
      username.name = "testuser";
      form.appendChild(username);
      var password = document.createElement("input");
      password.name = "testpass";
      password.type = "password";
      form.appendChild(password);

      let foundForcer = false;
      var observer = SpecialPowers.wrapCallback(function (
        _subject,
        _topic,
        data
      ) {
        if (data === "observerforcer") {
          foundForcer = true;
        } else {
          existingPasswordFieldsCount--;
        }

        if (!foundForcer || existingPasswordFieldsCount > 0) {
          return;
        }

        SpecialPowers.removeObserver(observer, "passwordmgr-processed-form");
        form.remove();
        SimpleTest.executeSoon(() => {
          callback?.();
          resolve();
        });
      });
      SpecialPowers.addObserver(observer, "passwordmgr-processed-form");

      document.body.appendChild(form);
    }
    // We provide a general mechanism for our tests to know when they can
    // safely run: we add a final form that we know will be filled in, wait
    // for the login manager to tell us that it's filled in and then continue
    // with the rest of the tests.
    if (
      document.readyState == "complete" ||
      document.readyState == "interactive"
    ) {
      onDOMContentLoaded();
    } else {
      window.addEventListener("DOMContentLoaded", onDOMContentLoaded);
    }
  });
}

function enablePrimaryPassword() {
  setPrimaryPassword(true);
}

function disablePrimaryPassword() {
  setPrimaryPassword(false);
}

function setPrimaryPassword(enable) {
  PWMGR_COMMON_PARENT.sendAsyncMessage("setPrimaryPassword", { enable });
}

function isLoggedIn() {
  return PWMGR_COMMON_PARENT.sendQuery("isLoggedIn");
}

function logoutPrimaryPassword() {
  runInParent(function parent_logoutPrimaryPassword() {
    var sdr = Cc["@mozilla.org/security/sdr;1"].getService(
      Ci.nsISecretDecoderRing
    );
    sdr.logoutAndTeardown();
  });
}

/**
 * Resolves when a specified number of forms have been processed for (potential) filling.
 * This relies on the observer service which only notifies observers within the same process.
 */
function promiseFormsProcessedInSameProcess(expectedCount = 1) {
  var processedCount = 0;
  return new Promise(resolve => {
    function onProcessedForm(subject, _topic, data) {
      processedCount++;
      if (processedCount == expectedCount) {
        info(`${processedCount} form(s) processed`);
        SpecialPowers.removeObserver(
          onProcessedForm,
          "passwordmgr-processed-form"
        );
        resolve(SpecialPowers.Cu.waiveXrays(subject), data);
      }
    }
    SpecialPowers.addObserver(onProcessedForm, "passwordmgr-processed-form");
  });
}

/**
 * Resolves when a form has been processed for (potential) filling.
 * This works across processes.
 */
async function promiseFormsProcessed(expectedCount = 1) {
  info(`waiting for ${expectedCount} forms to be processed`);
  var processedCount = 0;
  return new Promise(resolve => {
    PWMGR_COMMON_PARENT.addMessageListener(
      "formProcessed",
      function formProcessed() {
        processedCount++;
        info(`processed form ${processedCount} of ${expectedCount}`);
        if (processedCount == expectedCount) {
          info(`processing of ${expectedCount} forms complete`);
          PWMGR_COMMON_PARENT.removeMessageListener(
            "formProcessed",
            formProcessed
          );
          resolve();
        }
      }
    );
  });
}

async function loadFormIntoWindow(origin, html, win, expectedCount = 1, task) {
  let loadedPromise = new Promise(resolve => {
    win.addEventListener(
      "load",
      function (event) {
        if (event.target.location.href.endsWith("blank.html")) {
          resolve();
        }
      },
      { once: true }
    );
  });

  let processedPromise = promiseFormsProcessed(expectedCount);
  win.location =
    origin + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
  info(`Waiting for window to load for origin: ${origin}`);
  await loadedPromise;

  await SpecialPowers.spawn(
    win,
    [html, task?.toString()],
    function (contentHtml, contentTask = null) {
      this.content.document.documentElement.innerHTML = contentHtml;
      // Similar to the invokeContentTask helper in accessible/tests/browser/shared-head.js
      if (contentTask) {
        // eslint-disable-next-line no-eval
        const runnableTask = eval(`
      (() => {
        return (${contentTask});
      })();`);
        runnableTask.call(this);
      }
    }
  );

  info("Waiting for the form to be processed");
  await processedPromise;
}

async function getTelemetryEvents(options) {
  let events = await PWMGR_COMMON_PARENT.sendQuery(
    "getTelemetryEvents",
    options
  );
  info("CONTENT: getTelemetryEvents gotResult: " + JSON.stringify(events));
  return events;
}

function loadRecipes(recipes) {
  info("Loading recipes");
  return PWMGR_COMMON_PARENT.sendQuery("loadRecipes", recipes);
}

function resetRecipes() {
  info("Resetting recipes");
  return PWMGR_COMMON_PARENT.sendQuery("resetRecipes");
}

async function promiseStorageChanged(expectedChangeTypes) {
  let result = await PWMGR_COMMON_PARENT.sendQuery("storageChanged", {
    expectedChangeTypes,
  });

  if (result) {
    ok(false, result);
  }
}

async function promisePromptShown(expectedTopic) {
  let topic = await PWMGR_COMMON_PARENT.sendQuery("promptShown");
  is(topic, expectedTopic, "Check expected prompt topic");
}

/**
 * Run a function synchronously in the parent process and destroy it in the test cleanup function.
 * @param {Function|String} aFunctionOrURL - either a function that will be stringified and run
 *                                           or the URL to a JS file.
 * @return {Object} - the return value of loadChromeScript providing message-related methods.
 *                    @see loadChromeScript in specialpowersAPI.js
 */
function runInParent(aFunctionOrURL) {
  let chromeScript = SpecialPowers.loadChromeScript(aFunctionOrURL);
  SimpleTest.registerCleanupFunction(() => {
    chromeScript.destroy();
  });
  return chromeScript;
}

/** Manage logins in parent chrome process.
 * */
function manageLoginsInParent() {
  return runInParent(function addLoginsInParentInner() {
    /* eslint-env mozilla/chrome-script */
    addMessageListener("removeAllUserFacingLogins", () => {
      Services.logins.removeAllUserFacingLogins();
    });

    /* eslint-env mozilla/chrome-script */
    addMessageListener("getLogins", async () => {
      const logins = await Services.logins.getAllLogins();
      return logins.map(
        ({
          origin,
          formActionOrigin,
          httpRealm,
          username,
          password,
          usernameField,
          passwordField,
        }) => [
          origin,
          formActionOrigin,
          httpRealm,
          username,
          password,
          usernameField,
          passwordField,
        ]
      );
    });

    /* eslint-env mozilla/chrome-script */
    addMessageListener("addLogins", async logins => {
      let nsLoginInfo = Components.Constructor(
        "@mozilla.org/login-manager/loginInfo;1",
        Ci.nsILoginInfo,
        "init"
      );

      const loginInfos = logins.map(login => new nsLoginInfo(...login));
      try {
        await Services.logins.addLogins(loginInfos);
      } catch (e) {
        assert.ok(false, "addLogins threw: " + e);
      }
    });
  });
}

/** Initialize with a list of logins. The logins are added within the parent chrome process.
 * @param {array} aLogins - a list of logins to add. Each login is an array of the arguments
 *                          that would be passed to nsLoginInfo.init().
 */
async function addLoginsInParent(...aLogins) {
  const script = manageLoginsInParent();
  await script.sendQuery("addLogins", aLogins);
  return script;
}

/** Initialize with a list of logins, after removing all user facing logins.
 * The logins are added within the parent chrome process.
 * @param {array} aLogins - a list of logins to add. Each login is an array of the arguments
 *                          that would be passed to nsLoginInfo.init().
 */
async function setStoredLoginsAsync(...aLogins) {
  const script = manageLoginsInParent();
  script.sendQuery("removeAllUserFacingLogins");
  await script.sendQuery("addLogins", aLogins);
  return script;
}

/**
 * Sets given logins for the duration of the test. Existing logins are first
 * removed and finally restored when the test is finished.
 * The logins are added within the parent chrome process.
 * @param {array} logins - a list of logins to add. Each login is an array of the arguments
 *                          that would be passed to nsLoginInfo.init().
 */
async function setStoredLoginsDuringTest(...logins) {
  const script = manageLoginsInParent();
  const loginsBefore = await script.sendQuery("getLogins");
  await script.sendQuery("removeAllUserFacingLogins");
  await script.sendQuery("addLogins", logins);
  SimpleTest.registerCleanupFunction(async () => {
    await script.sendQuery("removeAllUserFacingLogins");
    await script.sendQuery("addLogins", loginsBefore);
  });
}

/**
 * Sets given logins for the duration of the task. Existing logins are first
 * removed and finally restored when the task is finished.
 * @param {array} logins - a list of logins to add. Each login is an array of the arguments
 *                          that would be passed to nsLoginInfo.init().
 */
async function setStoredLoginsDuringTask(...logins) {
  const script = manageLoginsInParent();
  const loginsBefore = await script.sendQuery("getLogins");
  await script.sendQuery("removeAllUserFacingLogins");
  await script.sendQuery("addLogins", logins);
  SimpleTest.registerTaskCleanupFunction(async () => {
    await script.sendQuery("removeAllUserFacingLogins");
    await script.sendQuery("addLogins", loginsBefore);
  });
}

/** Returns a promise which resolves to a list of logins
 */
function getLogins() {
  const script = manageLoginsInParent();
  return script.sendQuery("getLogins");
}

/*
 * gTestDependsOnDeprecatedLogin Set this global to true if your test relies
 * on the testuser/testpass login that is created in pwmgr_common.js. New tests
 * should not rely on this login.
 */
var gTestDependsOnDeprecatedLogin = false;

/**
 * Replace the content innerHTML with the provided form and wait for autofill to fill in the form.
 *
 * @param {string} form The form to be appended to the #content element.
 * @param {string} fieldSelector The CSS selector for the field to-be-filled
 * @param {string} fieldValue The value expected to be filled
 * @param {string} formId The ID (excluding the # character) of the form
 */
function setFormAndWaitForFieldFilled(
  form,
  { fieldSelector, fieldValue, formId }
) {
  document.querySelector("#content").innerHTML = form;
  return SimpleTest.promiseWaitForCondition(() => {
    let ancestor = formId
      ? document.querySelector("#" + formId)
      : document.documentElement;
    return ancestor.querySelector(fieldSelector).value == fieldValue;
  }, "Wait for password manager to fill form");
}

/**
 * Run commonInit synchronously in the parent then run the test function if supplied.
 *
 * @param {Function} aFunction The test function to run
 */
async function runChecksAfterCommonInit(aFunction = null) {
  SimpleTest.waitForExplicitFinish();
  await PWMGR_COMMON_PARENT.sendQuery("setupParent", {
    testDependsOnDeprecatedLogin: gTestDependsOnDeprecatedLogin,
  });

  if (aFunction) {
    await registerRunTests(0, aFunction);
  }

  return PWMGR_COMMON_PARENT;
}

// Begin code that runs immediately for all tests that include this file.

const PWMGR_COMMON_PARENT = runInParent(
  SimpleTest.getTestFileURL("pwmgr_common_parent.js")
);

SimpleTest.registerCleanupFunction(() => {
  SpecialPowers.flushPrefEnv();

  PWMGR_COMMON_PARENT.sendAsyncMessage("cleanup");

  runInParent(function cleanupParent() {
    /* eslint-env mozilla/chrome-script */
    // eslint-disable-next-line no-shadow
    const { LoginManagerParent } = ChromeUtils.importESModule(
      "resource://gre/modules/LoginManagerParent.sys.mjs"
    );

    // Remove all logins and disabled hosts
    Services.logins.removeAllUserFacingLogins();

    let disabledHosts = Services.logins.getAllDisabledHosts();
    disabledHosts.forEach(host =>
      Services.logins.setLoginSavingEnabled(host, true)
    );

    let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
      Ci.nsIHttpAuthManager
    );
    authMgr.clearAll();

    // Check that it's not null, instead of truthy to catch it becoming undefined
    // in a refactoring.
    if (LoginManagerParent._recipeManager !== null) {
      LoginManagerParent._recipeManager.reset();
    }

    // Cleanup PopupNotifications (if on a relevant platform)
    let chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
    if (chromeWin && chromeWin.PopupNotifications) {
      let notes = chromeWin.PopupNotifications._currentNotifications;
      if (notes.length) {
        dump("Removing " + notes.length + " popup notifications.\n");
      }
      for (let note of notes) {
        note.remove();
      }
    }

    // Clear events last in case the above cleanup records events.
    Services.telemetry.clearEvents();
  });
});

// This is a version of LoginHelper.loginToVanillaObject that is adapted to run
// as content JS instead of chrome JS. This is needed to make it return a
// content JS object because the structured cloning we use to send it over
// JS IPC can't deal with a cross compartment wrapper.
function loginToVanillaObject(login) {
  let obj = {};
  for (let i in SpecialPowers.do_QueryInterface(
    login,
    SpecialPowers.Ci.nsILoginMetaInfo
  )) {
    if (typeof login[i] !== "function") {
      obj[i] = login[i];
    }
  }
  return obj;
}

/**
 * Proxy for Services.logins (nsILoginManager).
 * Only supports arguments which support structured clone plus {nsILoginInfo}
 * Assumes properties are methods.
 */
this.LoginManager = new Proxy(
  {},
  {
    get(_target, prop, _receiver) {
      return (...args) => {
        let loginInfoIndices = [];
        let cloneableArgs = args.map((val, index) => {
          if (
            SpecialPowers.call_Instanceof(val, SpecialPowers.Ci.nsILoginInfo)
          ) {
            loginInfoIndices.push(index);
            return loginToVanillaObject(val);
          }

          return val;
        });

        return PWMGR_COMMON_PARENT.sendQuery("proxyLoginManager", {
          args: cloneableArgs,
          loginInfoIndices,
          methodName: prop,
        });
      };
    },
  }
);

/**
 * Set the inner html of the content div and ensure it gets reset after current
 * task finishes.
 * Returns the first child node of the newly created content div for convenient
 * access of the newly created dom node.
 *
 * @param {String} html
 *        string of dom content or dom element to be inserted into content element
 */
function setContentForTask(html) {
  const content = document.querySelector("#content");
  const innerHTMLBefore = content.innerHTML || "";
  SimpleTest.registerCurrentTaskCleanupFunction(
    () => (content.innerHTML = innerHTMLBefore)
  );
  if (html.content?.cloneNode) {
    const clone = html.content.cloneNode(true);
    content.replaceChildren(clone);
  } else {
    content.innerHTML = html;
  }
  return content.firstElementChild;
}

/*
 * Set preferences via SpecialPowers.pushPrefEnv and reset them after current
 * task has finished.
 *
 * @param {*Object} preferences
 * */
async function setPreferencesForTask(...preferences) {
  await SpecialPowers.pushPrefEnv({
    set: preferences,
  });
  SimpleTest.registerCurrentTaskCleanupFunction(() => SpecialPowers.popPrefEnv);
}

// capture form autofill results between tasks
let gPwmgrCommonCapturedAutofillResults = {};
PWMGR_COMMON_PARENT.addMessageListener(
  "formProcessed",
  ({ formId, autofillResult }) => {
    if (formId === "observerforcer") {
      return;
    }

    gPwmgrCommonCapturedAutofillResults[formId] = autofillResult;
  }
);
SimpleTest.registerTaskCleanupFunction(() => {
  gPwmgrCommonCapturedAutofillResults = {};
});

/**
 * Create a promise that resolves when the form has been processed.
 * Works with forms processed in the past since the task started and in the future,
 * across parent and child processes.
 *
 * @param {String} formId / the id of the form of which to expect formautofill events
 * @returns promise, resolving with the autofill result.
 */
async function formAutofillResult(formId) {
  if (formId in gPwmgrCommonCapturedAutofillResults) {
    const autofillResult = gPwmgrCommonCapturedAutofillResults[formId];
    delete gPwmgrCommonCapturedAutofillResults[formId];
    return autofillResult;
  }
  return new Promise(resolve => {
    PWMGR_COMMON_PARENT.addMessageListener(
      "formProcessed",
      ({ formId: id, autofillResult }) => {
        if (id !== formId) {
          return;
        }
        delete gPwmgrCommonCapturedAutofillResults[formId];
        resolve(autofillResult);
      },
      { once: true }
    );
  });
}
