const CLEARKEY_KEYSYSTEM = "org.w3.clearkey";

const gCencMediaKeySystemConfig = [{
  initDataTypes: ['cenc'],
  videoCapabilities: [{ contentType: 'video/mp4' }],
  audioCapabilities: [{ contentType: 'audio/mp4' }],
}];

function IsMacOSSnowLeopardOrEarlier() {
  var re = /Mac OS X (\d+)\.(\d+)/;
  var ver = navigator.userAgent.match(re);
  if (!ver || ver.length != 3) {
    return false;
  }
  var major = ver[1] | 0;
  var minor = ver[2] | 0;
  return major == 10 && minor <= 6;
}

function bail(message)
{
  return function(err) {
    if (err) {
      message +=  "; " + String(err)
    }
    ok(false, message);
    if (err) {
      info(String(err));
    }
    SimpleTest.finish();
  }
}

function ArrayBufferToString(arr)
{
  var str = '';
  var view = new Uint8Array(arr);
  for (var i = 0; i < view.length; i++) {
    str += String.fromCharCode(view[i]);
  }
  return str;
}

function StringToArrayBuffer(str)
{
  var arr = new ArrayBuffer(str.length);
  var view = new Uint8Array(arr);
  for (var i = 0; i < str.length; i++) {
    view[i] = str.charCodeAt(i);
  }
  return arr;
}

function StringToHex(str){
  var res = "";
  for (var i = 0; i < str.length; ++i) {
      res += ("0" + str.charCodeAt(i).toString(16)).slice(-2);
  }
  return res;
}

function Base64ToHex(str)
{
  var bin = window.atob(str.replace(/-/g, "+").replace(/_/g, "/"));
  var res = "";
  for (var i = 0; i < bin.length; i++) {
    res += ("0" + bin.charCodeAt(i).toString(16)).substr(-2);
  }
  return res;
}

function HexToBase64(hex)
{
  var bin = "";
  for (var i = 0; i < hex.length; i += 2) {
    bin += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
  }
  return window.btoa(bin).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}

function TimeRangesToString(trs)
{
  var l = trs.length;
  if (l === 0) { return "-"; }
  var s = "";
  var i = 0;
  for (;;) {
    s += trs.start(i) + "-" + trs.end(i);
    if (++i === l) { return s; }
    s += ",";
  }
}

function SourceBufferToString(sb)
{
  return ("SourceBuffer{"
    + "AppendMode=" + (sb.AppendMode || "-")
    + ", updating=" + (sb.updating ? "true" : "false")
    + ", buffered=" + TimeRangesToString(sb.buffered)
    + ", audioTracks=" + (sb.audioTracks ? sb.audioTracks.length : "-")
    + ", videoTracks=" + (sb.videoTracks ? sb.videoTracks.length : "-")
    + "}");
}

function SourceBufferListToString(sbl)
{
  return "SourceBufferList[" + sbl.map(SourceBufferToString).join(", ") + "]";
}

function UpdateSessionFunc(test, token, sessionType, resolve, reject) {
  return function(ev) {
    var msgStr = ArrayBufferToString(ev.message);
    var msg = JSON.parse(msgStr);

    Log(token, "got message from CDM: " + msgStr);
    is(msg.type, sessionType, TimeStamp(token) + " key session type should match");
    ok(msg.kids, TimeStamp(token) + " message event should contain key ID array");

    var outKeys = [];

    for (var i = 0; i < msg.kids.length; i++) {
      var id64 = msg.kids[i];
      var idHex = Base64ToHex(msg.kids[i]).toLowerCase();
      var key = test.keys[idHex];

      if (key) {
        Log(token, "found key " + key + " for key id " + idHex);
        outKeys.push({
          "kty":"oct",
          "kid":id64,
          "k":HexToBase64(key)
        });
      } else {
        bail(token + " couldn't find key for key id " + idHex);
      }
    }

    var update = JSON.stringify({
      "keys" : outKeys,
      "type" : msg.type
    });
    Log(token, "sending update message to CDM: " + update);

    ev.target.update(StringToArrayBuffer(update)).then(function() {
      Log(token, "MediaKeySession update ok!");
      resolve(ev.target);
    }).catch(function(reason) {
      bail(token + " MediaKeySession update failed")(reason);
      reject();
    });
  }
}

function MaybeCrossOriginURI(test, uri)
{
  if (test.crossOrigin) {
    return "http://test2.mochi.test:8888/tests/dom/media/test/allowed.sjs?" + uri;
  } else {
    return uri;
  }
}

function AppendTrack(test, ms, track, token, loadParams)
{
  return new Promise(function(resolve, reject) {
    var sb;
    var curFragment = 0;
    var resolved = false;
    var fragments = track.fragments;
    var fragmentFile;

    if (loadParams && loadParams.onlyLoadFirstFragments) {
      fragments = fragments.slice(0, loadParams.onlyLoadFirstFragments);
    }

    function addNextFragment() {
      if (curFragment >= fragments.length) {
        Log(token, track.name + ": end of track");
        resolve();
        resolved = true;
        return;
      }

      fragmentFile = MaybeCrossOriginURI(test, fragments[curFragment++]);

      var req = new XMLHttpRequest();
      req.open("GET", fragmentFile);
      req.responseType = "arraybuffer";

      req.addEventListener("load", function() {
        Log(token, track.name + ": fetch of " + fragmentFile + " complete, appending");
        sb.appendBuffer(new Uint8Array(req.response));
      });

      req.addEventListener("error", function(){info(token + " error fetching " + fragmentFile);});
      req.addEventListener("abort", function(){info(token + " aborted fetching " + fragmentFile);});

      Log(token, track.name + ": addNextFragment() fetching next fragment " + fragmentFile);
      req.send(null);
    }

    Log(token, track.name + ": addSourceBuffer(" + track.type + ")");
    sb = ms.addSourceBuffer(track.type);
    sb.addEventListener("updateend", function() {
      if (ms.readyState == "ended") {
        /* We can get another updateevent as a result of calling ms.endOfStream() if
           the highest end time of our source buffers is different from that of the
           media source duration. Due to bug 1065207 this can happen because of
           inaccuracies in the frame duration calculations. Check if we are already
           "ended" and ignore the update event */
        Log(token, track.name + ": updateend when readyState already 'ended'");
        if (!resolved) {
          // Needed if decoder knows this was the last fragment and ended by itself.
          Log(token, track.name + ": but promise not resolved yet -> end of track");
          resolve();
          resolved = true;
        }
        return;
      }
      Log(token, track.name + ": updateend for " + fragmentFile + ", " + SourceBufferToString(sb));
      addNextFragment();
    });

    addNextFragment();
  });
}

//Returns a promise that is resolved when the media element is ready to have
//its play() function called; when it's loaded MSE fragments.
function LoadTest(test, elem, token, loadParams)
{
  if (!test.tracks) {
    ok(false, token + " test does not have a tracks list");
    return Promise.reject();
  }

  var ms = new MediaSource();
  elem.src = URL.createObjectURL(ms);

  return new Promise(function (resolve, reject) {
    var firstOpen = true;
    ms.addEventListener("sourceopen", function () {
      if (!firstOpen) {
        Log(token, "sourceopen again?");
        return;
      }

      firstOpen = false;
      Log(token, "sourceopen");
      return Promise.all(test.tracks.map(function(track) {
        return AppendTrack(test, ms, track, token, loadParams);
      })).then(function() {
        if (loadParams && loadParams.noEndOfStream) {
          Log(token, "Tracks loaded");
        } else {
          Log(token, "Tracks loaded, calling MediaSource.endOfStream()");
          ms.endOfStream();
        }
        resolve();
      }).catch(function() {
        Log(token, "error while loading tracks");
      });
    })
  });
}

function EMEPromise() {
  var self = this;
  self.promise = new Promise(function(resolve, reject) {
    self.resolve = resolve;
    self.reject = reject;
  });
}

// Finish |token| when all promises are resolved or any one promise is
// rejected. It also clean up the media element to release resources.
function EMEPromiseAll(v, token, promises) {
  Promise.all(promises).then(values => {
    removeNodeAndSource(v);
    manager.finished(token);
  }, reason => {
    ok(false, TimeStamp(token) + " - Error during load: " + reason);
    removeNodeAndSource(v);
    manager.finished(token);
  });
}

function SetupEME(test, token, params)
{
  var v = document.createElement("video");
  v.crossOrigin = test.crossOrigin || false;
  v.sessions = [];

  v.closeSessions = function() {
    return Promise.all(v.sessions.map(s => s.close().then(() => s.closed))).then(
      () => {
        v.setMediaKeys(null);
        if (v.parentNode) {
          v.parentNode.removeChild(v);
        }
        v.onerror = null;
        v.src = null;
      });
  };

  // Log events dispatched to make debugging easier...
  [ "canplay", "canplaythrough", "ended", "error", "loadeddata",
    "loadedmetadata", "loadstart", "pause", "play", "playing", "progress",
    "stalled", "suspend", "waiting", "waitingforkey",
  ].forEach(function (e) {
    v.addEventListener(e, function(event) {
      Log(token, "" + e);
    });
  });

  // Finish the test when error is encountered.
  v.onerror = bail(token + " got error event");

  var onSetKeysFail = (params && params.onSetKeysFail)
    ? params.onSetKeysFail
    : bail(token + " Failed to set MediaKeys on <video> element");

  // null: No session management in progress, just go ahead and update the session.
  // [...]: Session management in progress, add {initDataType, initData} to
  //        this queue to get it processed when possible.
  var initDataQueue = [];
  function pushInitData(ev)
  {
    if (initDataQueue === null) {
      initDataQueue = [];
    }
    initDataQueue.push(ev);
    if (params && params.onInitDataQueued) {
      params.onInitDataQueued(ev, ev.initDataType, StringToHex(ArrayBufferToString(ev.initData)));
    }
  }

  function processInitDataQueue()
  {
    if (initDataQueue === null) { return; }
    // If we're processed all our init data null the queue to indicate encrypted event handled.
    if (initDataQueue.length === 0) {
      initDataQueue = null;
      return;
    }
    var ev = initDataQueue.shift();

    var sessionType = (params && params.sessionType) ? params.sessionType : "temporary";
    Log(token, "createSession(" + sessionType + ") for (" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ")");
    var session = v.mediaKeys.createSession(sessionType);
    if (params && params.onsessioncreated) {
      params.onsessioncreated(session);
    }
    v.sessions.push(session);

    return new Promise(function (resolve, reject) {
      session.addEventListener("message", UpdateSessionFunc(test, token, sessionType, resolve, reject));
      Log(token, "session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ")");
      session.generateRequest(ev.initDataType, ev.initData).catch(function(reason) {
        // Reject the promise if generateRequest() failed. Otherwise it will
        // be resolve in UpdateSessionFunc().
        bail(token + ": session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") failed")(reason);
        reject();
      });
    })

    .then(function(aSession) {
      Log(token, "session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") succeeded");
      if (params && params.onsessionupdated) {
        params.onsessionupdated(aSession);
      }
      processInitDataQueue();
    });
  }

  function streamType(type) {
    var x = test.tracks.find(o => o.name == type);
    return x ? x.type : undefined;
  }

  // If sessions are to be delayed we won't peform any processing until the
  // callback the assigned here is called by the test.
  if (params && params.delaySessions) {
    params.ProcessSessions = processInitDataQueue;
  }

  // Is this the first piece of init data we're processing?
  var firstInitData = true;
  v.addEventListener("encrypted", function(ev) {
    if (firstInitData) {
      Log(token, "got first encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + "), setup session");
      firstInitData = false;
      pushInitData(ev);

      function chain(promise, onReject) {
        return promise.then(function(value) {
          return Promise.resolve(value);
        }).catch(function(reason) {
          onReject(reason);
          return Promise.reject();
        })
      }

      var options = { initDataTypes: [ev.initDataType] };
      if (streamType("video")) {
        options.videoCapabilities = [{contentType: streamType("video")}];
      }
      if (streamType("audio")) {
        options.audioCapabilities = [{contentType: streamType("audio")}];
      }

      var p = navigator.requestMediaKeySystemAccess(CLEARKEY_KEYSYSTEM, [options]);
      var r = bail(token + " Failed to request key system access.");
      chain(p, r)
      .then(function(keySystemAccess) {
        var p = keySystemAccess.createMediaKeys();
        var r = bail(token +  " Failed to create MediaKeys object");
        return chain(p, r);
      })

      .then(function(mediaKeys) {
        Log(token, "created MediaKeys object ok");
        mediaKeys.sessions = [];
        var p = v.setMediaKeys(mediaKeys);
        return chain(p, onSetKeysFail);
      })

      .then(function() {
        Log(token, "set MediaKeys on <video> element ok");
        if (params && params.onMediaKeysSet) {
          params.onMediaKeysSet();
        }
        if (!(params && params.delaySessions)) {
          processInitDataQueue();
        }
      })
    } else {
      if (params && params.delaySessions) {
        Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, queue it in because we're delaying sessions");
        pushInitData(ev);
      } else if (initDataQueue !== null) {
        Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, queue it for later session update");
        pushInitData(ev);
      } else {
        Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, update session now");
        pushInitData(ev);
        processInitDataQueue();
      }
    }
  });
  return v;
}

function SetupEMEPref(callback) {
  var prefs = [
    [ "media.mediasource.enabled", true ],
    [ "media.eme.apiVisible", true ],
    [ "media.mediasource.webm.enabled", true ],
  ];

  if (SpecialPowers.Services.appinfo.name == "B2G" ||
      !manifestVideo().canPlayType("video/mp4")) {
    // XXX remove once we have mp4 PlatformDecoderModules on all platforms.
    prefs.push([ "media.use-blank-decoder", true ]);
  }

  SpecialPowers.pushPrefEnv({ "set" : prefs }, callback);
}

function fetchWithXHR(uri, onLoadFunction) {
  var p = new Promise(function(resolve, reject) {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", uri, true);
    xhr.responseType = "arraybuffer";
    xhr.addEventListener("load", function () {
      is(xhr.status, 200, "fetchWithXHR load uri='" + uri + "' status=" + xhr.status);
      resolve(xhr.response);
    });
    xhr.send();
  });

  if (onLoadFunction) {
    p.then(onLoadFunction);
  }

  return p;
};

function once(target, name, cb) {
  var p = new Promise(function(resolve, reject) {
    target.addEventListener(name, function(arg) {
      resolve(arg);
    }, {once: true});
  });
  if (cb) {
    p.then(cb);
  }
  return p;
}
