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

/*
 * Util base class to help test a captured canvas element. Initializes the
 * output canvas (used for testing the color of video elements), and optionally
 * overrides the default `createAndAppendElement` element |width| and |height|.
 */
function CaptureStreamTestHelper(width, height) {
  if (width) {
    this.elemWidth = width;
  }
  if (height) {
    this.elemHeight = height;
  }

  /* cout is used for `getPixel`; only needs to be big enough for one pixel */
  this.cout = document.createElement("canvas");
  this.cout.width = 1;
  this.cout.height = 1;
}

CaptureStreamTestHelper.prototype = {
  /* Predefined colors for use in the methods below. */
  black: { data: [0, 0, 0, 255], name: "black" },
  blackTransparent: { data: [0, 0, 0, 0], name: "blackTransparent" },
  white: { data: [255, 255, 255, 255], name: "white" },
  green: { data: [0, 255, 0, 255], name: "green" },
  red: { data: [255, 0, 0, 255], name: "red" },
  blue: { data: [0, 0, 255, 255], name: "blue" },
  grey: { data: [128, 128, 128, 255], name: "grey" },

  /* Default element size for createAndAppendElement() */
  elemWidth: 100,
  elemHeight: 100,

  /*
   * Perform the drawing operation on each animation frame until stop is called
   * on the returned object.
   */
  startDrawing(f) {
    var stop = false;
    var draw = () => {
      if (stop) {
        return;
      }
      f();
      window.requestAnimationFrame(draw);
    };
    draw();
    return { stop: () => (stop = true) };
  },

  /* Request a frame from the stream played by |video|. */
  requestFrame(video) {
    info("Requesting frame from " + video.id);
    video.srcObject.requestFrame();
  },

  /*
   * Returns the pixel at (|offsetX|, |offsetY|) (from top left corner) of
   * |video| as an array of the pixel's color channels: [R,G,B,A].
   */
  getPixel(video, offsetX = 0, offsetY = 0) {
    // Avoids old values in case of a transparent image.
    CaptureStreamTestHelper2D.prototype.clear.call(this, this.cout);

    var ctxout = this.cout.getContext("2d");
    ctxout.drawImage(
      video,
      offsetX, // source x coordinate
      offsetY, // source y coordinate
      1, // source width
      1, // source height
      0, // destination x coordinate
      0, // destination y coordinate
      1, // destination width
      1
    ); // destination height
    return ctxout.getImageData(0, 0, 1, 1).data;
  },

  /*
   * Returns true if px lies within the per-channel |threshold| of the
   * referenced color for all channels. px is on the form of an array of color
   * channels, [R,G,B,A]. Each channel is in the range [0, 255].
   *
   * Threshold defaults to 0 which is an exact match.
   */
  isPixel(px, refColor, threshold = 0) {
    return px.every((ch, i) => Math.abs(ch - refColor.data[i]) <= threshold);
  },

  /*
   * Returns true if px lies further away than |threshold| of the
   * referenced color for any channel. px is on the form of an array of color
   * channels, [R,G,B,A]. Each channel is in the range [0, 255].
   *
   * Threshold defaults to 127 which should be far enough for most cases.
   */
  isPixelNot(px, refColor, threshold = 127) {
    return px.some((ch, i) => Math.abs(ch - refColor.data[i]) > threshold);
  },

  /*
   * Behaves like isPixelNot but ignores the alpha channel.
   */
  isOpaquePixelNot(px, refColor, threshold) {
    px[3] = refColor.data[3];
    return this.isPixelNot(px, refColor, threshold);
  },

  /*
   * Returns a promise that resolves when the provided function |test|
   * returns true, or rejects when the optional `cancel` promise resolves.
   */
  async waitForPixel(
    video,
    test,
    {
      offsetX = 0,
      offsetY = 0,
      width = 0,
      height = 0,
      cancel = new Promise(() => {}),
    } = {}
  ) {
    let aborted = false;
    cancel.then(e => (aborted = true));

    while (true) {
      await Promise.race([
        new Promise(resolve =>
          video.addEventListener("timeupdate", resolve, { once: true })
        ),
        cancel,
      ]);
      if (aborted) {
        throw await cancel;
      }
      if (test(this.getPixel(video, offsetX, offsetY, width, height))) {
        return;
      }
    }
  },

  /*
   * Returns a promise that resolves when the top left pixel of |video| matches
   * on all channels. Use |threshold| for fuzzy matching the color on each
   * channel, in the range [0,255]. 0 means exact match, 255 accepts anything.
   */
  async pixelMustBecome(
    video,
    refColor,
    { threshold = 0, infoString = "n/a", cancel = new Promise(() => {}) } = {}
  ) {
    info(
      "Waiting for video " +
        video.id +
        " to match [" +
        refColor.data.join(",") +
        "] - " +
        refColor.name +
        " (" +
        infoString +
        ")"
    );
    var paintedFrames = video.mozPaintedFrames - 1;
    await this.waitForPixel(
      video,
      px => {
        if (paintedFrames != video.mozPaintedFrames) {
          info(
            "Frame: " +
              video.mozPaintedFrames +
              " IsPixel ref=" +
              refColor.data +
              " threshold=" +
              threshold +
              " value=" +
              px
          );
          paintedFrames = video.mozPaintedFrames;
        }
        return this.isPixel(px, refColor, threshold);
      },
      {
        offsetX: 0,
        offsetY: 0,
        width: 0,
        height: 0,
        cancel,
      }
    );
    ok(true, video.id + " " + infoString);
  },

  /*
   * Returns a promise that resolves after |time| ms of playback or when the
   * top left pixel of |video| becomes |refColor|. The test is failed if the
   * time is not reached, or if the cancel promise resolves.
   */
  async pixelMustNotBecome(
    video,
    refColor,
    { threshold = 0, time = 5000, infoString = "n/a" } = {}
  ) {
    info(
      "Waiting for " +
        video.id +
        " to time out after " +
        time +
        "ms against [" +
        refColor.data.join(",") +
        "] - " +
        refColor.name
    );
    let timeout = new Promise(resolve => setTimeout(resolve, time));
    let analysis = async () => {
      await this.waitForPixel(
        video,
        px => this.isPixel(px, refColor, threshold),
        {
          offsetX: 0,
          offsetY: 0,
          width: 0,
          height: 0,
        }
      );
      throw new Error("Got color " + refColor.name + ". " + infoString);
    };
    await Promise.race([timeout, analysis()]);
    ok(true, video.id + " " + infoString);
  },

  /* Create an element of type |type| with id |id| and append it to the body. */
  createAndAppendElement(type, id) {
    var e = document.createElement(type);
    e.id = id;
    e.width = this.elemWidth;
    e.height = this.elemHeight;
    if (type === "video") {
      e.autoplay = true;
    }
    document.body.appendChild(e);
    return e;
  },
};

/* Sub class holding 2D-Canvas specific helpers. */
function CaptureStreamTestHelper2D(width, height) {
  CaptureStreamTestHelper.call(this, width, height);
}

CaptureStreamTestHelper2D.prototype = Object.create(
  CaptureStreamTestHelper.prototype
);
CaptureStreamTestHelper2D.prototype.constructor = CaptureStreamTestHelper2D;

/* Clear all drawn content on |canvas|. */
CaptureStreamTestHelper2D.prototype.clear = function (canvas) {
  var ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, canvas.width, canvas.height);
};

/* Draw the color |color| to the source canvas |canvas|. Format [R,G,B,A]. */
CaptureStreamTestHelper2D.prototype.drawColor = function (
  canvas,
  color,
  {
    offsetX = 0,
    offsetY = 0,
    width = canvas.width / 2,
    height = canvas.height / 2,
  } = {}
) {
  var ctx = canvas.getContext("2d");
  var rgba = color.data.slice(); // Copy to not overwrite the original array
  rgba[3] = rgba[3] / 255.0; // Convert opacity to double in range [0,1]
  info("Drawing color " + rgba.join(","));
  ctx.fillStyle = "rgba(" + rgba.join(",") + ")";

  // Only fill top left corner to test that output is not flipped or rotated.
  ctx.fillRect(offsetX, offsetY, width, height);
};

/* Test that the given 2d canvas is NOT origin-clean. */
CaptureStreamTestHelper2D.prototype.testNotClean = function (canvas) {
  var ctx = canvas.getContext("2d");
  var error = "OK";
  try {
    var data = ctx.getImageData(0, 0, 1, 1);
  } catch (e) {
    error = e.name;
  }
  is(
    error,
    "SecurityError",
    "Canvas '" + canvas.id + "' should not be origin-clean"
  );
};

/* Sub class holding WebGL specific helpers. */
function CaptureStreamTestHelperWebGL(width, height) {
  CaptureStreamTestHelper.call(this, width, height);
}

CaptureStreamTestHelperWebGL.prototype = Object.create(
  CaptureStreamTestHelper.prototype
);
CaptureStreamTestHelperWebGL.prototype.constructor =
  CaptureStreamTestHelperWebGL;

/* Set the (uniform) color location for future draw calls. */
CaptureStreamTestHelperWebGL.prototype.setFragmentColorLocation = function (
  colorLocation
) {
  this.colorLocation = colorLocation;
};

/* Clear the given WebGL context with |color|. */
CaptureStreamTestHelperWebGL.prototype.clearColor = function (canvas, color) {
  info("WebGL: clearColor(" + color.name + ")");
  var gl = canvas.getContext("webgl");
  var conv = color.data.map(i => i / 255.0);
  gl.clearColor(conv[0], conv[1], conv[2], conv[3]);
  gl.clear(gl.COLOR_BUFFER_BIT);
};

/* Set an already setFragmentColorLocation() to |color| and drawArrays() */
CaptureStreamTestHelperWebGL.prototype.drawColor = function (canvas, color) {
  info("WebGL: drawArrays(" + color.name + ")");
  var gl = canvas.getContext("webgl");
  var conv = color.data.map(i => i / 255.0);
  gl.uniform4f(this.colorLocation, conv[0], conv[1], conv[2], conv[3]);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
