/* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil; -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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";

const Cu = Components.utils;
const Ci = Components.interfaces;
const Cc = Components.classes;
const Cr = Components.results;

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

const EXPORTED_SYMBOLS = ["RelyingParty"];

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

function log(...aMessageArgs) {
  Logger.log.apply(Logger, ["RP"].concat(aMessageArgs));
}
function reportError(...aMessageArgs) {
  Logger.reportError.apply(Logger, ["RP"].concat(aMessageArgs));
}

function IdentityRelyingParty() {
  // The store is a singleton shared among Identity, RelyingParty, and
  // IdentityProvider.  The Identity module takes care of resetting
  // state in the _store on shutdown.
  this._store = IdentityStore;

  this.reset();
}

IdentityRelyingParty.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),

  observe: function observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case "quit-application-granted":
        Services.obs.removeObserver(this, "quit-application-granted");
        this.shutdown();
        break;

    }
  },

  reset: function RP_reset() {
    // Forget all documents that call in.  (These are sometimes
    // referred to as callers.)
    this._rpFlows = {};
  },

  shutdown: function RP_shutdown() {
    this.reset();
    Services.obs.removeObserver(this, "quit-application-granted");
  },

  /**
   * Register a listener for a given windowID as a result of a call to
   * navigator.id.watch().
   *
   * @param aCaller
   *        (Object)  an object that represents the caller document, and
   *                  is expected to have properties:
   *                  - id (unique, e.g. uuid)
   *                  - loggedInEmail (string or null)
   *                  - origin (string)
   *
   *                  and a bunch of callbacks
   *                  - doReady()
   *                  - doLogin()
   *                  - doLogout()
   *                  - doError()
   *                  - doCancel()
   *
   */
  watch: function watch(aRpCaller) {
    this._rpFlows[aRpCaller.id] = aRpCaller;
    let origin = aRpCaller.origin;
    let state = this._store.getLoginState(origin) || { isLoggedIn: false, email: null };

    log("watch: rpId:", aRpCaller.id,
        "origin:", origin,
        "loggedInEmail:", aRpCaller.loggedInEmail,
        "loggedIn:", state.isLoggedIn,
        "email:", state.email);

    // If the user is already logged in, then there are three cases
    // to deal with:
    //
    //   1. the email is valid and unchanged:  'ready'
    //   2. the email is null:                 'login'; 'ready'
    //   3. the email has changed:             'login'; 'ready'
    if (state.isLoggedIn) {
      if (state.email && aRpCaller.loggedInEmail === state.email) {
        this._notifyLoginStateChanged(aRpCaller.id, state.email);
        return aRpCaller.doReady();

      } else if (aRpCaller.loggedInEmail === null) {
        // Generate assertion for existing login
        let options = {loggedInEmail: state.email, origin: origin};
        return this._doLogin(aRpCaller, options);

      } else {
        // A loggedInEmail different from state.email has been specified.
        // Change login identity.

        let options = {loggedInEmail: state.email, origin: origin};
        return this._doLogin(aRpCaller, options);
      }

    // If the user is not logged in, there are two cases:
    //
    //   1. a logged in email was provided: 'ready'; 'logout'
    //   2. not logged in, no email given:  'ready';

    } else {
      if (aRpCaller.loggedInEmail) {
        return this._doLogout(aRpCaller, {origin: origin});

      } else {
        return aRpCaller.doReady();
      }
    }
  },

  /**
   * A utility for watch() to set state and notify the dom
   * on login
   *
   * Note that this calls _getAssertion
   */
  _doLogin: function _doLogin(aRpCaller, aOptions, aAssertion) {
    log("_doLogin: rpId:", aRpCaller.id, "origin:", aOptions.origin);

    let loginWithAssertion = function loginWithAssertion(assertion) {
      this._store.setLoginState(aOptions.origin, true, aOptions.loggedInEmail);
      this._notifyLoginStateChanged(aRpCaller.id, aOptions.loggedInEmail);
      aRpCaller.doLogin(assertion);
      aRpCaller.doReady();
    }.bind(this);

    if (aAssertion) {
      loginWithAssertion(aAssertion);
    } else {
      this._getAssertion(aOptions, function gotAssertion(err, assertion) {
        if (err) {
          reportError("_doLogin:", "Failed to get assertion on login attempt:", err);
          this._doLogout(aRpCaller);
        } else {
          loginWithAssertion(assertion);
        }
      }.bind(this));
    }
  },

  /**
   * A utility for watch() to set state and notify the dom
   * on logout.
   */
  _doLogout: function _doLogout(aRpCaller, aOptions) {
    log("_doLogout: rpId:", aRpCaller.id, "origin:", aOptions.origin);

    let state = this._store.getLoginState(aOptions.origin) || {};

    state.isLoggedIn = false;
    this._notifyLoginStateChanged(aRpCaller.id, null);

    aRpCaller.doLogout();
    aRpCaller.doReady();
  },

  /**
   * For use with login or logout, emit 'identity-login-state-changed'
   *
   * The notification will send the rp caller id in the properties,
   * and the email of the user in the message.
   *
   * @param aRpCallerId
   *        (integer) The id of the RP caller
   *
   * @param aIdentity
   *        (string) The email of the user whose login state has changed
   */
  _notifyLoginStateChanged: function _notifyLoginStateChanged(aRpCallerId, aIdentity) {
    log("_notifyLoginStateChanged: rpId:", aRpCallerId, "identity:", aIdentity);

    let options = {rpId: aRpCallerId};
    Services.obs.notifyObservers({wrappedJSObject: options},
                                 "identity-login-state-changed",
                                 aIdentity);
  },

  /**
   * Initiate a login with user interaction as a result of a call to
   * navigator.id.request().
   *
   * @param aRPId
   *        (integer)  the id of the doc object obtained in .watch()
   *
   * @param aOptions
   *        (Object)  options including privacyPolicy, termsOfService
   */
  request: function request(aRPId, aOptions) {
    log("request: rpId:", aRPId);

    // Notify UX to display identity picker.
    // Pass the doc id to UX so it can pass it back to us later.
    let options = {rpId: aRPId};

    // Append URLs after resolving
    let rp = this._rpFlows[aRPId];
    let baseURI = Services.io.newURI(rp.origin, null, null);
    for (let optionName of ["privacyPolicy", "termsOfService"]) {
      if (aOptions[optionName]) {
        options[optionName] = baseURI.resolve(aOptions[optionName]);
      }
    }

    Services.obs.notifyObservers({wrappedJSObject: options}, "identity-request", null);
  },

  /**
   * Invoked when a user wishes to logout of a site (for instance, when clicking
   * on an in-content logout button).
   *
   * @param aRpCallerId
   *        (integer)  the id of the doc object obtained in .watch()
   *
   */
  logout: function logout(aRpCallerId) {
    log("logout: RP caller id:", aRpCallerId);
    let rp = this._rpFlows[aRpCallerId];
    if (rp && rp.origin) {
      let origin = rp.origin;
      log("logout: origin:", origin);
      this._doLogout(rp, {origin: origin});
    } else {
      log("logout: no RP found with id:", aRpCallerId);
    }
    // We don't delete this._rpFlows[aRpCallerId], because
    // the user might log back in again.
  },

  getDefaultEmailForOrigin: function getDefaultEmailForOrigin(aOrigin) {
    let identities = this.getIdentitiesForSite(aOrigin);
    let result = identities.lastUsed || null;
    log("getDefaultEmailForOrigin:", aOrigin, "->", result);
    return result;
  },

  /**
   * Return the list of identities a user may want to use to login to aOrigin.
   */
  getIdentitiesForSite: function getIdentitiesForSite(aOrigin) {
    let rv = { result: [] };
    for (let id in this._store.getIdentities()) {
      rv.result.push(id);
    }
    let loginState = this._store.getLoginState(aOrigin);
    if (loginState && loginState.email)
      rv.lastUsed = loginState.email;
    return rv;
  },

  /**
   * Obtain a BrowserID assertion with the specified characteristics.
   *
   * @param aCallback
   *        (Function) Callback to be called with (err, assertion) where 'err'
   *        can be an Error or NULL, and 'assertion' can be NULL or a valid
   *        BrowserID assertion. If no callback is provided, an exception is
   *        thrown.
   *
   * @param aOptions
   *        (Object) An object that may contain the following properties:
   *
   *          "audience"      : The audience for which the assertion is to be
   *                            issued. If this property is not set an exception
   *                            will be thrown.
   *
   *        Any properties not listed above will be ignored.
   */
  _getAssertion: function _getAssertion(aOptions, aCallback) {
    let audience = aOptions.origin;
    let email = aOptions.loggedInEmail || this.getDefaultEmailForOrigin(audience);
    log("_getAssertion: audience:", audience, "email:", email);
    if (!audience) {
      throw "audience required for _getAssertion";
    }

    // We might not have any identity info for this email
    if (!this._store.fetchIdentity(email)) {
      this._store.addIdentity(email, null, null);
    }

    let cert = this._store.fetchIdentity(email)['cert'];
    if (cert) {
      this._generateAssertion(audience, email, function generatedAssertion(err, assertion) {
        if (err) {
          log("ERROR: _getAssertion:", err);
        }
        log("_getAssertion: generated assertion:", assertion);
        return aCallback(err, assertion);
      });
    }
  },

  /**
   * Generate an assertion, including provisioning via IdP if necessary,
   * but no user interaction, so if provisioning fails, aCallback is invoked
   * with an error.
   *
   * @param aAudience
   *        (string) web origin
   *
   * @param aIdentity
   *        (string) the email we're logging in with
   *
   * @param aCallback
   *        (function) callback to invoke on completion
   *                   with first-positional parameter the error.
   */
  _generateAssertion: function _generateAssertion(aAudience, aIdentity, aCallback) {
    log("_generateAssertion: audience:", aAudience, "identity:", aIdentity);

    let id = this._store.fetchIdentity(aIdentity);
    if (! (id && id.cert)) {
      let errStr = "Cannot generate an assertion without a certificate";
      log("ERROR: _generateAssertion:", errStr);
      aCallback(errStr);
      return;
    }

    let kp = id.keyPair;

    if (!kp) {
      let errStr = "Cannot generate an assertion without a keypair";
      log("ERROR: _generateAssertion:", errStr);
      aCallback(errStr);
      return;
    }

    jwcrypto.generateAssertion(id.cert, kp, aAudience, aCallback);
  },

  /**
   * Clean up references to the provisioning flow for the specified RP.
   */
  _cleanUpProvisionFlow: function RP_cleanUpProvisionFlow(aRPId, aProvId) {
    let rp = this._rpFlows[aRPId];
    if (rp) {
      delete rp['provId'];
    } else {
      log("Error: Couldn't delete provision flow ", aProvId, " for RP ", aRPId);
    }
  },

};

let RelyingParty = new IdentityRelyingParty();
