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

const ENDED_TIMEOUT_LENGTH = 30000;

/* The time we wait depends primarily on the canplaythrough event firing
 * Note: this needs to be at least 30s because the
 *       B2G emulator in VMs is really slow. */
const VERIFYPLAYING_TIMEOUT_LENGTH = 60000;

/**
 * This class manages playback of a HTMLMediaElement with a MediaStream.
 * When constructed by a caller, an object instance is created with
 * a media element and a media stream object.
 *
 * @param {HTMLMediaElement} mediaElement the media element for playback
 * @param {MediaStream} mediaStream the media stream used in
 *                                  the mediaElement for playback
 */
function MediaStreamPlayback(mediaElement, mediaStream) {
  this.mediaElement = mediaElement;
  this.mediaStream = mediaStream;
}

MediaStreamPlayback.prototype = {

   /**
   * Starts media element with a media stream, runs it until a canplaythrough
   * and timeupdate event fires, and calls stop() on all its tracks.
   *
   * @param {Boolean} isResume specifies if this media element is being resumed
   *                           from a previous run
   */
  playMedia : function(isResume) {
    this.startMedia(isResume);
    return this.verifyPlaying()
      .then(() => this.stopTracksForStreamInMediaPlayback())
      .then(() => this.detachFromMediaElement());
  },

  /**
   * Stops the local media stream's tracks while it's currently in playback in
   * a media element.
   *
   * Precondition: The media stream and element should both be actively
   *               being played. All the stream's tracks must be local.
   */
  stopTracksForStreamInMediaPlayback : function () {
    var elem = this.mediaElement;
    var waitForEnded = () => new Promise(resolve => {
      elem.addEventListener('ended', function ended() {
        elem.removeEventListener('ended', ended);
        resolve();
      });
    });

    var noTrackEnded = Promise.all(this.mediaStream.getTracks().map(t => {
      let onNextLoop = wait(0);
      let p = Promise.race([
        onNextLoop,
        haveEvent(t, "ended", onNextLoop)
          .then(() => Promise.reject("Unexpected ended event for track " + t.id),
                () => Promise.resolve())
      ]);
      t.stop();
      return p;
    }));

    // XXX (bug 1208316) When we implement MediaStream.active, do not stop
    // the stream. We just do it now so the media element will raise 'ended'.
    if (!this.mediaStream.stop) {
      return;
    }
    this.mediaStream.stop();
    return timeout(waitForEnded(), ENDED_TIMEOUT_LENGTH, "ended event never fired")
             .then(() => ok(true, "ended event successfully fired"))
             .then(() => noTrackEnded);
  },

  /**
   * Starts media with a media stream, runs it until a canplaythrough and
   * timeupdate event fires, and detaches from the element without stopping media.
   *
   * @param {Boolean} isResume specifies if this media element is being resumed
   *                           from a previous run
   */
  playMediaWithoutStoppingTracks : function(isResume) {
    this.startMedia(isResume);
    return this.verifyPlaying()
      .then(() => this.detachFromMediaElement());
  },

  /**
   * Starts the media with the associated stream.
   *
   * @param {Boolean} isResume specifies if the media element playback
   *                           is being resumed from a previous run
   */
  startMedia : function(isResume) {

    // If we're playing media element for the first time, check that time is zero.
    if (!isResume) {
      is(this.mediaElement.currentTime, 0,
         "Before starting the media element, currentTime = 0");
    }
    this.canPlayThroughFired = listenUntil(this.mediaElement, 'canplaythrough',
                                           () => true);

    // Hooks up the media stream to the media element and starts playing it
    this.mediaElement.srcObject = this.mediaStream;
    this.mediaElement.play();
  },

  /**
   * Verifies that media is playing.
   */
  verifyPlaying : function() {
    var lastStreamTime = this.mediaStream.currentTime;
    var lastElementTime = this.mediaElement.currentTime;

    var mediaTimeProgressed = listenUntil(this.mediaElement, 'timeupdate',
        () => this.mediaStream.currentTime > lastStreamTime &&
              this.mediaElement.currentTime > lastElementTime);

    return timeout(Promise.all([this.canPlayThroughFired, mediaTimeProgressed]),
                   VERIFYPLAYING_TIMEOUT_LENGTH, "verifyPlaying timed out")
      .then(() => {
        is(this.mediaElement.paused, false,
           "Media element should be playing");
        is(this.mediaElement.duration, Number.POSITIVE_INFINITY,
           "Duration should be infinity");

        // When the media element is playing with a real-time stream, we
        // constantly switch between having data to play vs. queuing up data,
        // so we can only check that the ready state is one of those two values
        ok(this.mediaElement.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA ||
           this.mediaElement.readyState === HTMLMediaElement.HAVE_CURRENT_DATA,
           "Ready state shall be HAVE_ENOUGH_DATA or HAVE_CURRENT_DATA");

        is(this.mediaElement.seekable.length, 0,
           "Seekable length shall be zero");
        is(this.mediaElement.buffered.length, 0,
           "Buffered length shall be zero");

        is(this.mediaElement.seeking, false,
           "MediaElement is not seekable with MediaStream");
        ok(isNaN(this.mediaElement.startOffsetTime),
           "Start offset time shall not be a number");
        is(this.mediaElement.loop, false, "Loop shall be false");
        is(this.mediaElement.preload, "", "Preload should not exist");
        is(this.mediaElement.src, "", "No src should be defined");
        is(this.mediaElement.currentSrc, "",
           "Current src should still be an empty string");
      });
  },

  /**
   * Detaches from the element without stopping the media.
   *
   * Precondition: The media stream and element should both be actively
   *               being played.
   */
  detachFromMediaElement : function() {
    this.mediaElement.pause();
    this.mediaElement.srcObject = null;
  }
}


/**
 * This class is basically the same as MediaStreamPlayback except
 * ensures that the instance provided startMedia is a MediaStream.
 *
 * @param {HTMLMediaElement} mediaElement the media element for playback
 * @param {LocalMediaStream} mediaStream the media stream used in
 *                                       the mediaElement for playback
 */
function LocalMediaStreamPlayback(mediaElement, mediaStream) {
  ok(mediaStream instanceof LocalMediaStream,
     "Stream should be a LocalMediaStream");
  MediaStreamPlayback.call(this, mediaElement, mediaStream);
}

LocalMediaStreamPlayback.prototype = Object.create(MediaStreamPlayback.prototype, {

  /**
   * DEPRECATED - MediaStream.stop() is going away. Use MediaStreamTrack.stop()!
   *
   * Starts media with a media stream, runs it until a canplaythrough and
   * timeupdate event fires, and calls stop() on the stream.
   *
   * @param {Boolean} isResume specifies if this media element is being resumed
   *                           from a previous run
   */
  playMediaWithDeprecatedStreamStop : {
    value: function(isResume) {
      this.startMedia(isResume);
      return this.verifyPlaying()
        .then(() => this.deprecatedStopStreamInMediaPlayback())
        .then(() => this.detachFromMediaElement());
    }
  },

  /**
   * DEPRECATED - MediaStream.stop() is going away. Use MediaStreamTrack.stop()!
   *
   * Stops the local media stream while it's currently in playback in
   * a media element.
   *
   * Precondition: The media stream and element should both be actively
   *               being played.
   *
   */
  deprecatedStopStreamInMediaPlayback : {
    value: function () {
      return new Promise((resolve, reject) => {
        /**
         * Callback fired when the ended event fires when stop() is called on the
         * stream.
         */
        var endedCallback = () => {
          this.mediaElement.removeEventListener('ended', endedCallback, false);
          ok(true, "ended event successfully fired");
          resolve();
        };

        this.mediaElement.addEventListener('ended', endedCallback, false);
        this.mediaStream.stop();

        // If ended doesn't fire in enough time, then we fail the test
        setTimeout(() => {
          reject(new Error("ended event never fired"));
        }, ENDED_TIMEOUT_LENGTH);
      });
    }
  }
});

// haxx to prevent SimpleTest from failing at window.onload
function addLoadEvent() {}

var scriptsReady = Promise.all([
  "/tests/SimpleTest/SimpleTest.js",
  "head.js"
].map(script  => {
  var el = document.createElement("script");
  el.src = script;
  document.head.appendChild(el);
  return new Promise(r => el.onload = r);
}));

function createHTML(options) {
  return scriptsReady.then(() => realCreateHTML(options));
}

var pushPrefs = (...p) => new Promise(r => SpecialPowers.pushPrefEnv({set: p}, r));

// noGum - Helper to detect whether active guM tracks still exist.
//
// It relies on the fact that, by spec, device labels from enumerateDevices are
// only visible during active gum calls. They're also visible when persistent
// permissions are granted, so turn off media.navigator.permission.disabled
// (which is normally on otherwise in our tests). Lastly, we must turn on
// media.navigator.permission.fake otherwise fake devices don't count as active.

var noGum = () => pushPrefs(["media.navigator.permission.disabled", false],
                            ["media.navigator.permission.fake", true])
  .then(() => navigator.mediaDevices.enumerateDevices())
  .then(([device]) => device &&
      is(device.label, "", "Test must leave no active gUM streams behind."));

var runTest = testFunction => scriptsReady
  .then(() => runTestWhenReady(testFunction))
  .then(() => noGum())
  .then(() => finish());
