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

/*
 * Implements a service used by DOM content to request Form Autofill, in
 * particular when the requestAutocomplete method of Form objects is invoked.
 *
 * See the nsIFormAutofillContentService documentation for details.
 */

"use strict";

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

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

XPCOMUtils.defineLazyModuleGetter(this, "FormAutofill",
                                  "resource://gre/modules/FormAutofill.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                  "resource://gre/modules/Task.jsm");

/**
 * Handles requestAutocomplete for a DOM Form element.
 */
function FormHandler(aForm, aWindow) {
  this.form = aForm;
  this.window = aWindow;

  this.fieldDetails = [];
}

FormHandler.prototype = {
  /**
   * DOM Form element to which this object is attached.
   */
  form: null,

  /**
   * nsIDOMWindow to which this object is attached.
   */
  window: null,

  /**
   * Array of collected data about relevant form fields.  Each item is an object
   * storing the identifying details of the field and a reference to the
   * originally associated element from the form.
   *
   * The "section", "addressType", "contactType", and "fieldName" values are
   * used to identify the exact field when the serializable data is received
   * from the requestAutocomplete user interface.  There cannot be multiple
   * fields which have the same exact combination of these values.
   *
   * A direct reference to the associated element cannot be sent to the user
   * interface because processing may be done in the parent process.
   */
  fieldDetails: null,

  /**
   * Handles requestAutocomplete and generates the DOM events when finished.
   */
  handleRequestAutocomplete: Task.async(function* () {
    // Start processing the request asynchronously.  At the end, the "reason"
    // variable will contain the outcome of the operation, where an empty
    // string indicates that an unexpected exception occurred.
    let reason = "";
    try {
      reason = yield this.promiseRequestAutocomplete();
    } catch (ex) {
      Cu.reportError(ex);
    }

    // The type of event depends on whether this is a success condition.
    let event = (reason == "success")
                ? new this.window.Event("autocomplete", { bubbles: true })
                : new this.window.AutocompleteErrorEvent("autocompleteerror",
                                                         { bubbles: true,
                                                           reason: reason });
    yield this.waitForTick();
    this.form.dispatchEvent(event);
  }),

  /**
   * Handles requestAutocomplete and returns the outcome when finished.
   *
   * @return {Promise}
   * @resolves The "reason" value indicating the outcome of the
   *           requestAutocomplete operation, including "success" if the
   *           operation completed successfully.
   */
  promiseRequestAutocomplete: Task.async(function* () {
    let data = this.collectFormFields();
    if (!data) {
      return "disabled";
    }

    // Access the frame message manager of the window starting the request.
    let rootDocShell = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
                                  .getInterface(Ci.nsIDocShell)
                                  .sameTypeRootTreeItem
                                  .QueryInterface(Ci.nsIDocShell);
    let frameMM = rootDocShell.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIContentFrameMessageManager);

    // We need to set up a temporary message listener for our result before we
    // send the request to the parent process.  At present, there is no check
    // for reentrancy (bug 1020459), thus it is possible that we'll receive a
    // message for a different request, but this will not be normally allowed.
    let promiseRequestAutocompleteResult = new Promise((resolve, reject) => {
      frameMM.addMessageListener("FormAutofill:RequestAutocompleteResult",
        function onResult(aMessage) {
          frameMM.removeMessageListener(
                        "FormAutofill:RequestAutocompleteResult", onResult);
          // Exceptions in the parent process are serialized and propagated in
          // the response message that we received.
          if ("exception" in aMessage.data) {
            reject(aMessage.data.exception);
          } else {
            resolve(aMessage.data);
          }
        });
    });

    // Send the message to the parent process, and wait for the result.  This
    // will throw an exception if one occurred in the parent process.
    frameMM.sendAsyncMessage("FormAutofill:RequestAutocomplete", data);
    let result = yield promiseRequestAutocompleteResult;
    if (result.canceled) {
      return "cancel";
    }

    this.autofillFormFields(result);

    return "success";
  }),

  /**
   * Returns information from the form about fields that can be autofilled, and
   * populates the fieldDetails array on this object accordingly.
   *
   * @returns Serializable data structure that can be sent to the user
   *          interface, or null if the operation failed because the constraints
   *          on the allowed fields were not honored.
   */
  collectFormFields: function () {
    let autofillData = {
      sections: [],
    };

    for (let element of this.form.elements) {
      // Query the interface and exclude elements that cannot be autocompleted.
      if (!(element instanceof Ci.nsIDOMHTMLInputElement)) {
        continue;
      }

      // Exclude elements to which no autocomplete field has been assigned.
      let info = element.getAutocompleteInfo();
      if (!info.fieldName || ["on", "off"].indexOf(info.fieldName) != -1) {
        continue;
      }

      // Store the association between the field metadata and the element.
      if (this.fieldDetails.some(f => f.section == info.section &&
                                      f.addressType == info.addressType &&
                                      f.contactType == info.contactType &&
                                      f.fieldName == info.fieldName)) {
        // A field with the same identifier already exists.
        return null;
      }
      this.fieldDetails.push({
        section: info.section,
        addressType: info.addressType,
        contactType: info.contactType,
        fieldName: info.fieldName,
        element: element,
      });

      // The first level is the custom section.
      let section = autofillData.sections
                                .find(s => s.name == info.section);
      if (!section) {
        section = {
          name: info.section,
          addressSections: [],
        };
        autofillData.sections.push(section);
      }

      // The second level is the address section.
      let addressSection = section.addressSections
                                  .find(s => s.addressType == info.addressType);
      if (!addressSection) {
        addressSection = {
          addressType: info.addressType,
          fields: [],
        };
        section.addressSections.push(addressSection);
      }

      // The third level contains all the fields within the section.
      let field = {
        fieldName: info.fieldName,
        contactType: info.contactType,
      };
      addressSection.fields.push(field);
    }

    return autofillData;
  },

  /**
   * Processes form fields that can be autofilled, and populates them with the
   * data provided by RequestAutocompleteUI.
   *
   * @param aAutofillResult
   *        Data returned by the user interface.
   *        {
   *          fields: [
   *            section: Value originally provided to the user interface.
   *            addressType: Value originally provided to the user interface.
   *            contactType: Value originally provided to the user interface.
   *            fieldName: Value originally provided to the user interface.
   *            value: String with which the field should be updated.
   *          ],
   *        }
   */
  autofillFormFields: function (aAutofillResult) {
    for (let field of aAutofillResult.fields) {
      // Get the field details, if it was processed by the user interface.
      let fieldDetail = this.fieldDetails
                            .find(f => f.section == field.section &&
                                       f.addressType == field.addressType &&
                                       f.contactType == field.contactType &&
                                       f.fieldName == field.fieldName);
      if (!fieldDetail) {
        continue;
      }

      fieldDetail.element.value = field.value;
    }
  },

  /**
   * Waits for one tick of the event loop before resolving the returned promise.
   */
  waitForTick: function () {
    return new Promise(function (resolve) {
      Services.tm.currentThread.dispatch(resolve, Ci.nsIThread.DISPATCH_NORMAL);
    });
  },
};

/**
 * Implements a service used by DOM content to request Form Autofill, in
 * particular when the requestAutocomplete method of Form objects is invoked.
 */
function FormAutofillContentService() {
}

FormAutofillContentService.prototype = {
  classID: Components.ID("{ed9c2c3c-3f86-4ae5-8e31-10f71b0f19e6}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIFormAutofillContentService]),

  // nsIFormAutofillContentService
  requestAutocomplete: function (aForm, aWindow) {
    new FormHandler(aForm, aWindow).handleRequestAutocomplete()
                                   .catch(Cu.reportError);
  },
};

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([FormAutofillContentService]);
