/* 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/. */

/**
 * Code that we can share across Firefox Desktop, Firefox Android and Firefox iOS.
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { NewPasswordModel } from "resource://gre/modules/shared/NewPasswordModel.sys.mjs";

class Logic {
  static inputTypeIsCompatibleWithUsername(input) {
    const fieldType = input.getAttribute("type")?.toLowerCase() || input.type;
    return (
      ["text", "email", "url", "tel", "number", "search"].includes(fieldType) ||
      fieldType?.includes("user")
    );
  }

  /**
   * Test whether the element has the keyword in its attributes.
   * The tested attributes include id, name, className, and placeholder.
   */
  static elementAttrsMatchRegex(element, regex) {
    if (
      regex.test(element.id) ||
      regex.test(element.name) ||
      regex.test(element.className)
    ) {
      return true;
    }

    const placeholder = element.getAttribute("placeholder");
    return placeholder && regex.test(placeholder);
  }

  /**
   * Test whether associated labels of the element have the keyword.
   * This is a simplified rule of hasLabelMatchingRegex in NewPasswordModel.sys.mjs
   */
  static hasLabelMatchingRegex(element, regex) {
    return regex.test(element.labels?.[0]?.textContent);
  }

  static hasTextContentMatchingRegex(element, regex) {
    return regex.test(element.textContent);
  }

  /**
   * Get the parts of the URL we want for identification.
   * Strip out things like the userPass portion and handle javascript:.
   */
  static getLoginOrigin(uriString, allowJS = false) {
    try {
      const mozProxyRegex = /^moz-proxy:\/\//i;
      if (mozProxyRegex.test(uriString)) {
        // Special handling for moz-proxy URIs
        const uri = new URL(uriString.replace(mozProxyRegex, "https://"));
        return `moz-proxy://${uri.host}`;
      }

      const uri = new URL(uriString);
      if (uri.protocol === "javascript:") {
        return allowJS ? "javascript:" : null;
      }

      // Ensure the URL has a host
      // Execption: file URIs See Bug 1651186
      return uri.host || uri.protocol === "file:"
        ? `${uri.protocol}//${uri.host}`
        : null;
    } catch {
      return null;
    }
  }

  static getFormActionOrigin(form) {
    let uriString = form.action;

    // A blank or missing action submits to where it came from.
    if (uriString == "") {
      // ala bug 297761
      uriString = form.baseURI;
    }

    return this.getLoginOrigin(uriString, true);
  }

  /**
   * Checks if a field type is username compatible.
   *
   * @param {Element} element
   *                  the field we want to check.
   * @param {object} options
   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
   *                                         of the element.
   *
   * @returns {boolean} true if the field type is one
   *                    of the username types.
   */
  static isUsernameFieldType(element, { ignoreConnect = false } = {}) {
    if (!HTMLInputElement.isInstance(element)) {
      return false;
    }

    if (!element.isConnected && !ignoreConnect) {
      // If the element isn't connected then it isn't visible to the user so
      // shouldn't be considered. It must have been connected in the past.
      return false;
    }

    if (element.hasBeenTypePassword) {
      return false;
    }

    if (!Logic.inputTypeIsCompatibleWithUsername(element)) {
      return false;
    }

    let acFieldName = element.getAutocompleteInfo().fieldName;
    if (
      !(
        acFieldName == "username" ||
        acFieldName == "webauthn" ||
        // Bug 1540154: Some sites use tel/email on their username fields.
        acFieldName == "email" ||
        acFieldName == "tel" ||
        acFieldName == "tel-national" ||
        acFieldName == "off" ||
        acFieldName == "on" ||
        acFieldName == ""
      )
    ) {
      return false;
    }
    return true;
  }

  /**
   * Checks if a field type is password compatible.
   *
   * @param {Element} element
   *                  the field we want to check.
   * @param {object} options
   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
   *                                         of the element.
   *
   * @returns {boolean} true if the field can
   *                    be treated as a password input
   */
  static isPasswordFieldType(element, { ignoreConnect = false } = {}) {
    if (!HTMLInputElement.isInstance(element)) {
      return false;
    }

    if (!element.isConnected && !ignoreConnect) {
      // If the element isn't connected then it isn't visible to the user so
      // shouldn't be considered. It must have been connected in the past.
      return false;
    }

    if (!element.hasBeenTypePassword) {
      return false;
    }

    // Ensure the element is of a type that could have autocomplete.
    // These include the types with user-editable values. If not, even if it used to be
    // a type=password, we can't treat it as a password input now
    let acInfo = element.getAutocompleteInfo();
    if (!acInfo) {
      return false;
    }

    return true;
  }

  static #cachedNewPasswordScore = new WeakMap();

  static isProbablyANewPasswordField(inputElement) {
    const autocompleteInfo = inputElement.getAutocompleteInfo();
    if (autocompleteInfo.fieldName === "new-password") {
      return true;
    }

    if (Logic.newPasswordFieldFathomThreshold == -1) {
      // Fathom is disabled
      return false;
    }

    let score = this.#cachedNewPasswordScore.get(inputElement);
    if (score) {
      return score >= Logic.newPasswordFieldFathomThreshold;
    }

    const { rules, type } = NewPasswordModel;
    const results = rules.against(inputElement);
    score = results.get(inputElement).scoreFor(type);
    this.#cachedNewPasswordScore.set(inputElement, score);
    return score >= Logic.newPasswordFieldFathomThreshold;
  }

  static findConfirmationField(passwordField, formFactory) {
    const form = formFactory.createFromField(passwordField);
    let confirmPasswordInput = null;
    const MAX_CONFIRM_PASSWORD_DISTANCE = 3;

    const startIndex = form.elements.indexOf(passwordField);
    if (startIndex === -1) {
      throw new Error(
        "Password field is not in the form's elements collection"
      );
    }

    // Get a list of input fields to search in.
    // Pre-filter type=hidden fields; they don't count against the distance threshold
    const afterFields = form.elements
      .slice(startIndex + 1)
      .filter(elem => elem.type !== "hidden");

    const acFieldName = passwordField.getAutocompleteInfo()?.fieldName;

    // Match same autocomplete values first
    if (acFieldName === "new-password") {
      const matchIndex = afterFields.findIndex(
        elem =>
          Logic.isPasswordFieldType(elem) &&
          elem.getAutocompleteInfo().fieldName === acFieldName &&
          !elem.disabled &&
          !elem.readOnly
      );
      if (matchIndex >= 0 && matchIndex < MAX_CONFIRM_PASSWORD_DISTANCE) {
        confirmPasswordInput = afterFields[matchIndex];
      }
    }

    if (!confirmPasswordInput) {
      for (
        let idx = 0;
        idx < Math.min(MAX_CONFIRM_PASSWORD_DISTANCE, afterFields.length);
        idx++
      ) {
        if (
          Logic.isPasswordFieldType(afterFields[idx]) &&
          !afterFields[idx].disabled &&
          !afterFields[idx].readOnly
        ) {
          confirmPasswordInput = afterFields[idx];
          break;
        }
      }
    }

    return confirmPasswordInput;
  }

  /**
   * Transforms the parsed rules returned from PasswordRulesParser into a Map for easier access.
   * The returned Map could have the following keys: "allowed", "required", "maxlength", "minlength", and "max-consecutive"
   *
   * @example
   * // Returns a Map with a key-value pair of "allowed": "ascii-printable"
   * transformRulesToMap([{ _name: "allowed", value: [{ _name: "ascii-printable" }] }])
   * @param {object[]} rules rules from PasswordRulesParser.parsePasswordRules
   * @return {Map} mapped rules
   */
  static transformRulesToMap(rules) {
    let map = new Map();
    for (let rule of rules) {
      let { _name, value } = rule;
      if (
        _name === "minlength" ||
        _name === "maxlength" ||
        _name === "max-consecutive"
      ) {
        map.set(_name, value);
      } else {
        let _value = [];
        if (map.get(_name)) {
          _value = map.get(_name);
        }
        for (let _class of value) {
          let { _name: _className } = _class;
          if (_className) {
            _value.push(_className);
          } else {
            let { _characters } = _class;
            _value.push(_characters);
          }
        }
        map.set(_name, _value);
      }
    }
    return map;
  }
}

XPCOMUtils.defineLazyPreferenceGetter(
  Logic,
  "newPasswordFieldFathomThreshold",
  "signon.generation.confidenceThreshold",
  null,
  null,
  pref => parseFloat(pref)
);

export { Logic };
