/* jshint moz:true, browser:true */
/* 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/. */

this.EXPORTED_SYMBOLS = ['PeerConnectionIdp'];

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

Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/XPCOMUtils.jsm');
XPCOMUtils.defineLazyModuleGetter(this, 'IdpSandbox',
  'resource://gre/modules/media/IdpSandbox.jsm');

/**
 * Creates an IdP helper.
 *
 * @param win (object) the window we are working for
 * @param timeout (int) the timeout in milliseconds
 */
function PeerConnectionIdp(win, timeout) {
  this._win = win;
  this._timeout = timeout || 5000;

  this.provider = null;
  this._resetAssertion();
}

(function() {
  PeerConnectionIdp._mLinePattern = new RegExp('^m=', 'm');
  // attributes are funny, the 'a' is case sensitive, the name isn't
  let pattern = '^a=[iI][dD][eE][nN][tT][iI][tT][yY]:(\\S+)';
  PeerConnectionIdp._identityPattern = new RegExp(pattern, 'm');
  pattern = '^a=[fF][iI][nN][gG][eE][rR][pP][rR][iI][nN][tT]:(\\S+) (\\S+)';
  PeerConnectionIdp._fingerprintPattern = new RegExp(pattern, 'm');
})();

PeerConnectionIdp.prototype = {
  get enabled() {
    return !!this._idp;
  },

  _resetAssertion: function() {
    this.assertion = null;
    this.idpLoginUrl = null;
  },

  setIdentityProvider: function(provider, protocol, username) {
    this._resetAssertion();
    this.provider = provider;
    this.protocol = protocol || 'default';
    this.username = username;
    if (this._idp) {
      if (this._idp.isSame(provider, protocol)) {
        return; // noop
      }
      this._idp.stop();
    }
    this._idp = new IdpSandbox(provider, protocol, this._win);
  },

  // start the IdP and do some error fixup
  start: function() {
    return this._idp.start()
      .catch(e => {
        throw new this._win.DOMException(e.message, 'IdpError');
      });
  },

  close: function() {
    this._resetAssertion();
    this.provider = null;
    this.protocol = null;
    if (this._idp) {
      this._idp.stop();
      this._idp = null;
    }
  },

  _getFingerprintsFromSdp: function(sdp) {
    let fingerprints = {};
    let m = sdp.match(PeerConnectionIdp._fingerprintPattern);
    while (m) {
      fingerprints[m[0]] = { algorithm: m[1], digest: m[2] };
      sdp = sdp.substring(m.index + m[0].length);
      m = sdp.match(PeerConnectionIdp._fingerprintPattern);
    }

    return Object.keys(fingerprints).map(k => fingerprints[k]);
  },

  _isValidAssertion: function(assertion) {
    return assertion && assertion.idp &&
      typeof assertion.idp.domain === 'string' &&
      (!assertion.idp.protocol ||
       typeof assertion.idp.protocol === 'string') &&
      typeof assertion.assertion === 'string';
  },

  _getIdentityFromSdp: function(sdp) {
    // a=identity is session level
    let idMatch;
    let mLineMatch = sdp.match(PeerConnectionIdp._mLinePattern);
    if (mLineMatch) {
      let sessionLevel = sdp.substring(0, mLineMatch.index);
      idMatch = sessionLevel.match(PeerConnectionIdp._identityPattern);
    }
    if (!idMatch) {
      return; // undefined === no identity
    }

    let assertion;
    try {
      assertion = JSON.parse(atob(idMatch[1]));
    } catch (e) {
      throw new this._win.DOMException('invalid identity assertion: ' + e,
                                       'InvalidSessionDescriptionError');
    }
    if (!this._isValidAssertion(assertion)) {
      throw new this._win.DOMException('assertion missing idp/idp.domain/assertion',
                                       'InvalidSessionDescriptionError');
    }
    return assertion;
  },

  /**
   * Verifies the a=identity line the given SDP contains, if any.
   * If the verification succeeds callback is called with the message from the
   * IdP proxy as parameter, else (verification failed OR no a=identity line in
   * SDP at all) null is passed to callback.
   *
   * Note that this only verifies that the SDP is coherent.  We still rely on
   * the fact that the RTCPeerConnection won't connect to a peer if the
   * fingerprint of the certificate they offer doesn't appear in the SDP.
   */
  verifyIdentityFromSDP: function(sdp, origin) {
    let identity = this._getIdentityFromSdp(sdp);
    let fingerprints = this._getFingerprintsFromSdp(sdp);
    if (!identity || fingerprints.length <= 0) {
      return this._win.Promise.resolve(); // undefined result = no identity
    }

    this.setIdentityProvider(identity.idp.domain, identity.idp.protocol);
    return this._verifyIdentity(identity.assertion, fingerprints, origin);
  },

  /**
   * Checks that the name in the identity provided by the IdP is OK.
   *
   * @param name (string) the name to validate
   * @throws if the name isn't valid
   */
  _validateName: function(name) {
    let error = msg => {
        throw new this._win.DOMException('assertion name error: ' + msg,
                                         'IdpError');
    };

    if (typeof name !== 'string') {
      error('name not a string');
    }
    let atIdx = name.indexOf('@');
    if (atIdx <= 0) {
      error('missing authority in name from IdP');
    }

    // no third party assertions... for now
    let tail = name.substring(atIdx + 1);

    // strip the port number, if present
    let provider = this.provider;
    let providerPortIdx = provider.indexOf(':');
    if (providerPortIdx > 0) {
      provider = provider.substring(0, providerPortIdx);
    }
    let idnService = Components.classes['@mozilla.org/network/idn-service;1']
        .getService(Components.interfaces.nsIIDNService);
    if (idnService.convertUTF8toACE(tail) !==
        idnService.convertUTF8toACE(provider)) {
      error('name "' + name +
            '" doesn\'t match IdP: "' + this.provider + '"');
    }
  },

  /**
   * Check the validation response.  We are very defensive here when handling
   * the message from the IdP proxy.  That way, broken IdPs aren't likely to
   * cause catastrophic damage.
   */
  _checkValidation: function(validation, sdpFingerprints) {
    let error = msg => {
      throw new this._win.DOMException('IdP validation error: ' + msg,
                                       'IdpError');
    };

    if (!this.provider) {
      error('IdP closed');
    }

    if (typeof validation !== 'object' ||
        typeof validation.contents !== 'string' ||
        typeof validation.identity !== 'string') {
      error('no payload in validation response');
    }

    let fingerprints;
    try {
      fingerprints = JSON.parse(validation.contents).fingerprint;
    } catch (e) {
      error('invalid JSON');
    }

    let isFingerprint = f =>
        (typeof f.digest === 'string') &&
        (typeof f.algorithm === 'string');
    if (!Array.isArray(fingerprints) || !fingerprints.every(isFingerprint)) {
      error('fingerprints must be an array of objects' +
            ' with digest and algorithm attributes');
    }

    // everything in `innerSet` is found in `outerSet`
    let isSubsetOf = (outerSet, innerSet, comparator) => {
      return innerSet.every(i => {
        return outerSet.some(o => comparator(i, o));
      });
    };
    let compareFingerprints = (a, b) => {
      return (a.digest === b.digest) && (a.algorithm === b.algorithm);
    };
    if (!isSubsetOf(fingerprints, sdpFingerprints, compareFingerprints)) {
      error('the fingerprints must be covered by the assertion');
    }
    this._validateName(validation.identity);
    return validation;
  },

  /**
   * Asks the IdP proxy to verify an identity assertion.
   */
  _verifyIdentity: function(assertion, fingerprints, origin) {
    let p = this.start()
        .then(idp => this._wrapCrossCompartmentPromise(
          idp.validateAssertion(assertion, origin)))
        .then(validation => this._checkValidation(validation, fingerprints));

    return this._applyTimeout(p);
  },

  /**
   * Enriches the given SDP with an `a=identity` line.  getIdentityAssertion()
   * must have already run successfully, otherwise this does nothing to the sdp.
   */
  addIdentityAttribute: function(sdp) {
    if (!this.assertion) {
      return sdp;
    }

    // yes, we assume that this matches; if it doesn't something is *wrong*
    let match = sdp.match(PeerConnectionIdp._mLinePattern);
    return sdp.substring(0, match.index) +
      'a=identity:' + this.assertion + '\r\n' +
      sdp.substring(match.index);
  },

  /**
   * Asks the IdP proxy for an identity assertion.  Don't call this unless you
   * have checked .enabled, or you really like exceptions.  Also, don't call
   * this when another call is still running, because it's not certain which
   * call will finish first and the final state will be similarly uncertain.
   */
  getIdentityAssertion: function(fingerprint, origin) {
    if (!this.enabled) {
      throw new this._win.DOMException(
        'no IdP set, call setIdentityProvider() to set one', 'InvalidStateError');
    }

    let [algorithm, digest] = fingerprint.split(' ', 2);
    let content = {
      fingerprint: [{
        algorithm: algorithm,
        digest: digest
      }]
    };

    this._resetAssertion();
    let p = this.start()
        .then(idp => this._wrapCrossCompartmentPromise(
          idp.generateAssertion(JSON.stringify(content),
                                origin, this.username)))
        .then(assertion => {
          if (!this._isValidAssertion(assertion)) {
            throw new this._win.DOMException('IdP generated invalid assertion',
                                             'IdpError');
          }
          // save the base64+JSON assertion, since that is all that is used
          this.assertion = btoa(JSON.stringify(assertion));
          return this.assertion;
        });

    return this._applyTimeout(p);
  },

  /**
   * Promises generated by the sandbox need to be very carefully treated so that
   * they can chain into promises in the `this._win` compartment.  Results need
   * to be cloned across; errors need to be converted.
   */
  _wrapCrossCompartmentPromise: function(sandboxPromise) {
    return new this._win.Promise((resolve, reject) => {
      sandboxPromise.then(
        result => resolve(Cu.cloneInto(result, this._win)),
        e => {
          let message = '' + (e.message || JSON.stringify(e) || 'IdP error');
          if (e.name === 'IdpLoginError') {
            if (typeof e.loginUrl === 'string') {
              this.idpLoginUrl = e.loginUrl;
            }
            reject(new this._win.DOMException(message, 'IdpLoginError'));
          } else {
            reject(new this._win.DOMException(message, 'IdpError'));
          }
        });
    });
  },

  /**
   * Wraps a promise, adding a timeout guard on it so that it can't take longer
   * than the specified time.  Returns a promise that rejects if the timeout
   * elapses before `p` resolves.
   */
  _applyTimeout: function(p) {
    let timeout = new this._win.Promise(
      r => this._win.setTimeout(r, this._timeout))
        .then(() => {
          throw new this._win.DOMException('IdP timed out', 'IdpError');
        });
    return this._win.Promise.race([ timeout, p ]);
  }
};

this.PeerConnectionIdp = PeerConnectionIdp;
