/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * vim: ts=4 sw=4 expandtab:
 * 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/. */

package org.mozilla.geckoview;

import android.util.Log;
import androidx.annotation.AnyThread;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.mozilla.gecko.EventDispatcher;
import org.mozilla.gecko.util.BundleEventListener;
import org.mozilla.gecko.util.EventCallback;
import org.mozilla.gecko.util.GeckoBundle;

/**
 * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion.
 *
 * <p>The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates.
 *
 * <p>The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and
 * storing of login entries.
 *
 * <p>The user-prompt delegates propagate decisions to the app that could require user choice, e.g.,
 * saving or updating of login entries or the selection of a login entry out of multiple options.
 *
 * <p>Throughout the documentation, we will refer to the filling out of input forms using two terms:
 * 1. Autofill: automatic filling without user interaction. 2. Autocomplete: semi-automatic filling
 * that requires user prompting for the selection.
 *
 * <h2>Examples</h2>
 *
 * <h3>Autocomplete/Fetch API</h3>
 *
 * <p>GeckoView loads <code>https://example.com</code> which contains (for the purpose of this
 * example) elements resembling a login form, e.g.,
 *
 * <pre><code>
 *   &lt;form&gt;
 *     &lt;input type=&quot;text&quot; placeholder=&quot;username&quot;&gt;
 *     &lt;input type=&quot;password&quot; placeholder=&quot;password&quot;&gt;
 *     &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
 *   &lt;/form&gt;
 * </code></pre>
 *
 * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code>
 * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to fetch logins for the
 * given domain.
 *
 * <p>Based on the provided login entries, GeckoView will attempt to autofill the login input
 * fields, if there is only one suitable login entry option.
 *
 * <p>In the case of multiple valid login entry options, GeckoView dispatches a <code>
 * GeckoSession.PromptDelegate.onLoginSelect</code> request, which allows for user-choice
 * delegation.
 *
 * <p>Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login
 * input fields.
 *
 * <h3>Update API</h3>
 *
 * <p>When the user submits some login input fields, GeckoView dispatches another <code>
 * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to check whether the
 * submitted login exists or whether it's a new or updated login entry.
 *
 * <p>If the submitted login is already contained as-is in the collection returned by <code>
 * onLoginFetch</code>, then GeckoView dispatches <code>StorageDelegate.onLoginUsed</code> with the
 * submitted login entry.
 *
 * <p>If the submitted login is a new or updated entry, GeckoView dispatches a sequence of requests
 * to save/update the login entry, see the Save API example.
 *
 * <h3>Save API</h3>
 *
 * <p>The user enters new or updated (password) login credentials in some login input fields and
 * submits explicitely (submit action) or by navigation. GeckoView identifies the entered
 * credentials and dispatches a <code>GeckoSession.PromptDelegate.onLoginSave(session, request)
 * </code> with the provided credentials.
 *
 * <p>The app may dismiss the prompt request via <code>
 * return GeckoResult.fromValue(prompt.dismiss())</code> which terminates this saving request, or
 * confirm it via <code>return GeckoResult.fromValue(prompt.confirm(login))</code> where <code>login
 * </code> either holds the credentials originally provided by the prompt request (<code>
 * prompt.logins[0]</code>) or a new or modified login entry.
 *
 * <p>The login entry returned in a confirmed save prompt is used to request for saving in the
 * runtime delegate via <code>StorageDelegate.onLoginSave(login)</code>. If the app has already
 * stored the entry during the prompt request handling, it may ignore this storage saving request.
 * <br>
 *
 * @see GeckoRuntime#setAutocompleteStorageDelegate <br>
 * @see GeckoSession#setPromptDelegate <br>
 * @see GeckoSession.PromptDelegate#onLoginSave <br>
 * @see GeckoSession.PromptDelegate#onLoginSelect
 */
public class Autocomplete {
  private static final String LOGTAG = "Autocomplete";
  private static final boolean DEBUG = false;

  protected Autocomplete() {}

  /** Holds credit card information for a specific entry. */
  public static class CreditCard {
    private static final String GUID_KEY = "guid";
    private static final String NAME_KEY = "name";
    private static final String NUMBER_KEY = "number";
    private static final String EXP_MONTH_KEY = "expMonth";
    private static final String EXP_YEAR_KEY = "expYear";

    /** The unique identifier for this login entry. */
    public final @Nullable String guid;

    /** The full name as it appears on the credit card. */
    public final @NonNull String name;

    /** The credit card number. */
    public final @NonNull String number;

    /** The expiration month. */
    public final @NonNull String expirationMonth;

    /** The expiration year. */
    public final @NonNull String expirationYear;

    // For tests only.
    @AnyThread
    protected CreditCard() {
      guid = null;
      name = "";
      number = "";
      expirationMonth = "";
      expirationYear = "";
    }

    @AnyThread
    /* package */ CreditCard(final @NonNull GeckoBundle bundle) {
      guid = bundle.getString(GUID_KEY);
      name = bundle.getString(NAME_KEY, "");
      number = bundle.getString(NUMBER_KEY, "");
      expirationMonth = bundle.getString(EXP_MONTH_KEY, "");
      expirationYear = bundle.getString(EXP_YEAR_KEY, "");
    }

    @Override
    @AnyThread
    public String toString() {
      final StringBuilder builder = new StringBuilder("CreditCard {");
      builder
          .append("guid=")
          .append(guid)
          .append(", name=")
          .append(name)
          .append(", number=")
          .append(number)
          .append(", expirationMonth=")
          .append(expirationMonth)
          .append(", expirationYear=")
          .append(expirationYear)
          .append("}");
      return builder.toString();
    }

    @AnyThread
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(7);
      bundle.putString(GUID_KEY, guid);
      bundle.putString(NAME_KEY, name);
      bundle.putString(NUMBER_KEY, number);
      if (expirationMonth != null) {
        bundle.putString(EXP_MONTH_KEY, expirationMonth);
      }
      if (expirationYear != null) {
        bundle.putString(EXP_YEAR_KEY, expirationYear);
      }

      return bundle;
    }

    public static class Builder {
      private final GeckoBundle mBundle;

      @AnyThread
      /* package */ Builder(final @NonNull GeckoBundle bundle) {
        mBundle = new GeckoBundle(bundle);
      }

      @AnyThread
      @SuppressWarnings("checkstyle:javadocmethod")
      public Builder() {
        mBundle = new GeckoBundle(7);
      }

      /**
       * Finalize the {@link CreditCard} instance.
       *
       * @return The {@link CreditCard} instance.
       */
      @AnyThread
      public @NonNull CreditCard build() {
        return new CreditCard(mBundle);
      }

      /**
       * Set the unique identifier for this credit card entry.
       *
       * @param guid The unique identifier string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder guid(final @Nullable String guid) {
        mBundle.putString(GUID_KEY, guid);
        return this;
      }

      /**
       * Set the name for this credit card entry.
       *
       * @param name The full name as it appears on the credit card.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder name(final @Nullable String name) {
        mBundle.putString(NAME_KEY, name);
        return this;
      }

      /**
       * Set the number for this credit card entry.
       *
       * @param number The credit card number string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder number(final @Nullable String number) {
        mBundle.putString(NUMBER_KEY, number);
        return this;
      }

      /**
       * Set the expiration month for this credit card entry.
       *
       * @param expMonth The expiration month string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder expirationMonth(final @Nullable String expMonth) {
        mBundle.putString(EXP_MONTH_KEY, expMonth);
        return this;
      }

      /**
       * Set the expiration year for this credit card entry.
       *
       * @param expYear The expiration year string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder expirationYear(final @Nullable String expYear) {
        mBundle.putString(EXP_YEAR_KEY, expYear);
        return this;
      }
    }
  }

  /** Holds address information for a specific entry. */
  public static class Address {
    private static final String GUID_KEY = "guid";
    private static final String NAME_KEY = "name";
    private static final String GIVEN_NAME_KEY = "givenName";
    private static final String ADDITIONAL_NAME_KEY = "additionalName";
    private static final String FAMILY_NAME_KEY = "familyName";
    private static final String ORGANIZATION_KEY = "organization";
    private static final String STREET_ADDRESS_KEY = "streetAddress";
    private static final String ADDRESS_LEVEL1_KEY = "addressLevel1";
    private static final String ADDRESS_LEVEL2_KEY = "addressLevel2";
    private static final String ADDRESS_LEVEL3_KEY = "addressLevel3";
    private static final String POSTAL_CODE_KEY = "postalCode";
    private static final String COUNTRY_KEY = "country";
    private static final String TEL_KEY = "tel";
    private static final String EMAIL_KEY = "email";
    private static final byte bundleCapacity = 14;

    /** The unique identifier for this address entry. */
    public final @Nullable String guid;

    /** The full name. */
    public final @NonNull String name;

    /** The given (first) name. */
    public final @NonNull String givenName;

    /** An additional name, if available. */
    public final @NonNull String additionalName;

    /** The family name. */
    public final @NonNull String familyName;

    /** The name of the company, if applicable. */
    public final @NonNull String organization;

    /** The (multiline) street address. */
    public final @NonNull String streetAddress;

    /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */
    public final @NonNull String addressLevel1;

    /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */
    public final @NonNull String addressLevel2;

    /**
     * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided.
     */
    public final @NonNull String addressLevel3;

    /** The postal code. */
    public final @NonNull String postalCode;

    /** The country string in ISO 3166. */
    public final @NonNull String country;

    /** The telephone number string. */
    public final @NonNull String tel;

    /** The email address. */
    public final @NonNull String email;

    // For tests only.
    @AnyThread
    protected Address() {
      guid = null;
      name = "";
      givenName = "";
      additionalName = "";
      familyName = "";
      organization = "";
      streetAddress = "";
      addressLevel1 = "";
      addressLevel2 = "";
      addressLevel3 = "";
      postalCode = "";
      country = "";
      tel = "";
      email = "";
    }

    @AnyThread
    /* package */ Address(final @NonNull GeckoBundle bundle) {
      guid = bundle.getString(GUID_KEY);
      name = bundle.getString(NAME_KEY, "");
      givenName = bundle.getString(GIVEN_NAME_KEY, "");
      additionalName = bundle.getString(ADDITIONAL_NAME_KEY, "");
      familyName = bundle.getString(FAMILY_NAME_KEY, "");
      organization = bundle.getString(ORGANIZATION_KEY, "");
      streetAddress = bundle.getString(STREET_ADDRESS_KEY, "");
      addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, "");
      addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, "");
      addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, "");
      postalCode = bundle.getString(POSTAL_CODE_KEY, "");
      country = bundle.getString(COUNTRY_KEY, "");
      tel = bundle.getString(TEL_KEY, "");
      email = bundle.getString(EMAIL_KEY, "");
    }

    @Override
    @AnyThread
    public String toString() {
      final StringBuilder builder = new StringBuilder("Address {");
      builder
          .append("guid=")
          .append(guid)
          .append(", givenName=")
          .append(givenName)
          .append(", additionalName=")
          .append(additionalName)
          .append(", familyName=")
          .append(familyName)
          .append(", organization=")
          .append(organization)
          .append(", streetAddress=")
          .append(streetAddress)
          .append(", addressLevel1=")
          .append(addressLevel1)
          .append(", addressLevel2=")
          .append(addressLevel2)
          .append(", addressLevel3=")
          .append(addressLevel3)
          .append(", postalCode=")
          .append(postalCode)
          .append(", country=")
          .append(country)
          .append(", tel=")
          .append(tel)
          .append(", email=")
          .append(email)
          .append("}");
      return builder.toString();
    }

    @AnyThread
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(bundleCapacity);
      bundle.putString(GUID_KEY, guid);
      bundle.putString(NAME_KEY, name);
      bundle.putString(GIVEN_NAME_KEY, givenName);
      bundle.putString(ADDITIONAL_NAME_KEY, additionalName);
      bundle.putString(FAMILY_NAME_KEY, familyName);
      bundle.putString(ORGANIZATION_KEY, organization);
      bundle.putString(STREET_ADDRESS_KEY, streetAddress);
      bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
      bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
      bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
      bundle.putString(POSTAL_CODE_KEY, postalCode);
      bundle.putString(COUNTRY_KEY, country);
      bundle.putString(TEL_KEY, tel);
      bundle.putString(EMAIL_KEY, email);

      return bundle;
    }

    public static class Builder {
      private final GeckoBundle mBundle;

      @AnyThread
      /* package */ Builder(final @NonNull GeckoBundle bundle) {
        mBundle = new GeckoBundle(bundle);
      }

      @AnyThread
      @SuppressWarnings("checkstyle:javadocmethod")
      public Builder() {
        mBundle = new GeckoBundle(bundleCapacity);
      }

      /**
       * Finalize the {@link Address} instance.
       *
       * @return The {@link Address} instance.
       */
      @AnyThread
      public @NonNull Address build() {
        return new Address(mBundle);
      }

      /**
       * Set the unique identifier for this address entry.
       *
       * @param guid The unique identifier string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder guid(final @Nullable String guid) {
        mBundle.putString(GUID_KEY, guid);
        return this;
      }

      /**
       * Set the full name for this address entry.
       *
       * @param name The full name string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder name(final @Nullable String name) {
        mBundle.putString(NAME_KEY, name);
        return this;
      }

      /**
       * Set the given name for this address entry.
       *
       * @param givenName The given name string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder givenName(final @Nullable String givenName) {
        mBundle.putString(GIVEN_NAME_KEY, givenName);
        return this;
      }

      /**
       * Set the additional name for this address entry.
       *
       * @param additionalName The additional name string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder additionalName(final @Nullable String additionalName) {
        mBundle.putString(ADDITIONAL_NAME_KEY, additionalName);
        return this;
      }

      /**
       * Set the family name for this address entry.
       *
       * @param familyName The family name string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder familyName(final @Nullable String familyName) {
        mBundle.putString(FAMILY_NAME_KEY, familyName);
        return this;
      }

      /**
       * Set the company name for this address entry.
       *
       * @param organization The company name string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder organization(final @Nullable String organization) {
        mBundle.putString(ORGANIZATION_KEY, organization);
        return this;
      }

      /**
       * Set the street address for this address entry.
       *
       * @param streetAddress The street address string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder streetAddress(final @Nullable String streetAddress) {
        mBundle.putString(STREET_ADDRESS_KEY, streetAddress);
        return this;
      }

      /**
       * Set the level 1 address for this address entry.
       *
       * @param addressLevel1 The level 1 address string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) {
        mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
        return this;
      }

      /**
       * Set the level 2 address for this address entry.
       *
       * @param addressLevel2 The level 2 address string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) {
        mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
        return this;
      }

      /**
       * Set the level 3 address for this address entry.
       *
       * @param addressLevel3 The level 3 address string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) {
        mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
        return this;
      }

      /**
       * Set the postal code for this address entry.
       *
       * @param postalCode The postal code string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder postalCode(final @Nullable String postalCode) {
        mBundle.putString(POSTAL_CODE_KEY, postalCode);
        return this;
      }

      /**
       * Set the country code for this address entry.
       *
       * @param country The country string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder country(final @Nullable String country) {
        mBundle.putString(COUNTRY_KEY, country);
        return this;
      }

      /**
       * Set the telephone number for this address entry.
       *
       * @param tel The telephone number string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder tel(final @Nullable String tel) {
        mBundle.putString(TEL_KEY, tel);
        return this;
      }

      /**
       * Set the email address for this address entry.
       *
       * @param email The email address string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder email(final @Nullable String email) {
        mBundle.putString(EMAIL_KEY, email);
        return this;
      }
    }
  }

  /** Holds login information for a specific entry. */
  public static class LoginEntry {
    private static final String GUID_KEY = "guid";
    private static final String ORIGIN_KEY = "origin";
    private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin";
    private static final String HTTP_REALM_KEY = "httpRealm";
    private static final String USERNAME_KEY = "username";
    private static final String PASSWORD_KEY = "password";

    /** The unique identifier for this login entry. */
    public final @Nullable String guid;

    /** The origin this login entry applies to. */
    public final @NonNull String origin;

    /**
     * The origin this login entry was submitted to. This only applies to form-based login entries.
     * It's derived from the action attribute set on the form element.
     */
    public final @Nullable String formActionOrigin;

    /**
     * The HTTP realm this login entry was requested for. This only applies to non-form-based login
     * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see
     * RFC2617 for details.
     */
    public final @Nullable String httpRealm;

    /** The username for this login entry. */
    public final @NonNull String username;

    /** The password for this login entry. */
    public final @NonNull String password;

    // For tests only.
    @AnyThread
    protected LoginEntry() {
      guid = null;
      origin = "";
      formActionOrigin = null;
      httpRealm = null;
      username = "";
      password = "";
    }

    @AnyThread
    /* package */ LoginEntry(final @NonNull GeckoBundle bundle) {
      guid = bundle.getString(GUID_KEY);
      origin = bundle.getString(ORIGIN_KEY, "");
      formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY);
      httpRealm = bundle.getString(HTTP_REALM_KEY);
      username = bundle.getString(USERNAME_KEY, "");
      password = bundle.getString(PASSWORD_KEY, "");
    }

    @Override
    @AnyThread
    public String toString() {
      final StringBuilder builder = new StringBuilder("LoginEntry {");
      builder
          .append("guid=")
          .append(guid)
          .append(", origin=")
          .append(origin)
          .append(", formActionOrigin=")
          .append(formActionOrigin)
          .append(", httpRealm=")
          .append(httpRealm)
          .append(", username=")
          .append(username)
          .append(", password=")
          .append(password)
          .append("}");
      return builder.toString();
    }

    @AnyThread
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(6);
      bundle.putString(GUID_KEY, guid);
      bundle.putString(ORIGIN_KEY, origin);
      bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
      bundle.putString(HTTP_REALM_KEY, httpRealm);
      bundle.putString(USERNAME_KEY, username);
      bundle.putString(PASSWORD_KEY, password);

      return bundle;
    }

    public static class Builder {
      private final GeckoBundle mBundle;

      @AnyThread
      /* package */ Builder(final @NonNull GeckoBundle bundle) {
        mBundle = new GeckoBundle(bundle);
      }

      @AnyThread
      @SuppressWarnings("checkstyle:javadocmethod")
      public Builder() {
        mBundle = new GeckoBundle(6);
      }

      /**
       * Finalize the {@link LoginEntry} instance.
       *
       * @return The {@link LoginEntry} instance.
       */
      @AnyThread
      public @NonNull LoginEntry build() {
        return new LoginEntry(mBundle);
      }

      /**
       * Set the unique identifier for this login entry.
       *
       * @param guid The unique identifier string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder guid(final @Nullable String guid) {
        mBundle.putString(GUID_KEY, guid);
        return this;
      }

      /**
       * Set the origin this login entry applies to.
       *
       * @param origin The origin string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder origin(final @NonNull String origin) {
        mBundle.putString(ORIGIN_KEY, origin);
        return this;
      }

      /**
       * Set the origin this login entry was submitted to.
       *
       * @param formActionOrigin The form action origin string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) {
        mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
        return this;
      }

      /**
       * Set the HTTP realm this login entry was requested for.
       *
       * @param httpRealm The HTTP realm string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder httpRealm(final @Nullable String httpRealm) {
        mBundle.putString(HTTP_REALM_KEY, httpRealm);
        return this;
      }

      /**
       * Set the username for this login entry.
       *
       * @param username The username string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder username(final @NonNull String username) {
        mBundle.putString(USERNAME_KEY, username);
        return this;
      }

      /**
       * Set the password for this login entry.
       *
       * @param password The password string.
       * @return This {@link Builder} instance.
       */
      @AnyThread
      public @NonNull Builder password(final @NonNull String password) {
        mBundle.putString(PASSWORD_KEY, password);
        return this;
      }
    }
  }

  @Retention(RetentionPolicy.SOURCE)
  @IntDef(
      flag = true,
      value = {UsedField.PASSWORD})
  public @interface LSUsedField {}

  // Sync with UsedField in GeckoViewAutocomplete.sys.mjs.
  /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */
  public static class UsedField {
    /** The password field of a login entry. */
    public static final int PASSWORD = 1;

    protected UsedField() {}
  }

  /**
   * Implement this interface to handle runtime login storage requests. Login storage events include
   * login entry requests for autofill and autocompletion of login input fields. This delegate is
   * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}.
   */
  public interface StorageDelegate {
    /**
     * Request login entries for a given domain. While processing the web document, we have
     * identified elements resembling login input fields suitable for autofill. We will attempt to
     * match the provided login information to the identified input fields.
     *
     * @param domain The domain string for the requested logins.
     * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
     *     the existing logins for the given domain.
     */
    @UiThread
    default @Nullable GeckoResult<LoginEntry[]> onLoginFetch(@NonNull final String domain) {
      return null;
    }

    /**
     * Request login entries for all domains.
     *
     * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
     *     the existing logins.
     */
    @UiThread
    default @Nullable GeckoResult<LoginEntry[]> onLoginFetch() {
      return null;
    }

    /**
     * Request credit card entries. While processing the web document, we have identified elements
     * resembling credit card input fields suitable for autofill. We will attempt to match the
     * provided credit card information to the identified input fields.
     *
     * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing
     *     the existing credit cards.
     */
    @UiThread
    default @Nullable GeckoResult<CreditCard[]> onCreditCardFetch() {
      return null;
    }

    /**
     * Request address entries. While processing the web document, we have identified elements
     * resembling address input fields suitable for autofill. We will attempt to match the provided
     * address information to the identified input fields.
     *
     * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the
     *     existing addresses.
     */
    @UiThread
    default @Nullable GeckoResult<Address[]> onAddressFetch() {
      return null;
    }

    /**
     * Request saving or updating of the given login entry. This is triggered by confirming a {@link
     * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request.
     *
     * @param login The {@link LoginEntry} as confirmed by the prompt request.
     */
    @UiThread
    default void onLoginSave(@NonNull final LoginEntry login) {}

    /**
     * Request saving or updating of the given credit card entry. This is triggered by confirming a
     * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request.
     *
     * @param creditCard The {@link CreditCard} as confirmed by the prompt request.
     */
    @UiThread
    default void onCreditCardSave(@NonNull CreditCard creditCard) {}

    /**
     * Request saving or updating of the given address entry. This is triggered by confirming a
     * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request.
     *
     * @param address The {@link Address} as confirmed by the prompt request.
     */
    @UiThread
    default void onAddressSave(@NonNull Address address) {}

    /**
     * Notify that the given login was used to autofill login input fields. This is triggered by
     * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}.
     *
     * @param login The {@link LoginEntry} that was used for the autofilling.
     * @param usedFields The login entry fields used for autofilling. A combination of {@link
     *     UsedField}.
     */
    @UiThread
    default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {}
  }

  /**
   * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and
   * {@link Autocomplete.SelectOption}.
   */
  public abstract static class Option<T> {
    /* package */ static final String VALUE_KEY = "value";
    /* package */ static final String HINT_KEY = "hint";

    public final @NonNull T value;
    public final int hint;

    @SuppressWarnings("checkstyle:javadocmethod")
    public Option(final @NonNull T value, final int hint) {
      this.value = value;
      this.hint = hint;
    }

    @AnyThread
    /* package */ abstract @NonNull GeckoBundle toBundle();
  }

  /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSaveOption}. */
  public abstract static class SaveOption<T> extends Option<T> {
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(
        flag = true,
        value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE})
    public @interface SaveOptionHint {}

    /** Hint types for login saving requests. */
    public static class Hint {
      public static final int NONE = 0;

      /** Auto-generated password. Notify but do not prompt the user for saving. */
      public static final int GENERATED = 1 << 0;

      /**
       * Potentially non-login data. The form data entered may be not login credentials but other
       * forms of input like credit card numbers. Note that this could be valid login data in same
       * cases, e.g., some banks may expect credit card numbers in the username field.
       */
      public static final int LOW_CONFIDENCE = 1 << 1;

      protected Hint() {}
    }

    @SuppressWarnings("checkstyle:javadocmethod")
    public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) {
      super(value, hint);
    }
  }

  /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */
  public abstract static class SelectOption<T> extends Option<T> {
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(
        flag = true,
        value = {
          Hint.NONE,
          Hint.GENERATED,
          Hint.INSECURE_FORM,
          Hint.DUPLICATE_USERNAME,
          Hint.MATCHING_ORIGIN
        })
    public @interface SelectOptionHint {}

    /** Hint types for selection requests. */
    public static class Hint {
      public static final int NONE = 0;

      /**
       * Auto-generated password. A new password-only login entry containing a secure generated
       * password.
       */
      public static final int GENERATED = 1 << 0;

      /**
       * Insecure context. The form or transmission mechanics are considered insecure. This is the
       * case when the form is served via http or submitted insecurely.
       */
      public static final int INSECURE_FORM = 1 << 1;

      /**
       * The username is shared with another login entry. There are multiple login entries in the
       * options that share the same username. You may have to disambiguate the login entry, e.g.,
       * using the last date of modification and its origin.
       */
      public static final int DUPLICATE_USERNAME = 1 << 2;

      /**
       * The login entry's origin matches the login form origin. The login was saved from the same
       * origin it is being requested for, rather than for a subdomain.
       */
      public static final int MATCHING_ORIGIN = 1 << 3;
    }

    @SuppressWarnings("checkstyle:javadocmethod")
    public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) {
      super(value, hint);
    }

    @Override
    public String toString() {
      final StringBuilder builder = new StringBuilder("SelectOption {");
      builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}");
      return builder.toString();
    }
  }

  /** Holds information required to process login saving requests. */
  public static class LoginSaveOption extends SaveOption<LoginEntry> {
    /**
     * Construct a login save option.
     *
     * @param value The {@link LoginEntry} login entry to be saved.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) {
      super(value, hint);
    }

    /**
     * Construct a login save option.
     *
     * @param value The {@link LoginEntry} login entry to be saved.
     */
    public LoginSaveOption(final @NonNull LoginEntry value) {
      this(value, Hint.NONE);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /** Holds information required to process address saving requests. */
  public static class AddressSaveOption extends SaveOption<Address> {
    /**
     * Construct a address save option.
     *
     * @param value The {@link Address} address entry to be saved.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) {
      super(value, hint);
    }

    /**
     * Construct an address save option.
     *
     * @param value The {@link Address} address entry to be saved.
     */
    public AddressSaveOption(final @NonNull Address value) {
      this(value, Hint.NONE);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /** Holds information required to process credit card saving requests. */
  public static class CreditCardSaveOption extends SaveOption<CreditCard> {
    /**
     * Construct a credit card save option.
     *
     * @param value The {@link CreditCard} credit card entry to be saved.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ CreditCardSaveOption(
        final @NonNull CreditCard value, final @SaveOptionHint int hint) {
      super(value, hint);
    }

    /**
     * Construct a credit card save option.
     *
     * @param value The {@link CreditCard} credit card entry to be saved.
     */
    public CreditCardSaveOption(final @NonNull CreditCard value) {
      this(value, Hint.NONE);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /** Holds information required to process login selection requests. */
  public static class LoginSelectOption extends SelectOption<LoginEntry> {
    /**
     * Construct a login select option.
     *
     * @param value The {@link LoginEntry} login entry selection option.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ LoginSelectOption(
        final @NonNull LoginEntry value, final @SelectOptionHint int hint) {
      super(value, hint);
    }

    /**
     * Construct a login select option.
     *
     * @param value The {@link LoginEntry} login entry selection option.
     */
    public LoginSelectOption(final @NonNull LoginEntry value) {
      this(value, Hint.NONE);
    }

    /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) {
      final int hint = bundle.getInt("hint");
      final LoginEntry value = new LoginEntry(bundle.getBundle("value"));

      return new LoginSelectOption(value, hint);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /** Holds information required to process credit card selection requests. */
  public static class CreditCardSelectOption extends SelectOption<CreditCard> {
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(
        flag = true,
        value = {Hint.NONE, Hint.INSECURE_FORM})
    public @interface CreditCardSelectHint {}

    /** Hint types for credit card selection requests. */
    public static class Hint {
      public static final int NONE = 0;

      /**
       * Insecure context. The form or transmission mechanics are considered insecure. This is the
       * case when the form is served via http or submitted insecurely.
       */
      public static final int INSECURE_FORM = 1 << 1;
    }

    /**
     * Construct a credit card select option.
     *
     * @param value The {@link LoginEntry} credit card entry selection option.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ CreditCardSelectOption(
        final @NonNull CreditCard value, final @CreditCardSelectHint int hint) {
      super(value, hint);
    }

    /**
     * Construct a credit card select option.
     *
     * @param value The {@link CreditCard} credit card entry selection option.
     */
    public CreditCardSelectOption(final @NonNull CreditCard value) {
      this(value, Hint.NONE);
    }

    /* package */ static @NonNull CreditCardSelectOption fromBundle(
        final @NonNull GeckoBundle bundle) {
      final int hint = bundle.getInt("hint");
      final CreditCard value = new CreditCard(bundle.getBundle("value"));

      return new CreditCardSelectOption(value, hint);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /** Holds information required to process address selection requests. */
  public static class AddressSelectOption extends SelectOption<Address> {
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(
        flag = true,
        value = {Hint.NONE, Hint.INSECURE_FORM})
    public @interface AddressSelectHint {}

    /** Hint types for credit card selection requests. */
    public static class Hint {
      public static final int NONE = 0;

      /**
       * Insecure context. The form or transmission mechanics are considered insecure. This is the
       * case when the form is served via http or submitted insecurely.
       */
      public static final int INSECURE_FORM = 1 << 1;
    }

    /**
     * Construct a credit card select option.
     *
     * @param value The {@link LoginEntry} credit card entry selection option.
     * @param hint The {@link Hint} detailing the type of the option.
     */
    /* package */ AddressSelectOption(
        final @NonNull Address value, final @AddressSelectHint int hint) {
      super(value, hint);
    }

    /**
     * Construct a address select option.
     *
     * @param value The {@link Address} address entry selection option.
     */
    public AddressSelectOption(final @NonNull Address value) {
      this(value, Hint.NONE);
    }

    /* package */ static @NonNull AddressSelectOption fromBundle(
        final @NonNull GeckoBundle bundle) {
      final int hint = bundle.getInt("hint");
      final Address value = new Address(bundle.getBundle("value"));

      return new AddressSelectOption(value, hint);
    }

    @Override
    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(2);
      bundle.putBundle(VALUE_KEY, value.toBundle());
      bundle.putInt(HINT_KEY, hint);
      return bundle;
    }
  }

  /* package */ static final class StorageProxy implements BundleEventListener {
    private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login";
    private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard";
    private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address";
    private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login";
    private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard";
    private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address";
    private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login";

    private @Nullable StorageDelegate mDelegate;

    public StorageProxy() {}

    private void registerListener() {
      EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null);
      EventDispatcher.getInstance()
          .registerUiThreadListener(
              this,
              FETCH_LOGIN_EVENT,
              FETCH_CREDIT_CARD_EVENT,
              FETCH_ADDRESS_EVENT,
              SAVE_LOGIN_EVENT,
              SAVE_CREDIT_CARD_EVENT,
              SAVE_ADDRESS_EVENT,
              USED_LOGIN_EVENT);
    }

    private void unregisterListener() {
      EventDispatcher.getInstance()
          .unregisterUiThreadListener(
              this,
              FETCH_LOGIN_EVENT,
              FETCH_CREDIT_CARD_EVENT,
              FETCH_ADDRESS_EVENT,
              SAVE_LOGIN_EVENT,
              SAVE_CREDIT_CARD_EVENT,
              SAVE_ADDRESS_EVENT,
              USED_LOGIN_EVENT);
    }

    public synchronized void setDelegate(final @Nullable StorageDelegate delegate) {
      if (mDelegate == delegate) {
        return;
      }
      if (mDelegate != null) {
        unregisterListener();
      }

      mDelegate = delegate;

      if (mDelegate != null) {
        registerListener();
      }
    }

    public synchronized @Nullable StorageDelegate getDelegate() {
      return mDelegate;
    }

    @Override // BundleEventListener
    public synchronized void handleMessage(
        final String event, final GeckoBundle message, final EventCallback callback) {
      if (DEBUG) {
        Log.d(LOGTAG, "handleMessage " + event);
      }

      if (mDelegate == null) {
        if (callback != null) {
          callback.sendError("No StorageDelegate attached");
        }
        return;
      }

      if (FETCH_LOGIN_EVENT.equals(event)) {
        final String domain = message.getString("domain");
        final GeckoResult<Autocomplete.LoginEntry[]> result =
            domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch();

        if (result == null) {
          callback.sendSuccess(new GeckoBundle[0]);
          return;
        }

        callback.resolveTo(
            result.map(
                logins -> {
                  if (logins == null) {
                    return new GeckoBundle[0];
                  }

                  // This is a one-liner with streams (API level 24).
                  final GeckoBundle[] loginBundles = new GeckoBundle[logins.length];
                  for (int i = 0; i < logins.length; ++i) {
                    loginBundles[i] = logins[i].toBundle();
                  }

                  return loginBundles;
                }));
      } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) {
        final GeckoResult<Autocomplete.CreditCard[]> result = mDelegate.onCreditCardFetch();

        if (result == null) {
          callback.sendSuccess(new GeckoBundle[0]);
          return;
        }

        callback.resolveTo(
            result.map(
                creditCards -> {
                  if (creditCards == null) {
                    return new GeckoBundle[0];
                  }

                  // This is a one-liner with streams (API level 24).
                  final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length];
                  for (int i = 0; i < creditCards.length; ++i) {
                    creditCardBundles[i] = creditCards[i].toBundle();
                  }

                  return creditCardBundles;
                }));
      } else if (FETCH_ADDRESS_EVENT.equals(event)) {
        final GeckoResult<Autocomplete.Address[]> result = mDelegate.onAddressFetch();

        if (result == null) {
          callback.sendSuccess(new GeckoBundle[0]);
          return;
        }

        callback.resolveTo(
            result.map(
                addresses -> {
                  if (addresses == null) {
                    return new GeckoBundle[0];
                  }

                  // This is a one-liner with streams (API level 24).
                  final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length];
                  for (int i = 0; i < addresses.length; ++i) {
                    addressBundles[i] = addresses[i].toBundle();
                  }

                  return addressBundles;
                }));
      } else if (SAVE_LOGIN_EVENT.equals(event)) {
        final GeckoBundle loginBundle = message.getBundle("login");
        final LoginEntry login = new LoginEntry(loginBundle);

        mDelegate.onLoginSave(login);
      } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) {
        final GeckoBundle creditCardBundle = message.getBundle("creditCard");
        final CreditCard creditCard = new CreditCard(creditCardBundle);

        mDelegate.onCreditCardSave(creditCard);
      } else if (SAVE_ADDRESS_EVENT.equals(event)) {
        final GeckoBundle addressBundle = message.getBundle("address");
        final Address address = new Address(addressBundle);

        mDelegate.onAddressSave(address);
      } else if (USED_LOGIN_EVENT.equals(event)) {
        final GeckoBundle loginBundle = message.getBundle("login");
        final LoginEntry login = new LoginEntry(loginBundle);
        final int fields = message.getInt("usedFields");

        mDelegate.onLoginUsed(login, fields);
      }
    }
  }
}
