/* 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 ManageAddresses, ManageCreditCards */

"use strict";

const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
const EDIT_CREDIT_CARD_URL = "chrome://formautofill/content/editCreditCard.xhtml";
const AUTOFILL_BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://formautofill/FormAutofillUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "profileStorage",
                                  "resource://formautofill/ProfileStorage.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MasterPassword",
                                  "resource://formautofill/MasterPassword.jsm");

this.log = null;
FormAutofillUtils.defineLazyLogGetter(this, "manageAddresses");

class ManageRecords {
  constructor(subStorageName, elements) {
    this._storageInitPromise = profileStorage.initialize();
    this._subStorageName = subStorageName;
    this._elements = elements;
    this._newRequest = false;
    this._isLoadingRecords = false;
    this.prefWin = window.opener;
    this.localizeDocument();
    window.addEventListener("DOMContentLoaded", this, {once: true});
  }

  async init() {
    await this.loadRecords();
    this.attachEventListeners();
    // For testing only: Notify when the dialog is ready for interaction
    window.dispatchEvent(new CustomEvent("FormReady"));
  }

  uninit() {
    log.debug("uninit");
    this.detachEventListeners();
    this._elements = null;
  }

  localizeDocument() {
    FormAutofillUtils.localizeMarkup(AUTOFILL_BUNDLE_URI, document);
  }

  /**
   * Get the selected options on the addresses element.
   *
   * @returns {array<DOMElement>}
   */
  get _selectedOptions() {
    return Array.from(this._elements.records.selectedOptions);
  }

  /**
   * Get storage and ensure it has been initialized.
   * @returns {object}
   */
  async getStorage() {
    await this._storageInitPromise;
    return profileStorage[this._subStorageName];
  }

  /**
   * Load records and render them. This function is a wrapper for _loadRecords
   * to ensure any reentrant will be handled well.
   */
  async loadRecords() {
    // This function can be early returned when there is any reentrant happends.
    // "_newRequest" needs to be set to ensure all changes will be applied.
    if (this._isLoadingRecords) {
      this._newRequest = true;
      return;
    }
    this._isLoadingRecords = true;

    await this._loadRecords();

    // _loadRecords should be invoked again if there is any multiple entrant
    // during running _loadRecords(). This step ensures that the latest request
    // still is applied.
    while (this._newRequest) {
      this._newRequest = false;
      await this._loadRecords();
    }
    this._isLoadingRecords = false;

    // For testing only: Notify when records are loaded
    this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded"));
  }

  async _loadRecords() {
    let storage = await this.getStorage();
    let records = storage.getAll();
    // Sort by last modified time starting with most recent
    records.sort((a, b) => b.timeLastModified - a.timeLastModified);
    await this.renderRecordElements(records);
    this.updateButtonsStates(this._selectedOptions.length);
  }

  /**
   * Render the records onto the page while maintaining selected options if
   * they still exist.
   *
   * @param  {array<object>} records
   */
  async renderRecordElements(records) {
    let selectedGuids = this._selectedOptions.map(option => option.value);
    this.clearRecordElements();
    for (let record of records) {
      let option = new Option(await this.getLabel(record),
                              record.guid,
                              false,
                              selectedGuids.includes(record.guid));
      option.record = record;
      this._elements.records.appendChild(option);
    }
  }

  /**
   * Remove all existing record elements.
   */
  clearRecordElements() {
    let parent = this._elements.records;
    while (parent.lastChild) {
      parent.removeChild(parent.lastChild);
    }
  }

  /**
   * Remove records by selected options.
   *
   * @param  {array<DOMElement>} options
   */
  async removeRecords(options) {
    let storage = await this.getStorage();
    // Pause listening to storage change event to avoid triggering `loadRecords`
    // when removing records
    Services.obs.removeObserver(this, "formautofill-storage-changed");

    for (let option of options) {
      storage.remove(option.value);
      option.remove();
    }
    this.updateButtonsStates(this._selectedOptions);

    // Resume listening to storage change event
    Services.obs.addObserver(this, "formautofill-storage-changed");
    // For testing only: notify record(s) has been removed
    this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved"));
  }

  /**
   * Enable/disable the Edit and Remove buttons based on number of selected
   * options.
   *
   * @param  {number} selectedCount
   */
  updateButtonsStates(selectedCount) {
    log.debug("updateButtonsStates:", selectedCount);
    if (selectedCount == 0) {
      this._elements.edit.setAttribute("disabled", "disabled");
      this._elements.remove.setAttribute("disabled", "disabled");
    } else if (selectedCount == 1) {
      this._elements.edit.removeAttribute("disabled");
      this._elements.remove.removeAttribute("disabled");
    } else if (selectedCount > 1) {
      this._elements.edit.setAttribute("disabled", "disabled");
      this._elements.remove.removeAttribute("disabled");
    }
  }

  /**
   * Handle events
   *
   * @param  {DOMEvent} event
   */
  handleEvent(event) {
    switch (event.type) {
      case "DOMContentLoaded": {
        this.init();
        break;
      }
      case "click": {
        this.handleClick(event);
        break;
      }
      case "change": {
        this.updateButtonsStates(this._selectedOptions.length);
        break;
      }
      case "unload": {
        this.uninit();
        break;
      }
      case "keypress": {
        this.handleKeyPress(event);
        break;
      }
    }
  }

  /**
   * Handle click events
   *
   * @param  {DOMEvent} event
   */
  handleClick(event) {
    if (event.target == this._elements.remove) {
      this.removeRecords(this._selectedOptions);
    } else if (event.target == this._elements.add) {
      this.openEditDialog();
    } else if (event.target == this._elements.edit ||
               event.target.parentNode == this._elements.records && event.detail > 1) {
      this.openEditDialog(this._selectedOptions[0].record);
    }
  }

  /**
   * Handle key press events
   *
   * @param  {DOMEvent} event
   */
  handleKeyPress(event) {
    if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
      window.close();
    }
  }

  observe(subject, topic, data) {
    switch (topic) {
      case "formautofill-storage-changed": {
        this.loadRecords();
      }
    }
  }

  /**
   * Attach event listener
   */
  attachEventListeners() {
    window.addEventListener("unload", this, {once: true});
    window.addEventListener("keypress", this);
    this._elements.records.addEventListener("change", this);
    this._elements.records.addEventListener("click", this);
    this._elements.controlsContainer.addEventListener("click", this);
    Services.obs.addObserver(this, "formautofill-storage-changed");
  }

  /**
   * Remove event listener
   */
  detachEventListeners() {
    window.removeEventListener("keypress", this);
    this._elements.records.removeEventListener("change", this);
    this._elements.records.removeEventListener("click", this);
    this._elements.controlsContainer.removeEventListener("click", this);
    Services.obs.removeObserver(this, "formautofill-storage-changed");
  }
}

class ManageAddresses extends ManageRecords {
  constructor(elements) {
    super("addresses", elements);
    elements.add.setAttribute("searchkeywords", FormAutofillUtils.EDIT_ADDRESS_KEYWORDS
                                                  .map(key => FormAutofillUtils.stringBundle.GetStringFromName(key))
                                                  .join("\n"));
  }

  /**
   * Open the edit address dialog to create/edit an address.
   *
   * @param  {object} address [optional]
   */
  openEditDialog(address) {
    this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, null, address);
  }

  /**
   * Get address display label. It should display up to two pieces of
   * information, separated by a comma.
   *
   * @param  {object} address
   * @returns {string}
   */
  getLabel(address) {
    // TODO: Implement a smarter way for deciding what to display
    //       as option text. Possibly improve the algorithm in
    //       ProfileAutoCompleteResult.jsm and reuse it here.
    const fieldOrder = [
      "name",
      "-moz-street-address-one-line",  // Street address
      "address-level2",  // City/Town
      "organization",    // Company or organization name
      "address-level1",  // Province/State (Standardized code if possible)
      "country-name",    // Country name
      "postal-code",     // Postal code
      "tel",             // Phone number
      "email",           // Email address
    ];

    let parts = [];
    if (address["street-address"]) {
      address["-moz-street-address-one-line"] = FormAutofillUtils.toOneLineAddress(
        address["street-address"]
      );
    }
    for (const fieldName of fieldOrder) {
      let string = address[fieldName];
      if (string) {
        parts.push(string);
      }
      if (parts.length == 2) {
        break;
      }
    }
    return parts.join(", ");
  }
}

class ManageCreditCards extends ManageRecords {
  constructor(elements) {
    super("creditCards", elements);
    elements.add.setAttribute("searchkeywords", FormAutofillUtils.EDIT_CREDITCARD_KEYWORDS
                                                  .map(key => FormAutofillUtils.stringBundle.GetStringFromName(key))
                                                  .join("\n"));
    this._hasMasterPassword = MasterPassword.isEnabled;
    this._isDecrypted = false;
    if (this._hasMasterPassword) {
      elements.showHideCreditCards.setAttribute("hidden", true);
    }
  }

  /**
   * Open the edit address dialog to create/edit a credit card.
   *
   * @param  {object} creditCard [optional]
   */
  async openEditDialog(creditCard) {
    // If master password is set, ask for password if user is trying to edit an
    // existing credit card.
    if (!this._hasMasterPassword || !creditCard || await MasterPassword.prompt()) {
      this.prefWin.gSubDialog.open(EDIT_CREDIT_CARD_URL, null, creditCard);
    }
  }

  /**
   * Get credit card display label. It should display masked numbers and the
   * cardholder's name, separated by a comma. If `showCreditCards` is set to
   * true, decrypted credit card numbers are shown instead.
   *
   * @param  {object} creditCard
   * @param  {boolean} showCreditCards [optional]
   * @returns {string}
   */
  async getLabel(creditCard, showCreditCards = false) {
    let parts = [];
    if (creditCard["cc-number"]) {
      let ccLabel;
      if (showCreditCards) {
        ccLabel = await MasterPassword.decrypt(creditCard["cc-number-encrypted"]);
      } else {
        let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(creditCard["cc-number"]);
        ccLabel = `${affix} ${label}`;
      }
      parts.push(ccLabel);
    }
    if (creditCard["cc-name"]) {
      parts.push(creditCard["cc-name"]);
    }
    return parts.join(", ");
  }

  async toggleShowHideCards(options) {
    this._isDecrypted = !this._isDecrypted;
    this.updateShowHideButtonState();
    await this.updateLabels(options, this._isDecrypted);
  }

  async updateLabels(options, isDecrypted) {
    for (let option of options) {
      option.text = await this.getLabel(option.record, isDecrypted);
    }
    // For testing only: Notify when credit cards labels have been updated
    this._elements.records.dispatchEvent(new CustomEvent("LabelsUpdated"));
  }

  async renderRecordElements(records) {
    // Revert back to encrypted form when re-rendering happens
    this._isDecrypted = false;
    await super.renderRecordElements(records);
  }

  updateButtonsStates(selectedCount) {
    this.updateShowHideButtonState();
    super.updateButtonsStates(selectedCount);
  }

  updateShowHideButtonState() {
    if (this._elements.records.length) {
      this._elements.showHideCreditCards.removeAttribute("disabled");
    } else {
      this._elements.showHideCreditCards.setAttribute("disabled", true);
    }
    this._elements.showHideCreditCards.textContent =
      this._isDecrypted ? FormAutofillUtils.stringBundle.GetStringFromName("hideCreditCards") :
                          FormAutofillUtils.stringBundle.GetStringFromName("showCreditCards");
  }

  handleClick(event) {
    if (event.target == this._elements.showHideCreditCards) {
      this.toggleShowHideCards(this._elements.records.options);
    }
    super.handleClick(event);
  }
}
