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

/* exported EditAddress, EditCreditCard */
/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded.

"use strict";

class EditAutofillForm {
  constructor(elements) {
    this._elements = elements;
  }

  /**
   * Fill the form with a record object.
   * @param  {object} [record = {}]
   */
  loadRecord(record = {}) {
    for (let field of this._elements.form.elements) {
      let value = record[field.id];
      value = typeof value == "undefined" ? "" : value;

      if (record.guid) {
        field.value = value;
      } else if (field.localName == "select") {
        this.setDefaultSelectedOptionByValue(field, value);
      } else {
        // Use .defaultValue instead of .value to avoid setting the `dirty` flag
        // which triggers form validation UI.
        field.defaultValue = value;
      }
    }
    if (!record.guid) {
      // Reset the dirty value flag and validity state.
      this._elements.form.reset();
    } else {
      for (let field of this._elements.form.elements) {
        this.updatePopulatedState(field);
        this.updateCustomValidity(field);
      }
    }
  }

  setDefaultSelectedOptionByValue(select, value) {
    for (let option of select.options) {
      option.defaultSelected = option.value == value;
    }
  }

  /**
   * Get a record from the form suitable for a save/update in storage.
   * @returns {object}
   */
  buildFormObject() {
    let initialObject = {};
    if (this.hasMailingAddressFields) {
      // Start with an empty string for each mailing-address field so that any
      // fields hidden for the current country are blanked in the return value.
      initialObject = {
        "street-address": "",
        "address-level3": "",
        "address-level2": "",
        "address-level1": "",
        "postal-code": "",
      };
    }

    return Array.from(this._elements.form.elements).reduce((obj, input) => {
      if (!input.disabled) {
        obj[input.id] = input.value;
      }
      return obj;
    }, initialObject);
  }

  /**
   * Handle events
   *
   * @param  {DOMEvent} event
   */
  handleEvent(event) {
    switch (event.type) {
      case "change": {
        this.handleChange(event);
        break;
      }
      case "input": {
        this.handleInput(event);
        break;
      }
    }
  }

  /**
   * Handle change events
   *
   * @param  {DOMEvent} event
   */
  handleChange(event) {
    this.updatePopulatedState(event.target);
  }

  /**
   * Handle input events
   *
   * @param  {DOMEvent} event
   */
  handleInput(event) {}

  /**
   * Attach event listener
   */
  attachEventListeners() {
    this._elements.form.addEventListener("input", this);
  }

  /**
   * Set the field-populated attribute if the field has a value.
   *
   * @param {DOMElement} field The field that will be checked for a value.
   */
  updatePopulatedState(field) {
    let span = field.parentNode.querySelector(".label-text");
    if (!span) {
      return;
    }
    span.toggleAttribute("field-populated", !!field.value.trim());
  }

  /**
   * Run custom validity routines specific to the field and type of form.
   *
   * @param {DOMElement} field The field that will be validated.
   */
  updateCustomValidity(field) {}
}

class EditAddress extends EditAutofillForm {
  /**
   * @param {HTMLElement[]} elements
   * @param {object} record
   * @param {object} config
   * @param {string[]} config.DEFAULT_REGION
   * @param {function} config.getFormFormat Function to return form layout info for a given country.
   * @param {function} config.findAddressSelectOption Finds the matching select option for a given
                                                      select element, address, and fieldName.
   * @param {string[]} config.countries
   * @param {boolean} [config.noValidate=undefined] Whether to validate the form
   */
  constructor(elements, record, config) {
    super(elements);

    Object.assign(this, config);
    let { form } = this._elements;
    Object.assign(this._elements, {
      addressLevel3Label: form.querySelector(
        "#address-level3-container > .label-text"
      ),
      addressLevel2Label: form.querySelector(
        "#address-level2-container > .label-text"
      ),
      addressLevel1Label: form.querySelector(
        "#address-level1-container > .label-text"
      ),
      postalCodeLabel: form.querySelector(
        "#postal-code-container > .label-text"
      ),
      country: form.querySelector("#country"),
    });

    this.populateCountries();
    // Need to populate the countries before trying to set the initial country.
    // Also need to use this._record so it has the default country selected.
    this.loadRecord(record);
    this.attachEventListeners();

    form.noValidate = !!config.noValidate;
  }

  loadRecord(record) {
    this._record = record;
    if (!record) {
      record = {
        country: this.DEFAULT_REGION,
      };
    }

    let { addressLevel1Options } = this.getFormFormat(record.country);
    this.populateAddressLevel1(addressLevel1Options, record.country);

    super.loadRecord(record);
    this.loadAddressLevel1(record["address-level1"], record.country);
    this.formatForm(record.country);
  }

  get hasMailingAddressFields() {
    let { addressFields } = this._elements.form.dataset;
    return (
      !addressFields ||
      addressFields
        .trim()
        .split(/\s+/)
        .includes("mailing-address")
    );
  }

  /**
   * `mailing-address` is a special attribute token to indicate mailing fields + country.
   *
   * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat`
   * @param {string} addressFields - white-space-separated string of requested address fields to show
   * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields
   */
  static computeVisibleFields(mailingFieldsOrder, addressFields) {
    if (addressFields) {
      let requestedFieldClasses = addressFields.trim().split(/\s+/);
      let fieldClasses = [];
      if (requestedFieldClasses.includes("mailing-address")) {
        fieldClasses = fieldClasses.concat(mailingFieldsOrder);
        // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address
        requestedFieldClasses.splice(
          requestedFieldClasses.indexOf("mailing-address"),
          1,
          "country"
        );
      }

      for (let fieldClassName of requestedFieldClasses) {
        fieldClasses.push({
          fieldId: fieldClassName,
          newLine: fieldClassName == "name",
        });
      }
      return fieldClasses;
    }

    // This is the default which is shown in the management interface and includes all fields.
    return mailingFieldsOrder.concat([
      {
        fieldId: "country",
      },
      {
        fieldId: "tel",
      },
      {
        fieldId: "email",
        newLine: true,
      },
    ]);
  }

  /**
   * Format the form based on country. The address-level1 and postal-code labels
   * should be specific to the given country.
   * @param  {string} country
   */
  formatForm(country) {
    const {
      addressLevel3Label,
      addressLevel2Label,
      addressLevel1Label,
      addressLevel1Options,
      postalCodeLabel,
      fieldsOrder: mailingFieldsOrder,
      postalCodePattern,
      countryRequiredFields,
    } = this.getFormFormat(country);
    this._elements.addressLevel3Label.dataset.localization = addressLevel3Label;
    this._elements.addressLevel2Label.dataset.localization = addressLevel2Label;
    this._elements.addressLevel1Label.dataset.localization = addressLevel1Label;
    this._elements.postalCodeLabel.dataset.localization = postalCodeLabel;
    let addressFields = this._elements.form.dataset.addressFields;
    let extraRequiredFields = this._elements.form.dataset.extraRequiredFields;
    let fieldClasses = EditAddress.computeVisibleFields(
      mailingFieldsOrder,
      addressFields
    );
    let requiredFields = new Set(countryRequiredFields);
    if (extraRequiredFields) {
      for (let extraRequiredField of extraRequiredFields.trim().split(/\s+/)) {
        requiredFields.add(extraRequiredField);
      }
    }
    this.arrangeFields(fieldClasses, requiredFields);
    this.updatePostalCodeValidation(postalCodePattern);
    this.populateAddressLevel1(addressLevel1Options, country);
  }

  /**
   * Update address field visibility and order based on libaddressinput data.
   *
   * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties
   * @param {Set} requiredFields Set of `fieldId` strings that mark which fields are required
   */
  arrangeFields(fieldsOrder, requiredFields) {
    /**
     * @see FormAutofillStorage.VALID_ADDRESS_FIELDS
     */
    let fields = [
      // `name` is a wrapper for the 3 name fields.
      "name",
      "organization",
      "street-address",
      "address-level3",
      "address-level2",
      "address-level1",
      "postal-code",
      "country",
      "tel",
      "email",
    ];
    let inputs = [];
    for (let i = 0; i < fieldsOrder.length; i++) {
      let { fieldId, newLine } = fieldsOrder[i];

      let container = this._elements.form.querySelector(
        `#${fieldId}-container`
      );
      let containerInputs = [
        ...container.querySelectorAll("input, textarea, select"),
      ];
      containerInputs.forEach(function(input) {
        input.disabled = false;
        // libaddressinput doesn't list 'country' or 'name' as required.
        // The additional-name field should never get marked as required.
        input.required =
          (fieldId == "country" ||
            fieldId == "name" ||
            requiredFields.has(fieldId)) &&
          input.id != "additional-name";
      });
      inputs.push(...containerInputs);
      container.style.display = "flex";
      container.style.order = i;
      container.style.pageBreakAfter = newLine ? "always" : "auto";
      // Remove the field from the list of fields
      fields.splice(fields.indexOf(fieldId), 1);
    }
    for (let i = 0; i < inputs.length; i++) {
      // Assign tabIndex starting from 1
      inputs[i].tabIndex = i + 1;
    }
    // Hide the remaining fields
    for (let field of fields) {
      let container = this._elements.form.querySelector(`#${field}-container`);
      container.style.display = "none";
      for (let input of [
        ...container.querySelectorAll("input, textarea, select"),
      ]) {
        input.disabled = true;
      }
    }
  }

  updatePostalCodeValidation(postalCodePattern) {
    let postalCodeInput = this._elements.form.querySelector("#postal-code");
    if (postalCodePattern && postalCodeInput.style.display != "none") {
      postalCodeInput.setAttribute("pattern", postalCodePattern);
    } else {
      postalCodeInput.removeAttribute("pattern");
    }
  }

  /**
   * Set the address-level1 value on the form field (input or select, whichever is present).
   *
   * @param {string} addressLevel1Value Value of the address-level1 from the autofill record
   * @param {string} country The corresponding country
   */
  loadAddressLevel1(addressLevel1Value, country) {
    let field = this._elements.form.querySelector("#address-level1");

    if (field.localName == "input") {
      field.value = addressLevel1Value || "";
      return;
    }

    let matchedSelectOption = this.findAddressSelectOption(
      field,
      {
        country,
        "address-level1": addressLevel1Value,
      },
      "address-level1"
    );
    if (matchedSelectOption && !matchedSelectOption.selected) {
      field.value = matchedSelectOption.value;
      field.dispatchEvent(new Event("input", { bubbles: true }));
      field.dispatchEvent(new Event("change", { bubbles: true }));
    } else if (addressLevel1Value) {
      // If the option wasn't found, insert an option at the beginning of
      // the select that matches the stored value.
      field.insertBefore(
        new Option(addressLevel1Value, addressLevel1Value, true, true),
        field.firstChild
      );
    }
  }

  /**
   * Replace the text input for address-level1 with a select dropdown if
   * a fixed set of names exists. Otherwise show a text input.
   *
   * @param {Map?} options Map of options with regionCode -> name mappings
   * @param {string} country The corresponding country
   */
  populateAddressLevel1(options, country) {
    let field = this._elements.form.querySelector("#address-level1");

    if (field.dataset.country == country) {
      return;
    }

    if (!options) {
      if (field.localName == "input") {
        return;
      }

      let input = document.createElement("input");
      input.setAttribute("type", "text");
      input.id = "address-level1";
      input.required = field.required;
      input.disabled = field.disabled;
      input.tabIndex = field.tabIndex;
      field.replaceWith(input);
      return;
    }

    if (field.localName == "input") {
      let select = document.createElement("select");
      select.id = "address-level1";
      select.required = field.required;
      select.disabled = field.disabled;
      select.tabIndex = field.tabIndex;
      field.replaceWith(select);
      field = select;
    }

    field.textContent = "";
    field.dataset.country = country;
    let fragment = document.createDocumentFragment();
    fragment.appendChild(new Option(undefined, undefined, true, true));
    for (let [regionCode, regionName] of options) {
      let option = new Option(regionName, regionCode);
      fragment.appendChild(option);
    }
    field.appendChild(fragment);
  }

  populateCountries() {
    let fragment = document.createDocumentFragment();
    // Sort countries by their visible names.
    let countries = [...this.countries.entries()].sort((e1, e2) =>
      e1[1].localeCompare(e2[1])
    );
    for (let country of countries) {
      let option = new Option();
      option.value = country[0];
      option.dataset.localizationRegion = country[0].toLowerCase();
      fragment.appendChild(option);
    }
    this._elements.country.appendChild(fragment);
  }

  handleChange(event) {
    if (event.target == this._elements.country) {
      this.formatForm(event.target.value);
    }
    super.handleChange(event);
  }

  attachEventListeners() {
    this._elements.form.addEventListener("change", this);
    super.attachEventListeners();
  }
}

class EditCreditCard extends EditAutofillForm {
  /**
   * @param {HTMLElement[]} elements
   * @param {object} record with a decrypted cc-number
   * @param {object} addresses in an object with guid keys for the billing address picker.
   * @param {object} config
   * @param {function} config.isCCNumber Function to determine if a string is a valid CC number.
   * @param {function} config.getSupportedNetworks Function to get the list of card networks
   */
  constructor(elements, record, addresses, config) {
    super(elements);

    this._addresses = addresses;
    Object.assign(this, config);
    Object.assign(this._elements, {
      ccNumber: this._elements.form.querySelector("#cc-number"),
      invalidCardNumberStringElement: this._elements.form.querySelector(
        "#invalidCardNumberString"
      ),
      month: this._elements.form.querySelector("#cc-exp-month"),
      year: this._elements.form.querySelector("#cc-exp-year"),
      ccType: this._elements.form.querySelector("#cc-type"),
      billingAddress: this._elements.form.querySelector("#billingAddressGUID"),
      billingAddressRow: this._elements.form.querySelector(
        ".billingAddressRow"
      ),
    });

    this.attachEventListeners();
    this.loadRecord(record, addresses);
  }

  loadRecord(record, addresses, preserveFieldValues) {
    // _record must be updated before generateYears and generateBillingAddressOptions are called.
    this._record = record;
    this._addresses = addresses;
    this.generateBillingAddressOptions(preserveFieldValues);
    if (!preserveFieldValues) {
      // Re-populating the networks will reset the selected option.
      this.populateNetworks();
      // Re-generating the months will reset the selected option.
      this.generateMonths();
      // Re-generating the years will reset the selected option.
      this.generateYears();
      super.loadRecord(record);
    }
  }

  generateMonths() {
    const count = 12;

    // Clear the list
    this._elements.month.textContent = "";

    // Empty month option
    this._elements.month.appendChild(new Option());

    // Populate month list. Format: "month number - month name"
    let dateFormat = new Intl.DateTimeFormat(navigator.language, {
      month: "long",
    }).format;
    for (let i = 0; i < count; i++) {
      let monthNumber = (i + 1).toString();
      let monthName = dateFormat(new Date(1970, i));
      let option = new Option();
      option.value = monthNumber;
      // XXX: Bug 1446164 - Localize this string.
      option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`;
      this._elements.month.appendChild(option);
    }
  }

  generateYears() {
    const count = 11;
    const currentYear = new Date().getFullYear();
    const ccExpYear = this._record && this._record["cc-exp-year"];

    // Clear the list
    this._elements.year.textContent = "";

    // Provide an empty year option
    this._elements.year.appendChild(new Option());

    if (ccExpYear && ccExpYear < currentYear) {
      this._elements.year.appendChild(new Option(ccExpYear));
    }

    for (let i = 0; i < count; i++) {
      let year = currentYear + i;
      let option = new Option(year);
      this._elements.year.appendChild(option);
    }

    if (ccExpYear && ccExpYear > currentYear + count) {
      this._elements.year.appendChild(new Option(ccExpYear));
    }
  }

  populateNetworks() {
    // Clear the list
    this._elements.ccType.textContent = "";
    let frag = document.createDocumentFragment();
    // include an empty first option
    frag.appendChild(new Option("", ""));

    let supportedNetworks = this.getSupportedNetworks();
    for (let id of supportedNetworks) {
      let option = new Option();
      option.value = id;
      option.dataset.localization = "cardNetwork." + id;
      frag.appendChild(option);
    }
    this._elements.ccType.appendChild(frag);
  }

  generateBillingAddressOptions(preserveFieldValues) {
    let billingAddressGUID;
    if (preserveFieldValues && this._elements.billingAddress.value) {
      billingAddressGUID = this._elements.billingAddress.value;
    } else if (this._record) {
      billingAddressGUID = this._record.billingAddressGUID;
    }

    this._elements.billingAddress.textContent = "";

    this._elements.billingAddress.appendChild(new Option("", ""));

    let hasAddresses = false;
    for (let [guid, address] of Object.entries(this._addresses)) {
      hasAddresses = true;
      let selected = guid == billingAddressGUID;
      let option = new Option(
        this.getAddressLabel(address),
        guid,
        selected,
        selected
      );
      this._elements.billingAddress.appendChild(option);
    }

    this._elements.billingAddressRow.hidden = !hasAddresses;
  }

  attachEventListeners() {
    this._elements.form.addEventListener("change", this);
    super.attachEventListeners();
  }

  handleInput(event) {
    // Clear the error message if cc-number is valid
    if (
      event.target == this._elements.ccNumber &&
      this.isCCNumber(this._elements.ccNumber.value)
    ) {
      this._elements.ccNumber.setCustomValidity("");
    }
    super.handleInput(event);
  }

  updateCustomValidity(field) {
    super.updateCustomValidity(field);

    // Mark the cc-number field as invalid if the number is empty or invalid.
    if (field == this._elements.ccNumber && !this.isCCNumber(field.value)) {
      let invalidCardNumberString = this._elements
        .invalidCardNumberStringElement.textContent;
      field.setCustomValidity(invalidCardNumberString || " ");
    }
  }
}
