/* 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/. */
"use strict";

this.EXPORTED_SYMBOLS = ["fxAccounts", "FxAccounts"];

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

Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-common/rest.js");
Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");

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

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

XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
  "resource://gre/modules/identity/jwcrypto.jsm");

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

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

XPCOMUtils.defineLazyModuleGetter(this, "Utils",
  "resource://services-sync/util.js");

// All properties exposed by the public FxAccounts API.
var publicProperties = [
  "accountStatus",
  "checkVerificationStatus",
  "getAccountsClient",
  "getAssertion",
  "getDeviceId",
  "getKeys",
  "getOAuthToken",
  "getSignedInUser",
  "getSignedInUserProfile",
  "handleDeviceDisconnection",
  "invalidateCertificate",
  "loadAndPoll",
  "localtimeOffsetMsec",
  "notifyDevices",
  "now",
  "promiseAccountsChangeProfileURI",
  "promiseAccountsForceSigninURI",
  "promiseAccountsManageURI",
  "promiseAccountsSignUpURI",
  "promiseAccountsSignInURI",
  "removeCachedOAuthToken",
  "requiresHttps",
  "resendVerificationEmail",
  "resetCredentials",
  "sessionStatus",
  "setSignedInUser",
  "signOut",
  "updateDeviceRegistration",
  "updateUserAccountData",
  "whenVerified",
];

// An AccountState object holds all state related to one specific account.
// Only one AccountState is ever "current" in the FxAccountsInternal object -
// whenever a user logs out or logs in, the current AccountState is discarded,
// making it impossible for the wrong state or state data to be accidentally
// used.
// In addition, it has some promise-related helpers to ensure that if an
// attempt is made to resolve a promise on a "stale" state (eg, if an
// operation starts, but a different user logs in before the operation
// completes), the promise will be rejected.
// It is intended to be used thusly:
// somePromiseBasedFunction: function() {
//   let currentState = this.currentAccountState;
//   return someOtherPromiseFunction().then(
//     data => currentState.resolve(data)
//   );
// }
// If the state has changed between the function being called and the promise
// being resolved, the .resolve() call will actually be rejected.
var AccountState = this.AccountState = function(storageManager) {
  this.storageManager = storageManager;
  this.promiseInitialized = this.storageManager.getAccountData().then(data => {
    this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {};
  }).catch(err => {
    log.error("Failed to initialize the storage manager", err);
    // Things are going to fall apart, but not much we can do about it here.
  });
};

AccountState.prototype = {
  oauthTokens: null,
  whenVerifiedDeferred: null,
  whenKeysReadyDeferred: null,

  // If the storage manager has been nuked then we are no longer current.
  get isCurrent() {
    return this.storageManager != null;
  },

  abort() {
    if (this.whenVerifiedDeferred) {
      this.whenVerifiedDeferred.reject(
        new Error("Verification aborted; Another user signing in"));
      this.whenVerifiedDeferred = null;
    }

    if (this.whenKeysReadyDeferred) {
      this.whenKeysReadyDeferred.reject(
        new Error("Verification aborted; Another user signing in"));
      this.whenKeysReadyDeferred = null;
    }

    this.cert = null;
    this.keyPair = null;
    this.oauthTokens = null;
    // Avoid finalizing the storageManager multiple times (ie, .signOut()
    // followed by .abort())
    if (!this.storageManager) {
      return Promise.resolve();
    }
    let storageManager = this.storageManager;
    this.storageManager = null;
    return storageManager.finalize();
  },

  // Clobber all cached data and write that empty data to storage.
  signOut() {
    this.cert = null;
    this.keyPair = null;
    this.oauthTokens = null;
    let storageManager = this.storageManager;
    this.storageManager = null;
    return storageManager.deleteAccountData().then(() => {
      return storageManager.finalize();
    });
  },

  // Get user account data. Optionally specify explicit field names to fetch
  // (and note that if you require an in-memory field you *must* specify the
  // field name(s).)
  getUserAccountData(fieldNames = null) {
    if (!this.isCurrent) {
      return Promise.reject(new Error("Another user has signed in"));
    }
    return this.storageManager.getAccountData(fieldNames).then(result => {
      return this.resolve(result);
    });
  },

  updateUserAccountData(updatedFields) {
    if (!this.isCurrent) {
      return Promise.reject(new Error("Another user has signed in"));
    }
    return this.storageManager.updateAccountData(updatedFields);
  },

  resolve: function(result) {
    if (!this.isCurrent) {
      log.info("An accountState promise was resolved, but was actually rejected" +
               " due to a different user being signed in. Originally resolved" +
               " with", result);
      return Promise.reject(new Error("A different user signed in"));
    }
    return Promise.resolve(result);
  },

  reject: function(error) {
    // It could be argued that we should just let it reject with the original
    // error - but this runs the risk of the error being (eg) a 401, which
    // might cause the consumer to attempt some remediation and cause other
    // problems.
    if (!this.isCurrent) {
      log.info("An accountState promise was rejected, but we are ignoring that" +
               "reason and rejecting it due to a different user being signed in." +
               "Originally rejected with", error);
      return Promise.reject(new Error("A different user signed in"));
    }
    return Promise.reject(error);
  },

  // Abstractions for storage of cached tokens - these are all sync, and don't
  // handle revocation etc - it's just storage (and the storage itself is async,
  // but we don't return the storage promises, so it *looks* sync)
  // These functions are sync simply so we can handle "token races" - when there
  // are multiple in-flight requests for the same scope, we can detect this
  // and revoke the redundant token.

  // A preamble for the cache helpers...
  _cachePreamble() {
    if (!this.isCurrent) {
      throw new Error("Another user has signed in");
    }
  },

  // Set a cached token. |tokenData| must have a 'token' element, but may also
  // have additional fields (eg, it probably specifies the server to revoke
  // from). The 'get' functions below return the entire |tokenData| value.
  setCachedToken(scopeArray, tokenData) {
    this._cachePreamble();
    if (!tokenData.token) {
      throw new Error("No token");
    }
    let key = getScopeKey(scopeArray);
    this.oauthTokens[key] = tokenData;
    // And a background save...
    this._persistCachedTokens();
  },

  // Return data for a cached token or null (or throws on bad state etc)
  getCachedToken(scopeArray) {
    this._cachePreamble();
    let key = getScopeKey(scopeArray);
    let result = this.oauthTokens[key];
    if (result) {
      // later we might want to check an expiry date - but we currently
      // have no such concept, so just return it.
      log.trace("getCachedToken returning cached token");
      return result;
    }
    return null;
  },

  // Remove a cached token from the cache.  Does *not* revoke it from anywhere.
  // Returns the entire token entry if found, null otherwise.
  removeCachedToken(token) {
    this._cachePreamble();
    let data = this.oauthTokens;
    for (let [key, tokenValue] of Object.entries(data)) {
      if (tokenValue.token == token) {
        delete data[key];
        // And a background save...
        this._persistCachedTokens();
        return tokenValue;
      }
    }
    return null;
  },

  // A hook-point for tests.  Returns a promise that's ignored in most cases
  // (notable exceptions are tests and when we explicitly are saving the entire
  // set of user data.)
  _persistCachedTokens() {
    this._cachePreamble();
    return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(err => {
      log.error("Failed to update cached tokens", err);
    });
  },
}

/* Given an array of scopes, make a string key by normalizing. */
function getScopeKey(scopeArray) {
  let normalizedScopes = scopeArray.map(item => item.toLowerCase());
  return normalizedScopes.sort().join("|");
}

/**
 * Copies properties from a given object to another object.
 *
 * @param from (object)
 *        The object we read property descriptors from.
 * @param to (object)
 *        The object that we set property descriptors on.
 * @param options (object) (optional)
 *        {keys: [...]}
 *          Lets the caller pass the names of all properties they want to be
 *          copied. Will copy all properties of the given source object by
 *          default.
 *        {bind: object}
 *          Lets the caller specify the object that will be used to .bind()
 *          all function properties we find to. Will bind to the given target
 *          object by default.
 */
function copyObjectProperties(from, to, opts = {}) {
  let keys = (opts && opts.keys) || Object.keys(from);
  let thisArg = (opts && opts.bind) || to;

  for (let prop of keys) {
    let desc = Object.getOwnPropertyDescriptor(from, prop);

    if (typeof(desc.value) == "function") {
      desc.value = desc.value.bind(thisArg);
    }

    if (desc.get) {
      desc.get = desc.get.bind(thisArg);
    }

    if (desc.set) {
      desc.set = desc.set.bind(thisArg);
    }

    Object.defineProperty(to, prop, desc);
  }
}

function urlsafeBase64Encode(key) {
  return ChromeUtils.base64URLEncode(new Uint8Array(key), { pad: false });
}

/**
 * The public API's constructor.
 */
this.FxAccounts = function (mockInternal) {
  let internal = new FxAccountsInternal();
  let external = {};

  // Copy all public properties to the 'external' object.
  let prototype = FxAccountsInternal.prototype;
  let options = {keys: publicProperties, bind: internal};
  copyObjectProperties(prototype, external, options);

  // Copy all of the mock's properties to the internal object.
  if (mockInternal && !mockInternal.onlySetInternal) {
    copyObjectProperties(mockInternal, internal);
  }

  if (mockInternal) {
    // Exposes the internal object for testing only.
    external.internal = internal;
  }

  if (!internal.fxaPushService) {
    // internal.fxaPushService option is used in testing.
    // Otherwise we load the service lazily.
    XPCOMUtils.defineLazyGetter(internal, "fxaPushService", function () {
      return Components.classes["@mozilla.org/fxaccounts/push;1"]
        .getService(Components.interfaces.nsISupports)
        .wrappedJSObject;
    });
  }

  // wait until after the mocks are setup before initializing.
  internal.initialize();

  return Object.freeze(external);
}

/**
 * The internal API's constructor.
 */
function FxAccountsInternal() {
  // Make a local copy of this constant so we can mock it in testing
  this.POLL_SESSION = POLL_SESSION;

  // All significant initialization should be done in the initialize() method
  // below as it helps with testing.
}

/**
 * The internal API's prototype.
 */
FxAccountsInternal.prototype = {
  // The timeout (in ms) we use to poll for a verified mail for the first 2 mins.
  VERIFICATION_POLL_TIMEOUT_INITIAL: 15000, // 15 seconds
  // And how often we poll after the first 2 mins.
  VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 30000, // 30 seconds.
  // The current version of the device registration, we use this to re-register
  // devices after we update what we send on device registration.
  DEVICE_REGISTRATION_VERSION: 2,

  _fxAccountsClient: null,

  // All significant initialization should be done in this initialize() method,
  // as it's called after this object has been mocked for tests.
  initialize() {
    this.currentTimer = null;
    this.currentAccountState = this.newAccountState();
  },

  get fxAccountsClient() {
    if (!this._fxAccountsClient) {
      this._fxAccountsClient = new FxAccountsClient();
    }
    return this._fxAccountsClient;
  },

  // The profile object used to fetch the actual user profile.
  _profile: null,
  get profile() {
    if (!this._profile) {
      let profileServerUrl = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.profile.uri");
      this._profile = new FxAccountsProfile({
        fxa: this,
        profileServerUrl: profileServerUrl,
      });
    }
    return this._profile;
  },

  // A hook-point for tests who may want a mocked AccountState or mocked storage.
  newAccountState(credentials) {
    let storage = new FxAccountsStorageManager();
    storage.initialize(credentials);
    return new AccountState(storage);
  },

  /**
   * Send a message to a set of devices in the same account
   *
   * @return Promise
   */
  notifyDevices: function(deviceIds, payload, TTL) {
    if (!Array.isArray(deviceIds)) {
      deviceIds = [deviceIds];
    }
    return this.currentAccountState.getUserAccountData()
      .then(data => {
        if (!data) {
          throw this._error(ERROR_NO_ACCOUNT);
        }
        if (!data.sessionToken) {
          throw this._error(ERROR_AUTH_ERROR,
            "notifyDevices called without a session token");
        }
        return this.fxAccountsClient.notifyDevices(data.sessionToken, deviceIds,
          payload, TTL);
    });
  },

  /**
   * Return the current time in milliseconds as an integer.  Allows tests to
   * manipulate the date to simulate certificate expiration.
   */
  now: function() {
    return this.fxAccountsClient.now();
  },

  getAccountsClient: function() {
    return this.fxAccountsClient;
  },

  /**
   * Return clock offset in milliseconds, as reported by the fxAccountsClient.
   * This can be overridden for testing.
   *
   * The offset is the number of milliseconds that must be added to the client
   * clock to make it equal to the server clock.  For example, if the client is
   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
   */
  get localtimeOffsetMsec() {
    return this.fxAccountsClient.localtimeOffsetMsec;
  },

  /**
   * Ask the server whether the user's email has been verified
   */
  checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) {
    if (!sessionToken) {
      return Promise.reject(new Error(
        "checkEmailStatus called without a session token"));
    }
    return this.fxAccountsClient.recoveryEmailStatus(sessionToken,
      options).catch(error => this._handleTokenError(error));
  },

  /**
   * Once the user's email is verified, we can request the keys
   */
  fetchKeys: function fetchKeys(keyFetchToken) {
    log.debug("fetchKeys: " + !!keyFetchToken);
    if (logPII) {
      log.debug("fetchKeys - the token is " + keyFetchToken);
    }
    return this.fxAccountsClient.accountKeys(keyFetchToken);
  },

  // set() makes sure that polling is happening, if necessary.
  // get() does not wait for verification, and returns an object even if
  // unverified. The caller of get() must check .verified .
  // The "fxaccounts:onverified" event will fire only when the verified
  // state goes from false to true, so callers must register their observer
  // and then call get(). In particular, it will not fire when the account
  // was found to be verified in a previous boot: if our stored state says
  // the account is verified, the event will never fire. So callers must do:
  //   register notification observer (go)
  //   userdata = get()
  //   if (userdata.verified()) {go()}

  /**
   * Get the user currently signed in to Firefox Accounts.
   *
   * @return Promise
   *        The promise resolves to the credentials object of the signed-in user:
   *        {
   *          email: The user's email address
   *          uid: The user's unique id
   *          sessionToken: Session for the FxA server
   *          kA: An encryption key from the FxA server
   *          kB: An encryption key derived from the user's FxA password
   *          verified: email verification status
   *          authAt: The time (seconds since epoch) that this record was
   *                  authenticated
   *        }
   *        or null if no user is signed in.
   */
  getSignedInUser: function getSignedInUser() {
    let currentState = this.currentAccountState;
    return currentState.getUserAccountData().then(data => {
      if (!data) {
        return null;
      }
      if (!this.isUserEmailVerified(data)) {
        // If the email is not verified, start polling for verification,
        // but return null right away.  We don't want to return a promise
        // that might not be fulfilled for a long time.
        this.startVerifiedCheck(data);
      }
      return data;
    }).then(result => currentState.resolve(result));
  },

  /**
   * Set the current user signed in to Firefox Accounts.
   *
   * @param credentials
   *        The credentials object obtained by logging in or creating
   *        an account on the FxA server:
   *        {
   *          authAt: The time (seconds since epoch) that this record was
   *                  authenticated
   *          email: The users email address
   *          keyFetchToken: a keyFetchToken which has not yet been used
   *          sessionToken: Session for the FxA server
   *          uid: The user's unique id
   *          unwrapBKey: used to unwrap kB, derived locally from the
   *                      password (not revealed to the FxA server)
   *          verified: true/false
   *        }
   * @return Promise
   *         The promise resolves to null when the data is saved
   *         successfully and is rejected on error.
   */
  setSignedInUser: function setSignedInUser(credentials) {
    log.debug("setSignedInUser - aborting any existing flows");
    return this.abortExistingFlow().then(() => {
      let currentAccountState = this.currentAccountState = this.newAccountState(
        Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
      );
      // This promise waits for storage, but not for verification.
      // We're telling the caller that this is durable now (although is that
      // really something we should commit to? Why not let the write happen in
      // the background? Already does for updateAccountData ;)
      return currentAccountState.promiseInitialized.then(() => {
        // Starting point for polling if new user
        if (!this.isUserEmailVerified(credentials)) {
          this.startVerifiedCheck(credentials);
        }

        return this.updateDeviceRegistration();
      }).then(() => {
        Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
        this.notifyObservers(ONLOGIN_NOTIFICATION);
      }).then(() => {
        return currentAccountState.resolve();
      });
    })
  },

  /**
   * Update account data for the currently signed in user.
   *
   * @param credentials
   *        The credentials object containing the fields to be updated.
   *        This object must contain |email| and |uid| fields and they must
   *        match the currently signed in user.
   */
  updateUserAccountData(credentials) {
    log.debug("updateUserAccountData called with fields", Object.keys(credentials));
    if (logPII) {
      log.debug("updateUserAccountData called with data", credentials);
    }
    let currentAccountState = this.currentAccountState;
    return currentAccountState.promiseInitialized.then(() => {
      return currentAccountState.getUserAccountData(["email", "uid"]);
    }).then(existing => {
      if (existing.email != credentials.email || existing.uid != credentials.uid) {
        throw new Error("The specified credentials aren't for the current user");
      }
      // We need to nuke email and uid as storage will complain if we try and
      // update them (even when the value is the same)
      credentials = Cu.cloneInto(credentials, {}); // clone it first
      delete credentials.email;
      delete credentials.uid;
      return currentAccountState.updateUserAccountData(credentials);
    });
  },

  /**
   * returns a promise that fires with the assertion.  If there is no verified
   * signed-in user, fires with null.
   */
  getAssertion: function getAssertion(audience) {
    return this._getAssertion(audience);
  },

  // getAssertion() is "public" so screws with our mock story. This
  // implementation method *can* be (and is) mocked by tests.
  _getAssertion: function _getAssertion(audience) {
    log.debug("enter getAssertion()");
    let currentState = this.currentAccountState;
    return currentState.getUserAccountData().then(data => {
      if (!data) {
        // No signed-in user
        return null;
      }
      if (!this.isUserEmailVerified(data)) {
        // Signed-in user has not verified email
        return null;
      }
      if (!data.sessionToken) {
        // can't get a signed certificate without a session token. This
        // can happen if we request an assertion after clearing an invalid
        // session token from storage.
        throw this._error(ERROR_AUTH_ERROR, "getAssertion called without a session token");
      }
      return this.getKeypairAndCertificate(currentState).then(
        ({keyPair, certificate}) => {
          return this.getAssertionFromCert(data, keyPair, certificate, audience);
        }
      );
    }).catch(err =>
      this._handleTokenError(err)
    ).then(result => currentState.resolve(result));
  },

  /**
   * Invalidate the FxA certificate, so that it will be refreshed from the server
   * the next time it is needed.
   */
  invalidateCertificate() {
    return this.currentAccountState.updateUserAccountData({ cert: null });
  },

  getDeviceId() {
    return this.currentAccountState.getUserAccountData()
      .then(data => {
        if (data) {
          if (!data.deviceId || !data.deviceRegistrationVersion ||
              data.deviceRegistrationVersion < this.DEVICE_REGISTRATION_VERSION) {
            // There is no device id or the device registration is outdated.
            // Either way, we should register the device with FxA
            // before returning the id to the caller.
            return this._registerOrUpdateDevice(data);
          }

          // Return the device id that we already registered with the server.
          return data.deviceId;
        }

        // Without a signed-in user, there can be no device id.
        return null;
      });
  },

  /**
   * Resend the verification email fot the currently signed-in user.
   *
   */
  resendVerificationEmail: function resendVerificationEmail() {
    let currentState = this.currentAccountState;
    return this.getSignedInUser().then(data => {
      // If the caller is asking for verification to be re-sent, and there is
      // no signed-in user to begin with, this is probably best regarded as an
      // error.
      if (data) {
        if (!data.sessionToken) {
          return Promise.reject(new Error(
            "resendVerificationEmail called without a session token"));
        }
        this.pollEmailStatus(currentState, data.sessionToken, "start");
        return this.fxAccountsClient.resendVerificationEmail(
          data.sessionToken).catch(err => this._handleTokenError(err));
      }
      throw new Error("Cannot resend verification email; no signed-in user");
    });
  },

  /*
   * Reset state such that any previous flow is canceled.
   */
  abortExistingFlow: function abortExistingFlow() {
    if (this.currentTimer) {
      log.debug("Polling aborted; Another user signing in");
      clearTimeout(this.currentTimer);
      this.currentTimer = 0;
    }
    if (this._profile) {
      this._profile.tearDown();
      this._profile = null;
    }
    // We "abort" the accountState and assume our caller is about to throw it
    // away and replace it with a new one.
    return this.currentAccountState.abort();
  },

  accountStatus: function accountStatus() {
    return this.currentAccountState.getUserAccountData().then(data => {
      if (!data) {
        return false;
      }
      return this.fxAccountsClient.accountStatus(data.uid);
    });
  },

  checkVerificationStatus: function() {
    log.trace('checkVerificationStatus');
    let currentState = this.currentAccountState;
    return currentState.getUserAccountData().then(data => {
      if (!data) {
        log.trace("checkVerificationStatus - no user data");
        return null;
      }

      // Always check the verification status, even if the local state indicates
      // we're already verified. If the user changed their password, the check
      // will fail, and we'll enter the reauth state.
      log.trace("checkVerificationStatus - forcing verification status check");
      return this.pollEmailStatus(currentState, data.sessionToken, "push");
    });
  },

  _destroyOAuthToken: function(tokenData) {
    let client = new FxAccountsOAuthGrantClient({
      serverURL: tokenData.server,
      client_id: FX_OAUTH_CLIENT_ID
    });
    return client.destroyToken(tokenData.token)
  },

  _destroyAllOAuthTokens: function(tokenInfos) {
    // let's just destroy them all in parallel...
    let promises = [];
    for (let [key, tokenInfo] of Object.entries(tokenInfos || {})) {
      promises.push(this._destroyOAuthToken(tokenInfo));
    }
    return Promise.all(promises);
  },

  signOut: function signOut(localOnly) {
    let currentState = this.currentAccountState;
    let sessionToken;
    let tokensToRevoke;
    let deviceId;
    return currentState.getUserAccountData().then(data => {
      // Save the session token, tokens to revoke and the
      // device id for use in the call to signOut below.
      if (data) {
        sessionToken = data.sessionToken;
        tokensToRevoke = data.oauthTokens;
        deviceId = data.deviceId;
      }
      return this._signOutLocal();
    }).then(() => {
      // FxAccountsManager calls here, then does its own call
      // to FxAccountsClient.signOut().
      if (!localOnly) {
        // Wrap this in a promise so *any* errors in signOut won't
        // block the local sign out. This is *not* returned.
        Promise.resolve().then(() => {
          // This can happen in the background and shouldn't block
          // the user from signing out. The server must tolerate
          // clients just disappearing, so this call should be best effort.
          if (sessionToken) {
            return this._signOutServer(sessionToken, deviceId);
          }
          log.warn("Missing session token; skipping remote sign out");
        }).catch(err => {
          log.error("Error during remote sign out of Firefox Accounts", err);
        }).then(() => {
          return this._destroyAllOAuthTokens(tokensToRevoke);
        }).catch(err => {
          log.error("Error during destruction of oauth tokens during signout", err);
        }).then(() => {
          FxAccountsConfig.resetConfigURLs();
          // just for testing - notifications are cheap when no observers.
          this.notifyObservers("testhelper-fxa-signout-complete");
        })
      } else {
        // We want to do this either way -- but if we're signing out remotely we
        // need to wait until we destroy the oauth tokens if we want that to succeed.
        FxAccountsConfig.resetConfigURLs();
      }
    }).then(() => {
      this.notifyObservers(ONLOGOUT_NOTIFICATION);
    });
  },

  /**
   * This function should be called in conjunction with a server-side
   * signOut via FxAccountsClient.
   */
  _signOutLocal: function signOutLocal() {
    let currentAccountState = this.currentAccountState;
    return currentAccountState.signOut().then(() => {
      // this "aborts" this.currentAccountState but doesn't make a new one.
      return this.abortExistingFlow();
    }).then(() => {
      this.currentAccountState = this.newAccountState();
      return this.currentAccountState.promiseInitialized;
    });
  },

  _signOutServer(sessionToken, deviceId) {
    // For now we assume the service being logged out from is Sync, so
    // we must tell the server to either destroy the device or sign out
    // (if no device exists). We might need to revisit this when this
    // FxA code is used in a context that isn't Sync.

    const options = { service: "sync" };

    if (deviceId) {
      log.debug("destroying device and session");
      return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options);
    }

    log.debug("destroying session");
    return this.fxAccountsClient.signOut(sessionToken, options);
  },

  /**
   * Check the status of the current session using cached credentials.
   *
   * @return Promise
   *        Resolves with a boolean indicating if the session is still valid
   */
  sessionStatus() {
    return this.getSignedInUser().then(data => {
      if (!data.sessionToken) {
        return Promise.reject(new Error(
          "sessionStatus called without a session token"));
      }
      return this.fxAccountsClient.sessionStatus(data.sessionToken);
    });
  },

  /**
   * Fetch encryption keys for the signed-in-user from the FxA API server.
   *
   * Not for user consumption.  Exists to cause the keys to be fetch.
   *
   * Returns user data so that it can be chained with other methods.
   *
   * @return Promise
   *        The promise resolves to the credentials object of the signed-in user:
   *        {
   *          email: The user's email address
   *          uid: The user's unique id
   *          sessionToken: Session for the FxA server
   *          kA: An encryption key from the FxA server
   *          kB: An encryption key derived from the user's FxA password
   *          verified: email verification status
   *        }
   *        or null if no user is signed in
   */
  getKeys: function() {
    let currentState = this.currentAccountState;
    return currentState.getUserAccountData().then((userData) => {
      if (!userData) {
        throw new Error("Can't get keys; User is not signed in");
      }
      if (userData.kA && userData.kB) {
        return userData;
      }
      if (!currentState.whenKeysReadyDeferred) {
        currentState.whenKeysReadyDeferred = Promise.defer();
        if (userData.keyFetchToken) {
          this.fetchAndUnwrapKeys(userData.keyFetchToken).then(
            (dataWithKeys) => {
              if (!dataWithKeys.kA || !dataWithKeys.kB) {
                currentState.whenKeysReadyDeferred.reject(
                  new Error("user data missing kA or kB")
                );
                return;
              }
              currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
            },
            (err) => {
              currentState.whenKeysReadyDeferred.reject(err);
            }
          );
        } else {
          currentState.whenKeysReadyDeferred.reject('No keyFetchToken');
        }
      }
      return currentState.whenKeysReadyDeferred.promise;
    }).catch(err =>
      this._handleTokenError(err)
    ).then(result => currentState.resolve(result));
   },

  fetchAndUnwrapKeys: function(keyFetchToken) {
    if (logPII) {
      log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken);
    }
    let currentState = this.currentAccountState;
    return Task.spawn(function* task() {
      // Sign out if we don't have a key fetch token.
      if (!keyFetchToken) {
        log.warn("improper fetchAndUnwrapKeys() call: token missing");
        yield this.signOut();
        return null;
      }

      let {kA, wrapKB} = yield this.fetchKeys(keyFetchToken);

      let data = yield currentState.getUserAccountData();

      // Sanity check that the user hasn't changed out from under us
      if (data.keyFetchToken !== keyFetchToken) {
        throw new Error("Signed in user changed while fetching keys!");
      }

      // Next statements must be synchronous until we setUserAccountData
      // so that we don't risk getting into a weird state.
      let kB_hex = CryptoUtils.xor(CommonUtils.hexToBytes(data.unwrapBKey),
                                   wrapKB);

      if (logPII) {
        log.debug("kB_hex: " + kB_hex);
      }
      let updateData = {
        kA: CommonUtils.bytesAsHex(kA),
        kB: CommonUtils.bytesAsHex(kB_hex),
        keyFetchToken: null, // null values cause the item to be removed.
        unwrapBKey: null,
      }

      log.debug("Keys Obtained: kA=" + !!updateData.kA + ", kB=" + !!updateData.kB);
      if (logPII) {
        log.debug("Keys Obtained: kA=" + updateData.kA + ", kB=" + updateData.kB);
      }

      yield currentState.updateUserAccountData(updateData);
      // We are now ready for business. This should only be invoked once
      // per setSignedInUser(), regardless of whether we've rebooted since
      // setSignedInUser() was called.
      this.notifyObservers(ONVERIFIED_NOTIFICATION);
      return currentState.getUserAccountData();
    }.bind(this)).then(result => currentState.resolve(result));
  },

  getAssertionFromCert: function(data, keyPair, cert, audience) {
    log.debug("getAssertionFromCert");
    let payload = {};
    let d = Promise.defer();
    let options = {
      duration: ASSERTION_LIFETIME,
      localtimeOffsetMsec: this.localtimeOffsetMsec,
      now: this.now()
    };
    let currentState = this.currentAccountState;
    // "audience" should look like "http://123done.org".
    // The generated assertion will expire in two minutes.
    jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => {
      if (err) {
        log.error("getAssertionFromCert: " + err);
        d.reject(err);
      } else {
        log.debug("getAssertionFromCert returning signed: " + !!signed);
        if (logPII) {
          log.debug("getAssertionFromCert returning signed: " + signed);
        }
        d.resolve(signed);
      }
    });
    return d.promise.then(result => currentState.resolve(result));
  },

  getCertificateSigned: function(sessionToken, serializedPublicKey, lifetime) {
    log.debug("getCertificateSigned: " + !!sessionToken + " " + !!serializedPublicKey);
    if (logPII) {
      log.debug("getCertificateSigned: " + sessionToken + " " + serializedPublicKey);
    }
    return this.fxAccountsClient.signCertificate(
      sessionToken,
      JSON.parse(serializedPublicKey),
      lifetime
    );
  },

  /**
   * returns a promise that fires with {keyPair, certificate}.
   */
  getKeypairAndCertificate: Task.async(function* (currentState) {
    // If the debugging pref to ignore cached authentication credentials is set for Sync,
    // then don't use any cached key pair/certificate, i.e., generate a new
    // one and get it signed.
    // The purpose of this pref is to expedite any auth errors as the result of a
    // expired or revoked FxA session token, e.g., from resetting or changing the FxA
    // password.
    let ignoreCachedAuthCredentials = false;
    try {
      ignoreCachedAuthCredentials = Services.prefs.getBoolPref("services.sync.debug.ignoreCachedAuthCredentials");
    } catch(e) {
      // Pref doesn't exist
    }
    let mustBeValidUntil = this.now() + ASSERTION_USE_PERIOD;
    let accountData = yield currentState.getUserAccountData(["cert", "keyPair", "sessionToken"]);

    let keyPairValid = !ignoreCachedAuthCredentials &&
                       accountData.keyPair &&
                       (accountData.keyPair.validUntil > mustBeValidUntil);
    let certValid = !ignoreCachedAuthCredentials &&
                    accountData.cert &&
                    (accountData.cert.validUntil > mustBeValidUntil);
    // TODO: get the lifetime from the cert's .exp field
    if (keyPairValid && certValid) {
      log.debug("getKeypairAndCertificate: already have keyPair and certificate");
      return {
        keyPair: accountData.keyPair.rawKeyPair,
        certificate: accountData.cert.rawCert
      }
    }
    // We are definately going to generate a new cert, either because it has
    // already expired, or the keyPair has - and a new keyPair means we must
    // generate a new cert.

    // A keyPair has a longer lifetime than a cert, so it's possible we will
    // have a valid keypair but an expired cert, which means we can skip
    // keypair generation.
    // Either way, the cert will require hitting the network, so bail now if
    // we know that's going to fail.
    if (Services.io.offline) {
      throw new Error(ERROR_OFFLINE);
    }

    let keyPair;
    if (keyPairValid) {
      keyPair = accountData.keyPair;
    } else {
      let keyWillBeValidUntil = this.now() + KEY_LIFETIME;
      keyPair = yield new Promise((resolve, reject) => {
        jwcrypto.generateKeyPair("DS160", (err, kp) => {
          if (err) {
            return reject(err);
          }
          log.debug("got keyPair");
          resolve({
            rawKeyPair: kp,
            validUntil: keyWillBeValidUntil,
          });
        });
      });
    }

    // and generate the cert.
    let certWillBeValidUntil = this.now() + CERT_LIFETIME;
    let certificate = yield this.getCertificateSigned(accountData.sessionToken,
                                                      keyPair.rawKeyPair.serializedPublicKey,
                                                      CERT_LIFETIME);
    log.debug("getCertificate got a new one: " + !!certificate);
    if (certificate) {
      // Cache both keypair and cert.
      let toUpdate = {
        keyPair,
        cert: {
          rawCert: certificate,
          validUntil: certWillBeValidUntil,
        },
      };
      yield currentState.updateUserAccountData(toUpdate);
    }
    return {
      keyPair: keyPair.rawKeyPair,
      certificate: certificate,
    }
  }),

  getUserAccountData: function() {
    return this.currentAccountState.getUserAccountData();
  },

  isUserEmailVerified: function isUserEmailVerified(data) {
    return !!(data && data.verified);
  },

  /**
   * Setup for and if necessary do email verification polling.
   */
  loadAndPoll: function() {
    let currentState = this.currentAccountState;
    return currentState.getUserAccountData()
      .then(data => {
        if (data) {
          Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
          if (!this.isUserEmailVerified(data)) {
            this.pollEmailStatus(currentState, data.sessionToken, "start");
          }
        }
        return data;
      });
  },

  startVerifiedCheck: function(data) {
    log.debug("startVerifiedCheck", data && data.verified);
    if (logPII) {
      log.debug("startVerifiedCheck with user data", data);
    }

    // Get us to the verified state, then get the keys. This returns a promise
    // that will fire when we are completely ready.
    //
    // Login is truly complete once keys have been fetched, so once getKeys()
    // obtains and stores kA and kB, it will fire the onverified observer
    // notification.

    // The callers of startVerifiedCheck never consume a returned promise (ie,
    // this is simply kicking off a background fetch) so we must add a rejection
    // handler to avoid runtime warnings about the rejection not being handled.
    this.whenVerified(data).then(
      () => this.getKeys(),
      err => log.info("startVerifiedCheck promise was rejected: " + err)
    );
  },

  whenVerified: function(data) {
    let currentState = this.currentAccountState;
    if (data.verified) {
      log.debug("already verified");
      return currentState.resolve(data);
    }
    if (!currentState.whenVerifiedDeferred) {
      log.debug("whenVerified promise starts polling for verified email");
      this.pollEmailStatus(currentState, data.sessionToken, "start");
    }
    return currentState.whenVerifiedDeferred.promise.then(
      result => currentState.resolve(result)
    );
  },

  notifyObservers: function(topic, data) {
    log.debug("Notifying observers of " + topic);
    Services.obs.notifyObservers(null, topic, data);
  },

  // XXX - pollEmailStatus should maybe be on the AccountState object?
  pollEmailStatus: function pollEmailStatus(currentState, sessionToken, why) {
    log.debug("entering pollEmailStatus: " + why);
    if (why == "start" || why == "push") {
      if (this.currentTimer) {
        log.debug("pollEmailStatus starting while existing timer is running");
        clearTimeout(this.currentTimer);
        this.currentTimer = null;
      }

      // If we were already polling, stop and start again.  This could happen
      // if the user requested the verification email to be resent while we
      // were already polling for receipt of an earlier email.
      this.pollStartDate = Date.now();
      if (!currentState.whenVerifiedDeferred) {
        currentState.whenVerifiedDeferred = Promise.defer();
        // This deferred might not end up with any handlers (eg, if sync
        // is yet to start up.)  This might cause "A promise chain failed to
        // handle a rejection" messages, so add an error handler directly
        // on the promise to log the error.
        currentState.whenVerifiedDeferred.promise.then(null, err => {
          log.info("the wait for user verification was stopped: " + err);
        });
      }
    }

    // We return a promise for testing only. Other callers can ignore this,
    // since verification polling continues in the background.
    return this.checkEmailStatus(sessionToken, { reason: why })
      .then((response) => {
        log.debug("checkEmailStatus -> " + JSON.stringify(response));
        if (response && response.verified) {
          currentState.updateUserAccountData({ verified: true })
            .then(() => {
              return currentState.getUserAccountData();
            })
            .then(data => {
              // Now that the user is verified, we can proceed to fetch keys
              if (currentState.whenVerifiedDeferred) {
                currentState.whenVerifiedDeferred.resolve(data);
                delete currentState.whenVerifiedDeferred;
              }
              // Tell FxAccountsManager to clear its cache
              this.notifyObservers(ON_FXA_UPDATE_NOTIFICATION, ONVERIFIED_NOTIFICATION);
            });
        } else {
          // Poll email status again after a short delay.
          this.pollEmailStatusAgain(currentState, sessionToken);
        }
      }, error => {
        let timeoutMs = undefined;
        if (error && error.retryAfter) {
          // If the server told us to back off, back off the requested amount.
          timeoutMs = (error.retryAfter + 3) * 1000;
        }
        // The server will return 401 if a request parameter is erroneous or
        // if the session token expired. Let's continue polling otherwise.
        if (!error || !error.code || error.code != 401) {
          this.pollEmailStatusAgain(currentState, sessionToken, timeoutMs);
        } else {
          let error = new Error("Verification status check failed");
          this._rejectWhenVerified(currentState, error);
        }
      });
  },

  _rejectWhenVerified(currentState, error) {
    currentState.whenVerifiedDeferred.reject(error);
    delete currentState.whenVerifiedDeferred;
  },

  // Poll email status using truncated exponential back-off.
  pollEmailStatusAgain: function (currentState, sessionToken, timeoutMs) {
    let ageMs = Date.now() - this.pollStartDate;
    if (ageMs >= this.POLL_SESSION) {
      if (currentState.whenVerifiedDeferred) {
        let error = new Error("User email verification timed out.");
        this._rejectWhenVerified(currentState, error);
      }
      log.debug("polling session exceeded, giving up");
      return;
    }
    if (timeoutMs === undefined) {
      let currentMinute = Math.ceil(ageMs / 60000);
      timeoutMs = currentMinute <= 2 ? this.VERIFICATION_POLL_TIMEOUT_INITIAL
                                     : this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
    }
    log.debug("polling with timeout = " + timeoutMs);
    this.currentTimer = setTimeout(() => {
      this.pollEmailStatus(currentState, sessionToken, "timer");
    }, timeoutMs);
  },

  requiresHttps: function() {
    let allowHttp = false;
    try {
      allowHttp = Services.prefs.getBoolPref("identity.fxaccounts.allowHttp");
    } catch(e) {
      // Pref doesn't exist
    }
    return allowHttp !== true;
  },

  promiseAccountsSignUpURI() {
    return FxAccountsConfig.promiseAccountsSignUpURI();
  },

  promiseAccountsSignInURI() {
    return FxAccountsConfig.promiseAccountsSignInURI();
  },

  // Returns a promise that resolves with the URL to use to force a re-signin
  // of the current account.
  promiseAccountsForceSigninURI: Task.async(function *() {
    yield FxAccountsConfig.ensureConfigured();
    let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.force_auth.uri");
    if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
      throw new Error("Firefox Accounts server must use HTTPS");
    }
    let currentState = this.currentAccountState;
    // but we need to append the email address onto a query string.
    return this.getSignedInUser().then(accountData => {
      if (!accountData) {
        return null;
      }
      let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
      newQueryPortion += "email=" + encodeURIComponent(accountData.email);
      return url + newQueryPortion;
    }).then(result => currentState.resolve(result));
  }),

  // Returns a promise that resolves with the URL to use to change
  // the current account's profile image.
  // if settingToEdit is set, the profile page should hightlight that setting
  // for the user to edit.
  promiseAccountsChangeProfileURI: function(entrypoint, settingToEdit = null) {
    let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");

    if (settingToEdit) {
      url += (url.indexOf("?") == -1 ? "?" : "&") +
             "setting=" + encodeURIComponent(settingToEdit);
    }

    if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
      throw new Error("Firefox Accounts server must use HTTPS");
    }
    let currentState = this.currentAccountState;
    // but we need to append the email address onto a query string.
    return this.getSignedInUser().then(accountData => {
      if (!accountData) {
        return null;
      }
      let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
      newQueryPortion += "email=" + encodeURIComponent(accountData.email);
      newQueryPortion += "&uid=" + encodeURIComponent(accountData.uid);
      if (entrypoint) {
        newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint);
      }
      return url + newQueryPortion;
    }).then(result => currentState.resolve(result));
  },

  // Returns a promise that resolves with the URL to use to manage the current
  // user's FxA acct.
  promiseAccountsManageURI: function(entrypoint) {
    let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");
    if (this.requiresHttps() && !/^https:/.test(url)) { // Comment to un-break emacs js-mode highlighting
      throw new Error("Firefox Accounts server must use HTTPS");
    }
    let currentState = this.currentAccountState;
    // but we need to append the uid and email address onto a query string
    // (if the server has no matching uid it will offer to sign in with the
    // email address)
    return this.getSignedInUser().then(accountData => {
      if (!accountData) {
        return null;
      }
      let newQueryPortion = url.indexOf("?") == -1 ? "?" : "&";
      newQueryPortion += "uid=" + encodeURIComponent(accountData.uid) +
                         "&email=" + encodeURIComponent(accountData.email);
      if (entrypoint) {
        newQueryPortion += "&entrypoint=" + encodeURIComponent(entrypoint);
      }
      return url + newQueryPortion;
    }).then(result => currentState.resolve(result));
  },

  /**
   * Get an OAuth token for the user
   *
   * @param options
   *        {
   *          scope: (string/array) the oauth scope(s) being requested. As a
   *                 convenience, you may pass a string if only one scope is
   *                 required, or an array of strings if multiple are needed.
   *        }
   *
   * @return Promise.<string | Error>
   *        The promise resolves the oauth token as a string or rejects with
   *        an error object ({error: ERROR, details: {}}) of the following:
   *          INVALID_PARAMETER
   *          NO_ACCOUNT
   *          UNVERIFIED_ACCOUNT
   *          NETWORK_ERROR
   *          AUTH_ERROR
   *          UNKNOWN_ERROR
   */
  getOAuthToken: Task.async(function* (options = {}) {
    log.debug("getOAuthToken enter");
    let scope = options.scope;
    if (typeof scope === "string") {
      scope = [scope];
    }

    if (!scope || !scope.length) {
      throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'scope' option");
    }

    yield this._getVerifiedAccountOrReject();

    // Early exit for a cached token.
    let currentState = this.currentAccountState;
    let cached = currentState.getCachedToken(scope);
    if (cached) {
      log.debug("getOAuthToken returning a cached token");
      return cached.token;
    }

    // We are going to hit the server - this is the string we pass to it.
    let scopeString = scope.join(" ");
    let client = options.client;

    if (!client) {
      try {
        let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
        client = new FxAccountsOAuthGrantClient({
          serverURL: defaultURL,
          client_id: FX_OAUTH_CLIENT_ID
        });
      } catch (e) {
        throw this._error(ERROR_INVALID_PARAMETER, e);
      }
    }
    let oAuthURL = client.serverURL.href;

    try {
      log.debug("getOAuthToken fetching new token from", oAuthURL);
      let assertion = yield this.getAssertion(oAuthURL);
      let result = yield client.getTokenFromAssertion(assertion, scopeString);
      let token = result.access_token;
      // If we got one, cache it.
      if (token) {
        let entry = {token: token, server: oAuthURL};
        // But before we do, check the cache again - if we find one now, it
        // means someone else concurrently requested the same scope and beat
        // us to the cache write. To be nice to the server, we revoke the one
        // we just got and return the newly cached value.
        let cached = currentState.getCachedToken(scope);
        if (cached) {
          log.debug("Detected a race for this token - revoking the new one.");
          this._destroyOAuthToken(entry);
          return cached.token;
        }
        currentState.setCachedToken(scope, entry);
      }
      return token;
    } catch (err) {
      throw this._errorToErrorClass(err);
    }
  }),

  /**
   * Remove an OAuth token from the token cache.  Callers should call this
   * after they determine a token is invalid, so a new token will be fetched
   * on the next call to getOAuthToken().
   *
   * @param options
   *        {
   *          token: (string) A previously fetched token.
   *        }
   * @return Promise.<undefined> This function will always resolve, even if
   *         an unknown token is passed.
   */
   removeCachedOAuthToken: Task.async(function* (options) {
    if (!options.token || typeof options.token !== "string") {
      throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'token' option");
    }
    let currentState = this.currentAccountState;
    let existing = currentState.removeCachedToken(options.token);
    if (existing) {
      // background destroy.
      this._destroyOAuthToken(existing).catch(err => {
        log.warn("FxA failed to revoke a cached token", err);
      });
    }
   }),

  _getVerifiedAccountOrReject: Task.async(function* () {
    let data = yield this.currentAccountState.getUserAccountData();
    if (!data) {
      // No signed-in user
      throw this._error(ERROR_NO_ACCOUNT);
    }
    if (!this.isUserEmailVerified(data)) {
      // Signed-in user has not verified email
      throw this._error(ERROR_UNVERIFIED_ACCOUNT);
    }
  }),

  /*
   * Coerce an error into one of the general error cases:
   *          NETWORK_ERROR
   *          AUTH_ERROR
   *          UNKNOWN_ERROR
   *
   * These errors will pass through:
   *          INVALID_PARAMETER
   *          NO_ACCOUNT
   *          UNVERIFIED_ACCOUNT
   */
  _errorToErrorClass: function (aError) {
    if (aError.errno) {
      let error = SERVER_ERRNO_TO_ERROR[aError.errno];
      return this._error(ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, aError);
    } else if (aError.message &&
        (aError.message === "INVALID_PARAMETER" ||
        aError.message === "NO_ACCOUNT" ||
        aError.message === "UNVERIFIED_ACCOUNT" ||
        aError.message === "AUTH_ERROR")) {
      return aError;
    }
    return this._error(ERROR_UNKNOWN, aError);
  },

  _error: function(aError, aDetails) {
    log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {aError, aDetails});
    let reason = new Error(aError);
    if (aDetails) {
      reason.details = aDetails;
    }
    return reason;
  },

  /**
   * Get the user's account and profile data
   *
   * @param options
   *        {
   *          contentUrl: (string) Used by the FxAccountsWebChannel.
   *            Defaults to pref identity.fxaccounts.settings.uri
   *          profileServerUrl: (string) Used by the FxAccountsWebChannel.
   *            Defaults to pref identity.fxaccounts.remote.profile.uri
   *        }
   *
   * @return Promise.<object | Error>
   *        The promise resolves to an accountData object with extra profile
   *        information such as profileImageUrl, or rejects with
   *        an error object ({error: ERROR, details: {}}) of the following:
   *          INVALID_PARAMETER
   *          NO_ACCOUNT
   *          UNVERIFIED_ACCOUNT
   *          NETWORK_ERROR
   *          AUTH_ERROR
   *          UNKNOWN_ERROR
   */
  getSignedInUserProfile: function () {
    let currentState = this.currentAccountState;
    return this.profile.getProfile().then(
      profileData => {
        let profile = Cu.cloneInto(profileData, {});
        return currentState.resolve(profile);
      },
      error => {
        log.error("Could not retrieve profile data", error);
        return currentState.reject(error);
      }
    ).catch(err => Promise.reject(this._errorToErrorClass(err)));
  },

  // Attempt to update the auth server with whatever device details are stored
  // in the account data. Returns a promise that always resolves, never rejects.
  // If the promise resolves to a value, that value is the device id.
  updateDeviceRegistration() {
    return this.getSignedInUser().then(signedInUser => {
      if (signedInUser) {
        return this._registerOrUpdateDevice(signedInUser);
      }
    }).catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
  },

  handleDeviceDisconnection(deviceId) {
    return this.currentAccountState.getUserAccountData()
      .then(data => data ? data.deviceId : null)
      .then(localDeviceId => {
        if (deviceId == localDeviceId) {
          this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, deviceId);
          return this.signOut(true);
        }
        log.error(
          "The device ID to disconnect doesn't match with the local device ID.\n"
          + "Local: " + localDeviceId + ", ID to disconnect: " + deviceId);
    });
  },

  /**
   * Delete all the cached persisted credentials we store for FxA.
   *
   * @return Promise resolves when the user data has been persisted
  */
  resetCredentials() {
    // Delete all fields except those required for the user to
    // reauthenticate.
    let updateData = {};
    let clearField = field => {
      if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
        updateData[field] = null;
      }
    }
    FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
    FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
    FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);

    let currentState = this.currentAccountState;
    return currentState.updateUserAccountData(updateData);
  },

  // If you change what we send to the FxA servers during device registration,
  // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
  // devices to re-register when Firefox updates
  _registerOrUpdateDevice(signedInUser) {
    try {
      // Allow tests to skip device registration because:
      //   1. It makes remote requests to the auth server.
      //   2. _getDeviceName does not work from xpcshell.
      //   3. The B2G tests fail when attempting to import services-sync/util.js.
      if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
        return Promise.resolve();
      }
    } catch(ignore) {}

    if (!signedInUser.sessionToken) {
      return Promise.reject(new Error(
        "_registerOrUpdateDevice called without a session token"));
    }

    return this.fxaPushService.registerPushEndpoint().then(subscription => {
      const deviceName = this._getDeviceName();
      let deviceOptions = {};

      // if we were able to obtain a subscription
      if (subscription && subscription.endpoint) {
        deviceOptions.pushCallback = subscription.endpoint;
        let publicKey = subscription.getKey('p256dh');
        let authKey = subscription.getKey('auth');
        if (publicKey && authKey) {
          deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
          deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
        }
      }

      if (signedInUser.deviceId) {
        log.debug("updating existing device details");
        return this.fxAccountsClient.updateDevice(
          signedInUser.sessionToken, signedInUser.deviceId, deviceName, deviceOptions);
      }

      log.debug("registering new device details");
      return this.fxAccountsClient.registerDevice(
        signedInUser.sessionToken, deviceName, this._getDeviceType(), deviceOptions);
    }).then(device =>
      this.currentAccountState.updateUserAccountData({
        deviceId: device.id,
        deviceRegistrationVersion: this.DEVICE_REGISTRATION_VERSION
      }).then(() => device.id)
    ).catch(error => this._handleDeviceError(error, signedInUser.sessionToken));
  },

  _getDeviceName() {
    return Utils.getDeviceName();
  },

  _getDeviceType() {
    return Utils.getDeviceType();
  },

  _handleDeviceError(error, sessionToken) {
    return Promise.resolve().then(() => {
      if (error.code === 400) {
        if (error.errno === ERRNO_UNKNOWN_DEVICE) {
          return this._recoverFromUnknownDevice();
        }

        if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
          return this._recoverFromDeviceSessionConflict(error, sessionToken);
        }
      }

      // `_handleTokenError` re-throws the error.
      return this._handleTokenError(error);
    }).catch(error =>
      this._logErrorAndResetDeviceRegistrationVersion(error)
    ).catch(() => {});
  },

  _recoverFromUnknownDevice() {
    // FxA did not recognise the device id. Handle it by clearing the device
    // id on the account data. At next sync or next sign-in, registration is
    // retried and should succeed.
    log.warn("unknown device id, clearing the local device data");
    return this.currentAccountState.updateUserAccountData({ deviceId: null })
      .catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
  },

  _recoverFromDeviceSessionConflict(error, sessionToken) {
    // FxA has already associated this session with a different device id.
    // Perhaps we were beaten in a race to register. Handle the conflict:
    //   1. Fetch the list of devices for the current user from FxA.
    //   2. Look for ourselves in the list.
    //   3. If we find a match, set the correct device id and device registration
    //      version on the account data and return the correct device id. At next
    //      sync or next sign-in, registration is retried and should succeed.
    //   4. If we don't find a match, log the original error.
    log.warn("device session conflict, attempting to ascertain the correct device id");
    return this.fxAccountsClient.getDeviceList(sessionToken)
      .then(devices => {
        const matchingDevices = devices.filter(device => device.isCurrentDevice);
        const length = matchingDevices.length;
        if (length === 1) {
          const deviceId = matchingDevices[0].id;
          return this.currentAccountState.updateUserAccountData({
            deviceId,
            deviceRegistrationVersion: null
          }).then(() => deviceId);
        }
        if (length > 1) {
          log.error("insane server state, " + length + " devices for this session");
        }
        return this._logErrorAndResetDeviceRegistrationVersion(error);
      }).catch(secondError => {
        log.error("failed to recover from device-session conflict", secondError);
        this._logErrorAndResetDeviceRegistrationVersion(error)
      });
  },

  _logErrorAndResetDeviceRegistrationVersion(error) {
    // Device registration should never cause other operations to fail.
    // If we've reached this point, just log the error and reset the device
    // registration version on the account data. At next sync or next sign-in,
    // registration will be retried.
    log.error("device registration failed", error);
    return this.currentAccountState.updateUserAccountData({
      deviceRegistrationVersion: null
    }).catch(secondError => {
      log.error(
        "failed to reset the device registration version, device registration won't be retried",
        secondError);
    }).then(() => {});
  },

  _handleTokenError(err) {
    if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
      throw err;
    }
    log.warn("recovering from invalid token error", err);
    return this.accountStatus().then(exists => {
      if (!exists) {
        // Delete all local account data. Since the account no longer
        // exists, we can skip the remote calls.
        log.info("token invalidated because the account no longer exists");
        return this.signOut(true);
      }
      log.info("clearing credentials to handle invalid token error");
      return this.resetCredentials();
    }).then(() => Promise.reject(err));
  },
};


// A getter for the instance to export
XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
  let a = new FxAccounts();

  // XXX Bug 947061 - We need a strategy for resuming email verification after
  // browser restart
  a.loadAndPoll();

  return a;
});
