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

import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
  GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs",
  AddressRecord: "resource://gre/modules/shared/AddressRecord.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "LoginInfo", () =>
  Components.Constructor(
    "@mozilla.org/login-manager/loginInfo;1",
    "nsILoginInfo",
    "init"
  )
);

export class LoginEntry {
  constructor({
    origin,
    formActionOrigin,
    httpRealm,
    username,
    password,
    guid,
    timeCreated,
    timeLastUsed,
    timePasswordChanged,
    timesUsed,
  }) {
    this.origin = origin ?? "";
    this.formActionOrigin = formActionOrigin ?? null;
    this.httpRealm = httpRealm ?? null;
    this.username = username ?? "";
    this.password = password ?? "";

    // Metadata.
    this.guid = guid ?? null;
    // TODO: Not supported by GV.
    this.timeCreated = timeCreated ?? null;
    this.timeLastUsed = timeLastUsed ?? null;
    this.timePasswordChanged = timePasswordChanged ?? null;
    this.timesUsed = timesUsed ?? null;
  }

  toLoginInfo() {
    const info = new lazy.LoginInfo(
      this.origin,
      this.formActionOrigin,
      this.httpRealm,
      this.username,
      this.password
    );

    // Metadata.
    info.QueryInterface(Ci.nsILoginMetaInfo);
    info.guid = this.guid;
    info.timeCreated = this.timeCreated;
    info.timeLastUsed = this.timeLastUsed;
    info.timePasswordChanged = this.timePasswordChanged;
    info.timesUsed = this.timesUsed;

    return info;
  }

  static parse(aObj) {
    const entry = new LoginEntry({});
    Object.assign(entry, aObj);

    return entry;
  }

  static fromLoginInfo(aInfo) {
    const entry = new LoginEntry({});
    entry.origin = aInfo.origin;
    entry.formActionOrigin = aInfo.formActionOrigin;
    entry.httpRealm = aInfo.httpRealm;
    entry.username = aInfo.username;
    entry.password = aInfo.password;

    // Metadata.
    aInfo.QueryInterface(Ci.nsILoginMetaInfo);
    entry.guid = aInfo.guid;
    entry.timeCreated = aInfo.timeCreated;
    entry.timeLastUsed = aInfo.timeLastUsed;
    entry.timePasswordChanged = aInfo.timePasswordChanged;
    entry.timesUsed = aInfo.timesUsed;

    return entry;
  }
}

export class Address {
  constructor({
    name,
    givenName,
    additionalName,
    familyName,
    organization,
    streetAddress,
    addressLevel1,
    addressLevel2,
    addressLevel3,
    postalCode,
    country,
    tel,
    email,
    guid,
    timeCreated,
    timeLastUsed,
    timeLastModified,
    timesUsed,
    version,
  }) {
    this.name = name ?? "";
    this.givenName = givenName ?? "";
    this.additionalName = additionalName ?? "";
    this.familyName = familyName ?? "";
    this.organization = organization ?? "";
    this.streetAddress = streetAddress ?? "";
    this.addressLevel1 = addressLevel1 ?? "";
    this.addressLevel2 = addressLevel2 ?? "";
    this.addressLevel3 = addressLevel3 ?? "";
    this.postalCode = postalCode ?? "";
    this.country = country ?? "";
    this.tel = tel ?? "";
    this.email = email ?? "";

    // Metadata.
    this.guid = guid ?? null;
    // TODO: Not supported by GV.
    this.timeCreated = timeCreated ?? null;
    this.timeLastUsed = timeLastUsed ?? null;
    this.timeLastModified = timeLastModified ?? null;
    this.timesUsed = timesUsed ?? null;
    this.version = version ?? null;
  }

  isValid() {
    return (
      (this.name ?? this.givenName ?? this.familyName) !== "" &&
      this.streetAddress !== "" &&
      this.postalCode !== ""
    );
  }

  static fromGecko(aObj) {
    return new Address({
      version: aObj.version,
      name: aObj.name,
      givenName: aObj["given-name"],
      additionalName: aObj["additional-name"],
      familyName: aObj["family-name"],
      organization: aObj.organization,
      streetAddress: aObj["street-address"],
      addressLevel1: aObj["address-level1"],
      addressLevel2: aObj["address-level2"],
      addressLevel3: aObj["address-level3"],
      postalCode: aObj["postal-code"],
      country: aObj.country,
      tel: aObj.tel,
      email: aObj.email,
      guid: aObj.guid,
      timeCreated: aObj.timeCreated,
      timeLastUsed: aObj.timeLastUsed,
      timeLastModified: aObj.timeLastModified,
      timesUsed: aObj.timesUsed,
    });
  }

  static parse(aObj) {
    const entry = new Address({});
    Object.assign(entry, aObj);

    return entry;
  }

  toGecko() {
    let address = {
      version: this.version,
      name: this.name,
      organization: this.organization,
      "street-address": this.streetAddress,
      "address-level1": this.addressLevel1,
      "address-level2": this.addressLevel2,
      "address-level3": this.addressLevel3,
      "postal-code": this.postalCode,
      country: this.country,
      tel: this.tel,
      email: this.email,
      guid: this.guid,
      ...(this.givenName && {
        "given-name": this.givenName,
        "additional-name": this.additionalName,
        "family-name": this.familyName,
      }),
    };

    lazy.AddressRecord.computeFields(address);

    return address;
  }
}

export class CreditCard {
  constructor({
    name,
    number,
    expMonth,
    expYear,
    type,
    guid,
    timeCreated,
    timeLastUsed,
    timeLastModified,
    timesUsed,
    version,
  }) {
    this.name = name ?? "";
    this.number = number ?? "";
    this.expMonth = expMonth ?? "";
    this.expYear = expYear ?? "";
    this.type = type ?? "";

    // Metadata.
    this.guid = guid ?? null;
    // TODO: Not supported by GV.
    this.timeCreated = timeCreated ?? null;
    this.timeLastUsed = timeLastUsed ?? null;
    this.timeLastModified = timeLastModified ?? null;
    this.timesUsed = timesUsed ?? null;
    this.version = version ?? null;
  }

  isValid() {
    return this.number !== "";
  }

  static fromGecko(aObj) {
    return new CreditCard({
      version: aObj.version,
      name: aObj["cc-name"],
      number: aObj["cc-number"],
      expMonth: aObj["cc-exp-month"]?.toString(),
      expYear: aObj["cc-exp-year"]?.toString(),
      type: aObj["cc-type"],
      guid: aObj.guid,
      timeCreated: aObj.timeCreated,
      timeLastUsed: aObj.timeLastUsed,
      timeLastModified: aObj.timeLastModified,
      timesUsed: aObj.timesUsed,
    });
  }

  static parse(aObj) {
    const entry = new CreditCard({});
    Object.assign(entry, aObj);

    return entry;
  }

  toGecko() {
    return {
      version: this.version,
      "cc-name": this.name,
      "cc-number": this.number,
      "cc-exp-month": this.expMonth,
      "cc-exp-year": this.expYear,
      "cc-type": this.type,
      guid: this.guid,
    };
  }
}

export class SelectOption {
  // Sync with Autocomplete.SelectOption.Hint in Autocomplete.java.
  static Hint = {
    NONE: 0,
    GENERATED: 1 << 0,
    INSECURE_FORM: 1 << 1,
    DUPLICATE_USERNAME: 1 << 2,
    MATCHING_ORIGIN: 1 << 3,
  };

  constructor({ value, hint }) {
    this.value = value ?? null;
    this.hint = hint ?? SelectOption.Hint.NONE;
  }
}

// Sync with Autocomplete.UsedField in Autocomplete.java.
const UsedField = { PASSWORD: 1 };

export const GeckoViewAutocomplete = {
  /** current opened prompt */
  _prompt: null,

  /**
   * Delegates login entry fetching for the given domain to the attached
   * LoginStorage GeckoView delegate.
   *
   * @param aDomain
   *        The domain string to fetch login entries for. If null, all logins
   *        will be fetched.
   * @return {Promise}
   *         Resolves with an array of login objects or null.
   *         Rejected if no delegate is attached.
   *         Login object string properties:
   *         { guid, origin, formActionOrigin, httpRealm, username, password }
   */
  fetchLogins(aDomain = null) {
    debug`fetchLogins for ${aDomain ?? "All domains"}`;

    return lazy.EventDispatcher.instance.sendRequestForResult({
      type: "GeckoView:Autocomplete:Fetch:Login",
      domain: aDomain,
    });
  },

  /**
   * Delegates credit card entry fetching to the attached LoginStorage
   * GeckoView delegate.
   *
   * @return {Promise}
   *         Resolves with an array of credit card objects or null.
   *         Rejected if no delegate is attached.
   *         Login object string properties:
   *         { guid, name, number, expMonth, expYear, type }
   */
  fetchCreditCards() {
    debug`fetchCreditCards`;

    return lazy.EventDispatcher.instance.sendRequestForResult({
      type: "GeckoView:Autocomplete:Fetch:CreditCard",
    });
  },

  /**
   * Delegates address entry fetching to the attached LoginStorage
   * GeckoView delegate.
   *
   * @return {Promise}
   *         Resolves with an array of address objects or null.
   *         Rejected if no delegate is attached.
   *         Login object string properties:
   *         { guid, name, givenName, additionalName, familyName,
   *           organization, streetAddress, addressLevel1, addressLevel2,
   *           addressLevel3, postalCode, country, tel, email }
   */
  fetchAddresses() {
    debug`fetchAddresses`;

    return lazy.EventDispatcher.instance.sendRequestForResult({
      type: "GeckoView:Autocomplete:Fetch:Address",
    });
  },

  /**
   * Delegates credit card entry saving to the attached LoginStorage GeckoView delegate.
   * Call this when a new or modified credit card entry has been submitted.
   *
   * @param aCreditCard The {CreditCard} to be saved.
   */
  onCreditCardSave(aCreditCard) {
    debug`onCreditCardSave ${aCreditCard}`;

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:Autocomplete:Save:CreditCard",
      creditCard: aCreditCard,
    });
  },

  /**
   * Delegates address entry saving to the attached LoginStorage GeckoView delegate.
   * Call this when a new or modified address entry has been submitted.
   *
   * @param aAddress The {Address} to be saved.
   */
  onAddressSave(aAddress) {
    debug`onAddressSave ${aAddress}`;

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:Autocomplete:Save:Address",
      address: aAddress,
    });
  },

  /**
   * Delegates login entry saving to the attached LoginStorage GeckoView delegate.
   * Call this when a new login entry or a new password for an existing login
   * entry has been submitted.
   *
   * @param aLogin The {LoginEntry} to be saved.
   */
  onLoginSave(aLogin) {
    debug`onLoginSave ${aLogin}`;

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:Autocomplete:Save:Login",
      login: aLogin,
    });
  },

  /**
   * Delegates login entry password usage to the attached LoginStorage GeckoView
   * delegate.
   * Call this when the password of an existing login entry, as returned by
   * fetchLogins, has been used for autofill.
   *
   * @param aLogin The {LoginEntry} whose password was used.
   */
  onLoginPasswordUsed(aLogin) {
    debug`onLoginUsed ${aLogin}`;

    lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:Autocomplete:Used:Login",
      usedFields: UsedField.PASSWORD,
      login: aLogin,
    });
  },

  _numActiveSelections: 0,

  /**
   * Delegates login entry selection.
   * Call this when there are multiple login entry option for a form to delegate
   * the selection.
   *
   * @param aBrowser The browser instance the triggered the selection.
   * @param aOptions The list of {SelectOption} depicting viable options.
   */
  onLoginSelect(aBrowser, aOptions) {
    debug`onLoginSelect ${aOptions}`;

    return new Promise((resolve, reject) => {
      if (!aBrowser || !aOptions) {
        debug`onLoginSelect Rejecting - no browser or options provided`;
        reject();
        return;
      }

      const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
      prompt.asyncShowPrompt(
        {
          type: "Autocomplete:Select:Login",
          options: aOptions,
        },
        result => {
          if (!result || !result.selection) {
            reject();
            return;
          }

          const option = new SelectOption({
            value: LoginEntry.parse(result.selection.value),
            hint: result.selection.hint,
          });
          resolve(option);
        }
      );
      this._prompt = prompt;
    });
  },

  /**
   * Delegates credit card entry selection.
   * Call this when there are multiple credit card entry option for a form to delegate
   * the selection.
   *
   * @param aBrowser The browser instance the triggered the selection.
   * @param aOptions The list of {SelectOption} depicting viable options.
   */
  onCreditCardSelect(aBrowser, aOptions) {
    debug`onCreditCardSelect ${aOptions}`;

    return new Promise((resolve, reject) => {
      if (!aBrowser || !aOptions) {
        debug`onCreditCardSelect Rejecting - no browser or options provided`;
        reject();
        return;
      }

      const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
      prompt.asyncShowPrompt(
        {
          type: "Autocomplete:Select:CreditCard",
          options: aOptions,
        },
        result => {
          if (!result || !result.selection) {
            reject();
            return;
          }

          const option = new SelectOption({
            value: CreditCard.parse(result.selection.value),
            hint: result.selection.hint,
          });
          resolve(option);
        }
      );
      this._prompt = prompt;
    });
  },

  /**
   * Delegates address entry selection.
   * Call this when there are multiple address entry option for a form to delegate
   * the selection.
   *
   * @param aBrowser The browser instance the triggered the selection.
   * @param aOptions The list of {SelectOption} depicting viable options.
   */
  onAddressSelect(aBrowser, aOptions) {
    debug`onAddressSelect ${aOptions}`;

    return new Promise((resolve, reject) => {
      if (!aBrowser || !aOptions) {
        debug`onAddressSelect Rejecting - no browser or options provided`;
        reject();
        return;
      }

      const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
      prompt.asyncShowPrompt(
        {
          type: "Autocomplete:Select:Address",
          options: aOptions,
        },
        result => {
          if (!result || !result.selection) {
            reject();
            return;
          }

          const option = new SelectOption({
            value: Address.parse(result.selection.value),
            hint: result.selection.hint,
          });
          resolve(option);
        }
      );
      this._prompt = prompt;
    });
  },

  async delegateSelection({
    browsingContext,
    options,
    inputElementIdentifier,
    formOrigin,
  }) {
    debug`delegateSelection ${options}`;

    if (!options.length) {
      return;
    }

    let insecureHint = SelectOption.Hint.NONE;
    let loginStyle = null;

    // TODO: Replace this string with more robust mechanics.
    let selectionType = null;
    const selectOptions = [];

    for (const option of options) {
      switch (option.style) {
        case "insecureWarning": {
          // We depend on the insecure warning to be the first option.
          insecureHint = SelectOption.Hint.INSECURE_FORM;
          break;
        }
        case "generatedPassword": {
          selectionType = "login";
          const comment = JSON.parse(option.comment);
          selectOptions.push(
            new SelectOption({
              value: new LoginEntry({
                password: comment.generatedPassword,
              }),
              hint: SelectOption.Hint.GENERATED | insecureHint,
            })
          );
          break;
        }
        case "login":
        // Fallthrough.
        case "loginWithOrigin": {
          selectionType = "login";
          loginStyle = option.style;
          const comment = JSON.parse(option.comment);

          let hint = SelectOption.Hint.NONE | insecureHint;
          if (comment.isDuplicateUsername) {
            hint |= SelectOption.Hint.DUPLICATE_USERNAME;
          }
          if (comment.isOriginMatched) {
            hint |= SelectOption.Hint.MATCHING_ORIGIN;
          }

          selectOptions.push(
            new SelectOption({
              value: LoginEntry.parse(comment.fillMessageData),
              hint,
            })
          );
          break;
        }
        case "autofill": {
          const { fillMessageData } = JSON.parse(option.comment);
          const profile = fillMessageData.profile;
          debug`delegateSelection - autofill profile ${profile}`;
          const creditCard = CreditCard.fromGecko(profile);
          const address = Address.fromGecko(profile);
          if (creditCard.isValid()) {
            selectionType = "creditCard";
            selectOptions.push(
              new SelectOption({
                value: creditCard,
                hint: insecureHint,
              })
            );
          } else if (address.isValid()) {
            selectionType = "address";
            selectOptions.push(
              new SelectOption({
                value: address,
                hint: insecureHint,
              })
            );
          }
          break;
        }
        default:
          debug`delegateSelection - ignoring unknown option style ${option.style}`;
      }
    }

    if (selectOptions.length < 1) {
      debug`Abort delegateSelection - no valid options provided`;
      return;
    }

    if (this._numActiveSelections > 0) {
      debug`Abort delegateSelection - there is already one delegation active`;
      return;
    }

    ++this._numActiveSelections;

    let selectedOption = null;
    const browser = browsingContext.top.embedderElement;
    if (selectionType === "login") {
      selectedOption = await this.onLoginSelect(browser, selectOptions).catch(
        _ => {
          debug`No GV delegate attached`;
        }
      );
    } else if (selectionType === "creditCard") {
      selectedOption = await this.onCreditCardSelect(
        browser,
        selectOptions
      ).catch(_ => {
        debug`No GV delegate attached`;
      });
    } else if (selectionType === "address") {
      selectedOption = await this.onAddressSelect(browser, selectOptions).catch(
        _ => {
          debug`No GV delegate attached`;
        }
      );
    }

    // prompt is closed now.
    this._prompt = null;

    --this._numActiveSelections;

    debug`delegateSelection selected option: ${selectedOption}`;

    if (selectionType === "login") {
      const selectedLogin = selectedOption?.value?.toLoginInfo();

      if (!selectedLogin) {
        debug`Abort delegateSelection - no login entry selected`;
        return;
      }

      debug`delegateSelection - filling form`;

      if (selectedOption.hint & SelectOption.Hint.GENERATED) {
        this.onLoginSave(selectedLogin);
      }

      const actor =
        browsingContext.currentWindowGlobal.getActor("LoginManager");

      await actor.fillForm({
        browser,
        inputElementIdentifier,
        loginFormOrigin: formOrigin,
        login: selectedLogin,
        style:
          selectedOption.hint & SelectOption.Hint.GENERATED
            ? "generatedPassword"
            : loginStyle,
      });
    } else if (selectionType === "creditCard") {
      const actor =
        browsingContext.currentWindowGlobal.getActor("FormAutofill");
      const elementId = JSON.stringify(inputElementIdentifier);
      const selectedCreditCard = selectedOption?.value?.toGecko();

      actor.autofillFields(elementId, selectedCreditCard);
    } else if (selectionType === "address") {
      const actor =
        browsingContext.currentWindowGlobal.getActor("FormAutofill");
      const elementId = JSON.stringify(inputElementIdentifier);
      const selectedAddress = selectedOption?.value?.toGecko();

      actor.autofillFields(elementId, selectedAddress);
    }

    debug`delegateSelection - form filled`;
  },

  delegateDismiss() {
    debug`delegateDismiss`;

    this._prompt?.dismiss();
  },
};

const { debug } = GeckoViewUtils.initLogging("GeckoViewAutocomplete");
