/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const REQUIRED_KEYS = ["hosts"];
const OPTIONAL_KEYS = [
  "description",
  "notPasswordSelector",
  "notUsernameSelector",
  "passwordSelector",
  "pathRegex",
  "usernameSelector",
  "schema",
  "id",
  "last_modified",
];
const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS);

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "log", () =>
  lazy.LoginHelper.createLogger("LoginRecipes")
);

/**
 * Create an instance of the object to manage recipes in the parent process.
 * Consumers should wait until {@link initializationPromise} resolves before
 * calling methods on the object.
 *
 * @class
 * @param {string} [aOptions.defaults=null] the URI to load the recipes from.
 *                                          If it's null, nothing is loaded.
 */
export function LoginRecipesParent(aOptions = { defaults: null }) {
  if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
    throw new Error(
      "LoginRecipesParent should only be used from the main process"
    );
  }
  this._defaults = aOptions.defaults;
  this.reset();
}

LoginRecipesParent.prototype = {
  /**
   * Promise resolved with an instance of itself when the module is ready.
   *
   * @type {Promise}
   */
  initializationPromise: null,

  /**
   * @type {bool} Whether default recipes were loaded at construction time.
   */
  _defaults: null,

  /**
   * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes.
   *             e.g. "example.com:8080" => Set({...})
   */
  _recipesByHost: null,

  /**
   * @type {object} Instance of Remote Settings client that has access to the
   *                "password-recipes" collection
   */
  _rsClient: null,

  /**
   * @param {object} aRecipes an object containing recipes to load for use. The object
   *                          should be compatible with JSON (e.g. no RegExp).
   * @return {Promise} resolving when the recipes are loaded
   */
  load(aRecipes) {
    let recipeErrors = 0;
    for (let rawRecipe of aRecipes.siteRecipes) {
      try {
        rawRecipe.pathRegex = rawRecipe.pathRegex
          ? new RegExp(rawRecipe.pathRegex)
          : undefined;
        this.add(rawRecipe);
      } catch (e) {
        recipeErrors++;
        lazy.log.error("Error loading recipe.", rawRecipe, e);
      }
    }
    if (recipeErrors) {
      return Promise.reject(`There were ${recipeErrors} recipe error(s)`);
    }
    return Promise.resolve();
  },

  /**
   * Reset the set of recipes to the ones from the time of construction.
   */
  reset() {
    lazy.log.debug("Resetting recipes with defaults:", this._defaults);
    this._recipesByHost = new Map();
    if (this._defaults) {
      let initPromise;
      /**
       * Both branches rely on a JSON dump of the Remote Settings collection, packaged both in Desktop and Android.
       * The «legacy» mode will read the dump directly from the packaged resources.
       * With Remote Settings, the dump is used to initialize the local database without network,
       * and the list of password recipes can be refreshed without restarting and without software update.
       */
      if (lazy.LoginHelper.remoteRecipesEnabled) {
        if (!this._rsClient) {
          this._rsClient = lazy.RemoteSettings(
            lazy.LoginHelper.remoteRecipesCollection
          );
          // Set up sync observer to update local recipes from Remote Settings recipes
          this._rsClient.on("sync", event => this.onRemoteSettingsSync(event));
        }
        initPromise = this._rsClient.get();
      } else if (this._defaults.startsWith("resource://")) {
        initPromise = fetch(this._defaults)
          .then(resp => resp.json())
          .then(({ data }) => data);
      } else {
        lazy.log.error(
          "Invalid recipe path found, setting empty recipes list!"
        );
        initPromise = new Promise(() => []);
      }
      this.initializationPromise = initPromise.then(async siteRecipes => {
        Services.ppmm.broadcastAsyncMessage("clearRecipeCache");
        await this.load({ siteRecipes });
        return this;
      });
    } else {
      this.initializationPromise = Promise.resolve(this);
    }
  },

  /**
   * Validate the recipe is sane and then add it to the set of recipes.
   *
   * @param {object} recipe
   */
  add(recipe) {
    let recipeKeys = Object.keys(recipe);
    let unknownKeys = recipeKeys.filter(key => !SUPPORTED_KEYS.includes(key));
    if (unknownKeys.length) {
      throw new Error(
        "The following recipe keys aren't supported: " + unknownKeys.join(", ")
      );
    }

    let missingRequiredKeys = REQUIRED_KEYS.filter(
      key => !recipeKeys.includes(key)
    );
    if (missingRequiredKeys.length) {
      throw new Error(
        "The following required recipe keys are missing: " +
          missingRequiredKeys.join(", ")
      );
    }

    if (!Array.isArray(recipe.hosts)) {
      throw new Error("'hosts' must be a array");
    }

    if (!recipe.hosts.length) {
      throw new Error("'hosts' must be a non-empty array");
    }

    if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") {
      throw new Error("'pathRegex' must be a regular expression");
    }

    const OPTIONAL_STRING_PROPS = [
      "description",
      "passwordSelector",
      "usernameSelector",
    ];
    for (let prop of OPTIONAL_STRING_PROPS) {
      if (recipe[prop] && typeof recipe[prop] != "string") {
        throw new Error(`'${prop}' must be a string`);
      }
    }

    // Add the recipe to the map for each host
    for (let host of recipe.hosts) {
      if (!this._recipesByHost.has(host)) {
        this._recipesByHost.set(host, new Set());
      }
      this._recipesByHost.get(host).add(recipe);
    }
  },

  /**
   * Currently only exact host matches are returned but this will eventually handle parent domains.
   *
   * @param {string} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
   * @return {Set} of recipes that apply to the host ordered by host priority
   */
  getRecipesForHost(aHost) {
    let hostRecipes = this._recipesByHost.get(aHost);
    if (!hostRecipes) {
      return new Set();
    }

    return hostRecipes;
  },

  /**
   * Handles the Remote Settings sync event for the "password-recipes" collection.
   *
   * @param {object} aEvent
   * @param {Array} event.current Records in the "password-recipes" collection after the sync event
   * @param {Array} event.created Records that were created with this particular sync
   * @param {Array} event.updated Records that were updated with this particular sync
   * @param {Array} event.deleted Records that were deleted with this particular sync
   */
  onRemoteSettingsSync(aEvent) {
    this._recipesByHost = new Map();
    let {
      data: { current },
    } = aEvent;
    let recipes = {
      siteRecipes: current,
    };
    Services.ppmm.broadcastAsyncMessage("clearRecipeCache");
    this.load(recipes);
  },
};

export const LoginRecipesContent = {
  _recipeCache: new WeakMap(),

  _clearRecipeCache() {
    lazy.log.debug("Clearing recipe cache.");
    this._recipeCache = new WeakMap();
  },

  /**
   * Locally caches recipes for a given host.
   *
   * @param {string} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
   * @param {object} win - the window of the host
   * @param {Set} recipes - recipes that apply to the host
   */
  cacheRecipes(aHost, win, recipes) {
    let recipeMap = this._recipeCache.get(win);

    if (!recipeMap) {
      recipeMap = new Map();
      this._recipeCache.set(win, recipeMap);
    }

    recipeMap.set(aHost, recipes);
  },

  /**
   * Tries to fetch recipes for a given host, using a local cache if possible.
   * Otherwise, the recipes are cached for later use.
   *
   * @param {string} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
   * @param {object} win - the window of the host
   * @return {Set} of recipes that apply to the host
   */
  getRecipes(aHost, win) {
    let recipes;
    const recipeMap = this._recipeCache.get(win);

    if (recipeMap) {
      recipes = recipeMap.get(aHost);

      if (recipes) {
        return recipes;
      }
    }

    if (!Cu.isInAutomation) {
      // this is a blocking call we expect in tests and rarely expect in
      // production, for example when Remote Settings are updated.
      lazy.log.warn(`Falling back to a synchronous message for: ${aHost}.`);
    }
    recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", {
      formOrigin: aHost,
    })[0];
    this.cacheRecipes(aHost, win, recipes);

    return recipes;
  },

  /**
   * @param {Set} aRecipes - Possible recipes that could apply to the form
   * @param {FormLike} aForm - We use a form instead of just a URL so we can later apply
   * tests to the page contents.
   * @return {Set} a subset of recipes that apply to the form with the order preserved
   */
  _filterRecipesForForm(aRecipes, aForm) {
    let formDocURL = aForm.ownerDocument.location;
    let hostRecipes = aRecipes;
    let recipes = new Set();
    if (!hostRecipes) {
      return recipes;
    }

    for (let hostRecipe of hostRecipes) {
      if (
        hostRecipe.pathRegex &&
        !hostRecipe.pathRegex.test(formDocURL.pathname)
      ) {
        continue;
      }
      recipes.add(hostRecipe);
    }

    return recipes;
  },

  /**
   * Given a set of recipes that apply to the host, choose the one most applicable for
   * overriding login fields in the form.
   *
   * @param {Set} aRecipes The set of recipes to consider for the form
   * @param {FormLike} aForm The form where login fields exist.
   * @return {object} The recipe that is most applicable for the form.
   */
  getFieldOverrides(aRecipes, aForm) {
    let recipes = this._filterRecipesForForm(aRecipes, aForm);
    lazy.log.debug(`Filtered recipes size: ${recipes.size}.`);
    if (!recipes.size) {
      return null;
    }

    let chosenRecipe = null;
    // Find the first (most-specific recipe that involves field overrides).
    for (let recipe of recipes) {
      if (
        !recipe.usernameSelector &&
        !recipe.passwordSelector &&
        !recipe.notUsernameSelector &&
        !recipe.notPasswordSelector
      ) {
        continue;
      }

      chosenRecipe = recipe;
      break;
    }

    return chosenRecipe;
  },

  /**
   * @param {HTMLElement} aParent the element to query for the selector from.
   * @param {CSSSelector} aSelector the CSS selector to query for the login field.
   * @return {HTMLElement|null}
   */
  queryLoginField(aParent, aSelector) {
    if (!aSelector) {
      return null;
    }
    let field = aParent.ownerDocument.querySelector(aSelector);
    if (!field) {
      lazy.log.debug(`Login field selector wasn't matched: ${aSelector}.`);
      return null;
    }
    // documentGlobal doesn't exist in content privileged windows.
    if (
      // eslint-disable-next-line mozilla/use-documentGlobal
      !aParent.ownerDocument.defaultView.HTMLInputElement.isInstance(field)
    ) {
      lazy.log.warn(
        `Login field with selector ${aSelector} isn't an <input> so ignoring it.`
      );
      return null;
    }
    return field;
  },
};
