import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js';
import * as xrSessionMojom from '/gen/device/vr/public/mojom/xr_session.mojom.m.js';
import {GamepadHand, GamepadMapping} from '/gen/device/gamepad/public/mojom/gamepad.mojom.m.js';

// This polyfill library implements the WebXR Test API as specified here:
// https://github.com/immersive-web/webxr-test-api

const defaultMojoFromFloor = {
  matrix: [1, 0,     0, 0,
           0, 1,     0, 0,
           0, 0,     1, 0,
           0, -1.65, 0, 1]
};
const default_stage_parameters = {
  mojoFromFloor: defaultMojoFromFloor,
  bounds: null
};

const default_framebuffer_scale = 0.7;

function getMatrixFromTransform(transform) {
  const x = transform.orientation[0];
  const y = transform.orientation[1];
  const z = transform.orientation[2];
  const w = transform.orientation[3];

  const m11 = 1.0 - 2.0 * (y * y + z * z);
  const m21 = 2.0 * (x * y + z * w);
  const m31 = 2.0 * (x * z - y * w);

  const m12 = 2.0 * (x * y - z * w);
  const m22 = 1.0 - 2.0 * (x * x + z * z);
  const m32 = 2.0 * (y * z + x * w);

  const m13 = 2.0 * (x * z + y * w);
  const m23 = 2.0 * (y * z - x * w);
  const m33 = 1.0 - 2.0 * (x * x + y * y);

  const m14 = transform.position[0];
  const m24 = transform.position[1];
  const m34 = transform.position[2];

  // Column-major linearized order is expected.
  return [m11, m21, m31, 0,
          m12, m22, m32, 0,
          m13, m23, m33, 0,
          m14, m24, m34, 1];
}

function getPoseFromTransform(transform) {
  const [px, py, pz] = transform.position;
  const [ox, oy, oz, ow] = transform.orientation;
  return {
    position: {x: px, y: py, z: pz},
    orientation: {x: ox, y: oy, z: oz, w: ow},
  };
}

function composeGFXTransform(fakeTransformInit) {
  return {matrix: getMatrixFromTransform(fakeTransformInit)};
}

// Value equality for camera image init objects - they must contain `width` &
// `height` properties and may contain `pixels` property.
function isSameCameraImageInit(rhs, lhs) {
  return lhs.width === rhs.width && lhs.height === rhs.height && lhs.pixels === rhs.pixels;
}

class ChromeXRTest {
  constructor() {
    this.mockVRService_ = new MockVRService();
  }

  // WebXR Test API
  simulateDeviceConnection(init_params) {
    return Promise.resolve(this.mockVRService_._addRuntime(init_params));
  }

  disconnectAllDevices() {
    this.mockVRService_._removeAllRuntimes();
    return Promise.resolve();
  }

  simulateUserActivation(callback) {
    if (window.top !== window) {
      // test_driver.click only works for the toplevel frame. This alternate
      // Chrome-specific method is sufficient for starting an XR session in an
      // iframe, and is used in platform-specific tests.
      //
      // TODO(https://github.com/web-platform-tests/wpt/issues/20282): use
      // a cross-platform method if available.
      xr_debug('simulateUserActivation', 'use eventSender');
      document.addEventListener('click', callback);
      eventSender.mouseMoveTo(0, 0);
      eventSender.mouseDown();
      eventSender.mouseUp();
      document.removeEventListener('click', callback);
      return;
    }
    const button = document.createElement('button');
    button.textContent = 'click to continue test';
    button.style.display = 'block';
    button.style.fontSize = '20px';
    button.style.padding = '10px';
    button.onclick = () => {
      callback();
      document.body.removeChild(button);
    };
    document.body.appendChild(button);
    test_driver.click(button);
  }

  // Helper method leveraged by chrome-specific setups.
  Debug(name, msg) {
    console.log(new Date().toISOString() + ' DEBUG[' + name + '] ' + msg);
  }
}

// Mocking class definitions

// Mock service implements the VRService mojo interface.
class MockVRService {
  constructor() {
    this.receiver_ = new vrMojom.VRServiceReceiver(this);
    this.runtimes_ = [];

    this.interceptor_ =
        new MojoInterfaceInterceptor(vrMojom.VRService.$interfaceName);
    this.interceptor_.oninterfacerequest =
        e => this.receiver_.$.bindHandle(e.handle);
    this.interceptor_.start();
  }

  // WebXR Test API Implementation Helpers
  _addRuntime(fakeDeviceInit) {
    const runtime = new MockRuntime(fakeDeviceInit, this);
    this.runtimes_.push(runtime);

    if (this.client_) {
      this.client_.onDeviceChanged();
    }

    return runtime;
  }

  _removeAllRuntimes() {
    if (this.client_) {
      this.client_.onDeviceChanged();
    }

    this.runtimes_ = [];
  }

  _removeRuntime(device) {
    const index = this.runtimes_.indexOf(device);
    if (index >= 0) {
      this.runtimes_.splice(index, 1);
      if (this.client_) {
        this.client_.onDeviceChanged();
      }
    }
  }

  // VRService overrides
  setClient(client) {
    if (this.client_) {
      throw new Error("setClient should only be called once");
    }

    this.client_ = client;
  }

  requestSession(sessionOptions) {
    const requests = [];
    // Request a session from all the runtimes.
    for (let i = 0; i < this.runtimes_.length; i++) {
      requests[i] = this.runtimes_[i]._requestRuntimeSession(sessionOptions);
    }

    return Promise.all(requests).then((results) => {
      // Find and return the first successful result.
      for (let i = 0; i < results.length; i++) {
        if (results[i].session) {
          // Construct a dummy metrics recorder
          const metricsRecorderPtr = new vrMojom.XRSessionMetricsRecorderRemote();
          metricsRecorderPtr.$.bindNewPipeAndPassReceiver().handle.close();

          const success = {
            session: results[i].session,
            metricsRecorder: metricsRecorderPtr,
          };

          return {result: {success}};
        }
      }

      // If there were no successful results, returns a null session.
      return {
        result: {failureReason: xrSessionMojom.RequestSessionError.NO_RUNTIME_FOUND}
      };
    });
  }

  supportsSession(sessionOptions) {
    const requests = [];
    // Check supports on all the runtimes.
    for (let i = 0; i < this.runtimes_.length; i++) {
      requests[i] = this.runtimes_[i]._runtimeSupportsSession(sessionOptions);
    }

    return Promise.all(requests).then((results) => {
      // Find and return the first successful result.
      for (let i = 0; i < results.length; i++) {
        if (results[i].supportsSession) {
          return results[i];
        }
      }

      // If there were no successful results, returns false.
      return {supportsSession: false};
    });
  }

  exitPresent() {
    return Promise.resolve();
  }

  setFramesThrottled(throttled) {
    this.setFramesThrottledImpl(throttled);
  }

  // We cannot override the mojom interceptors via the prototype; so this method
  // and the above indirection exist to allow overrides by internal code.
  setFramesThrottledImpl(throttled) {}

  // Only handles asynchronous calls to makeXrCompatible. Synchronous calls are
  // not supported in Javascript.
  makeXrCompatible() {
    if (this.runtimes_.length == 0) {
      return {
        xrCompatibleResult: vrMojom.XrCompatibleResult.kNoDeviceAvailable
      };
    }
    return {xrCompatibleResult: vrMojom.XrCompatibleResult.kAlreadyCompatible};
  }
}

class FakeXRAnchorController {
  constructor() {
    // Private properties.
    this.device_ = null;
    this.id_ = null;
    this.dirty_ = true;

    // Properties backing up public attributes / methods.
    this.deleted_ = false;
    this.paused_ = false;
    this.anchorOrigin_ = XRMathHelper.identity();
  }

  // WebXR Test API (Anchors Extension)
  get deleted() {
    return this.deleted_;
  }

  pauseTracking() {
    if(!this.paused_) {
      this.paused_ = true;
      this.dirty_ = true;
    }
  }

  resumeTracking() {
    if(this.paused_) {
      this.paused_ = false;
      this.dirty_ = true;
    }
  }

  stopTracking() {
    if(!this.deleted_) {
      this.device_._deleteAnchorController(this.id_);

      this.deleted_ = true;
      this.dirty_ = true;
    }
  }

  setAnchorOrigin(anchorOrigin) {
    this.anchorOrigin_ = getMatrixFromTransform(anchorOrigin);
    this.dirty_ = true;
  }

  // Internal implementation:
  set id(value) {
    this.id_ = value;
  }

  set device(value) {
    this.device_ = value;
  }

  get dirty() {
    return this.dirty_;
  }

  get paused() {
    return this.paused_;
  }

  _markProcessed() {
    this.dirty_ = false;
  }

  _getAnchorOrigin() {
    return this.anchorOrigin_;
  }
}

// Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock
// for XRPresentationProvider. Implements FakeXRDevice test API.
class MockRuntime {
  // Mapping from string feature names to the corresponding mojo types.
  // This is exposed as a member for extensibility.
  static _featureToMojoMap = {
    'viewer': xrSessionMojom.XRSessionFeature.REF_SPACE_VIEWER,
    'local': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL,
    'local-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR,
    'bounded-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR,
    'unbounded': xrSessionMojom.XRSessionFeature.REF_SPACE_UNBOUNDED,
    'hit-test': xrSessionMojom.XRSessionFeature.HIT_TEST,
    'dom-overlay': xrSessionMojom.XRSessionFeature.DOM_OVERLAY,
    'light-estimation': xrSessionMojom.XRSessionFeature.LIGHT_ESTIMATION,
    'anchors': xrSessionMojom.XRSessionFeature.ANCHORS,
    'depth-sensing': xrSessionMojom.XRSessionFeature.DEPTH,
    'secondary-views': xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS,
    'camera-access': xrSessionMojom.XRSessionFeature.CAMERA_ACCESS,
    'layers': xrSessionMojom.XRSessionFeature.LAYERS,
  };

  static _sessionModeToMojoMap = {
    "inline": xrSessionMojom.XRSessionMode.kInline,
    "immersive-vr": xrSessionMojom.XRSessionMode.kImmersiveVr,
    "immersive-ar": xrSessionMojom.XRSessionMode.kImmersiveAr,
  };

  static _environmentBlendModeToMojoMap = {
    "opaque": vrMojom.XREnvironmentBlendMode.kOpaque,
    "alpha-blend": vrMojom.XREnvironmentBlendMode.kAlphaBlend,
    "additive": vrMojom.XREnvironmentBlendMode.kAdditive,
  };

  static _interactionModeToMojoMap = {
    "screen-space": vrMojom.XRInteractionMode.kScreenSpace,
    "world-space": vrMojom.XRInteractionMode.kWorldSpace,
  };

  constructor(fakeDeviceInit, service) {
    this.sessionClient_ = null;
    this.presentation_provider_ = new MockXRPresentationProvider();

    this.pose_ = null;
    this.next_frame_id_ = 0;
    this.bounds_ = null;
    this.send_mojo_space_reset_ = false;
    this.stageParameters_ = null;
    this.stageParametersId_ = 1;

    this.service_ = service;

    this.framesOfReference = {};

    this.input_sources_ = new Map();
    this.next_input_source_index_ = 1;

    // Currently active hit test subscriptons.
    this.hitTestSubscriptions_ = new Map();
    // Currently active transient hit test subscriptions.
    this.transientHitTestSubscriptions_ = new Map();
    // ID of the next subscription to be assigned.
    this.next_hit_test_id_ = 1n;

    this.anchor_controllers_ = new Map();
    // ID of the next anchor to be assigned.
    this.next_anchor_id_ = 1n;
    // Anchor creation callback (initially null, can be set by tests).
    this.anchor_creation_callback_ = null;

    this.depthSensingData_ = null;
    this.depthSensingDataDirty_ = false;

    let supportedModes = [];
    if (fakeDeviceInit.supportedModes) {
      supportedModes = fakeDeviceInit.supportedModes.slice();
      if (fakeDeviceInit.supportedModes.length === 0) {
        supportedModes = ["inline"];
      }
    } else {
      // Back-compat mode.
      console.warn("Please use `supportedModes` to signal which modes are supported by this device.");
      if (fakeDeviceInit.supportsImmersive == null) {
        throw new TypeError("'supportsImmersive' must be set");
      }

      supportedModes = ["inline"];
      if (fakeDeviceInit.supportsImmersive) {
        supportedModes.push("immersive-vr");
      }
    }

    this.supportedModes_ = this._convertModesToEnum(supportedModes);
    if (this.supportedModes_.length == 0) {
      console.error("Device has empty supported modes array!");
      throw new InvalidStateError();
    }

    if (fakeDeviceInit.viewerOrigin != null) {
      this.setViewerOrigin(fakeDeviceInit.viewerOrigin);
    }

    if (fakeDeviceInit.floorOrigin != null) {
      this.setFloorOrigin(fakeDeviceInit.floorOrigin);
    }

    if (fakeDeviceInit.world) {
      this.setWorld(fakeDeviceInit.world);
    }

    if (fakeDeviceInit.depthSensingData) {
      this.setDepthSensingData(fakeDeviceInit.depthSensingData);
    }

    this.defaultFramebufferScale_ = default_framebuffer_scale;
    this.enviromentBlendMode_ = this._convertBlendModeToEnum(fakeDeviceInit.environmentBlendMode);
    this.interactionMode_ = this._convertInteractionModeToEnum(fakeDeviceInit.interactionMode);

    // This appropriately handles if the coordinates are null
    this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates);

    this.setViews(fakeDeviceInit.views, fakeDeviceInit.secondaryViews);

    // Need to support webVR which doesn't have a notion of features
    this._setFeatures(fakeDeviceInit.supportedFeatures || []);
  }

  // WebXR Test API
  setViews(primaryViews, secondaryViews) {
    this.cameraImage_ = null;
    this.primaryViews_ = [];
    this.secondaryViews_ = [];
    let xOffset = 0;
    if (primaryViews) {
      this.primaryViews_ = [];
      xOffset = this._setViews(primaryViews, xOffset, this.primaryViews_);
      const cameraImage = this._findCameraImage(primaryViews);

      if (cameraImage) {
        this.cameraImage_ = cameraImage;
      }
    }

    if (secondaryViews) {
      this.secondaryViews_ = [];
      this._setViews(secondaryViews, xOffset, this.secondaryViews_);
      const cameraImage = this._findCameraImage(secondaryViews);

      if (cameraImage) {
        if (!isSameCameraImageInit(this.cameraImage_, cameraImage)) {
          throw new Error("If present, camera resolutions on each view must match each other!"
                          + " Secondary views' camera doesn't match primary views.");
        }

        this.cameraImage_ = cameraImage;
      }
    }
  }

  disconnect() {
    this.service_._removeRuntime(this);
    this.presentation_provider_._close();
    if (this.sessionClient_) {
      this.sessionClient_.$.close();
      this.sessionClient_ = null;
    }

    return Promise.resolve();
  }

  setViewerOrigin(origin, emulatedPosition = false) {
    const p = origin.position;
    const q = origin.orientation;
    this.pose_ = {
      orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
      position: { x: p[0], y: p[1], z: p[2] },
      emulatedPosition: emulatedPosition,
      angularVelocity: null,
      linearVelocity: null,
      angularAcceleration: null,
      linearAcceleration: null,
      inputState: null,
      poseIndex: 0
    };
  }

  clearViewerOrigin() {
    this.pose_ = null;
  }

  setFloorOrigin(floorOrigin) {
    if (!this.stageParameters_) {
      this.stageParameters_ = default_stage_parameters;
      this.stageParameters_.bounds = this.bounds_;
    }

    // floorOrigin is passed in as mojoFromFloor.
    this.stageParameters_.mojoFromFloor =
        {matrix: getMatrixFromTransform(floorOrigin)};

    this._onStageParametersUpdated();
  }

  clearFloorOrigin() {
    if (this.stageParameters_) {
      this.stageParameters_ = null;
      this._onStageParametersUpdated();
    }
  }

  setBoundsGeometry(bounds) {
    if (bounds == null) {
      this.bounds_ = null;
    } else if (bounds.length < 3) {
      throw new Error("Bounds must have a length of at least 3");
    } else {
      this.bounds_ = bounds;
    }

    // We can only set bounds if we have stageParameters set; otherwise, we
    // don't know the transform from local space to bounds space.
    // We'll cache the bounds so that they can be set in the future if the
    // floorLevel transform is set, but we won't update them just yet.
    if (this.stageParameters_) {
      this.stageParameters_.bounds = this.bounds_;
      this._onStageParametersUpdated();
    }
  }

  simulateResetPose() {
    this.send_mojo_space_reset_ = true;
  }

  simulateVisibilityChange(visibilityState) {
    let mojoState = null;
    switch (visibilityState) {
      case "visible":
        mojoState = vrMojom.XRVisibilityState.VISIBLE;
        break;
      case "visible-blurred":
        mojoState = vrMojom.XRVisibilityState.VISIBLE_BLURRED;
        break;
      case "hidden":
        mojoState = vrMojom.XRVisibilityState.HIDDEN;
        break;
    }
    if (mojoState && this.sessionClient_) {
      this.sessionClient_.onVisibilityStateChanged(mojoState);
    }
  }

  simulateInputSourceConnection(fakeInputSourceInit) {
    const index = this.next_input_source_index_;
    this.next_input_source_index_++;

    const source = new MockXRInputSource(fakeInputSourceInit, index, this);
    this.input_sources_.set(index, source);
    return source;
  }

  // WebXR Test API Hit Test extensions
  setWorld(world) {
    this.world_ = world;
  }

  clearWorld() {
    this.world_ = null;
  }

  // WebXR Test API Anchor extensions
  setAnchorCreationCallback(callback) {
    this.anchor_creation_callback_ = callback;
  }

  setHitTestSourceCreationCallback(callback) {
    this.hit_test_source_creation_callback_ = callback;
  }

  // WebXR Test API Lighting estimation extensions
  setLightEstimate(fakeXrLightEstimateInit) {
    if (!fakeXrLightEstimateInit.sphericalHarmonicsCoefficients) {
      throw new TypeError("sphericalHarmonicsCoefficients must be set");
    }

    if (fakeXrLightEstimateInit.sphericalHarmonicsCoefficients.length != 27) {
      throw new TypeError("Must supply all 27 sphericalHarmonicsCoefficients");
    }

    if (fakeXrLightEstimateInit.primaryLightDirection && fakeXrLightEstimateInit.primaryLightDirection.w != 0) {
      throw new TypeError("W component of primaryLightDirection must be 0");
    }

    if (fakeXrLightEstimateInit.primaryLightIntensity && fakeXrLightEstimateInit.primaryLightIntensity.w != 1) {
      throw new TypeError("W component of primaryLightIntensity must be 1");
    }

    // If the primaryLightDirection or primaryLightIntensity aren't set, we need to set them
    // to the defaults that the spec expects. ArCore will either give us everything or nothing,
    // so these aren't nullable on the mojom.
    if (!fakeXrLightEstimateInit.primaryLightDirection) {
      fakeXrLightEstimateInit.primaryLightDirection = { x: 0.0, y: 1.0, z: 0.0, w: 0.0 };
    }

    if (!fakeXrLightEstimateInit.primaryLightIntensity) {
      fakeXrLightEstimateInit.primaryLightIntensity = { x: 0.0, y: 0.0, z: 0.0, w: 1.0 };
    }

    let c = fakeXrLightEstimateInit.sphericalHarmonicsCoefficients;

    this.light_estimate_ = {
      lightProbe: {
        // XRSphereicalHarmonics
        sphericalHarmonics: {
          coefficients: [
            { red: c[0],  green: c[1],  blue: c[2] },
            { red: c[3],  green: c[4],  blue: c[5] },
            { red: c[6],  green: c[7],  blue: c[8] },
            { red: c[9],  green: c[10], blue: c[11] },
            { red: c[12], green: c[13], blue: c[14] },
            { red: c[15], green: c[16], blue: c[17] },
            { red: c[18], green: c[19], blue: c[20] },
            { red: c[21], green: c[22], blue: c[23] },
            { red: c[24], green: c[25], blue: c[26] }
          ]
        },
        // Vector3dF
        mainLightDirection: {
          x: fakeXrLightEstimateInit.primaryLightDirection.x,
          y: fakeXrLightEstimateInit.primaryLightDirection.y,
          z: fakeXrLightEstimateInit.primaryLightDirection.z
        },
        // RgbTupleF32
        mainLightIntensity: {
          red:   fakeXrLightEstimateInit.primaryLightIntensity.x,
          green: fakeXrLightEstimateInit.primaryLightIntensity.y,
          blue:  fakeXrLightEstimateInit.primaryLightIntensity.z
        }
      }
    }
  }

  // WebXR Test API depth Sensing Extensions
  setDepthSensingData(depthSensingData) {
    for(const key of ["depthData", "normDepthBufferFromNormView", "rawValueToMeters", "width", "height"]) {
      if(!(key in depthSensingData)) {
        throw new TypeError("Required key not present. Key: " + key);
      }
    }

    if(depthSensingData.depthData != null) {
      // Create new object w/ properties based on the depthSensingData, but
      // convert the FakeXRRigidTransformInit into a transformation matrix object.
      this.depthSensingData_ = Object.assign({},
        depthSensingData, {
          normDepthBufferFromNormView: composeGFXTransform(depthSensingData.normDepthBufferFromNormView),
        });
    } else {
      throw new TypeError("`depthData` is not set");
    }

    this.depthSensingDataDirty_ = true;
  }

  clearDepthSensingData() {
    this.depthSensingData_ = null;
    this.depthSensingDataDirty_ = true;
  }

  // Internal Implementation/Helper Methods
  _convertModeToEnum(sessionMode) {
    if (sessionMode in MockRuntime._sessionModeToMojoMap) {
      return MockRuntime._sessionModeToMojoMap[sessionMode];
    }

    throw new TypeError("Unrecognized value for XRSessionMode enum: " + sessionMode);
  }

  _convertModesToEnum(sessionModes) {
    return sessionModes.map(mode => this._convertModeToEnum(mode));
  }

  _convertBlendModeToEnum(blendMode) {
    if (blendMode in MockRuntime._environmentBlendModeToMojoMap) {
      return MockRuntime._environmentBlendModeToMojoMap[blendMode];
    } else {
      if (this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
        return vrMojom.XREnvironmentBlendMode.kAdditive;
      } else if (this.supportedModes_.includes(
        xrSessionMojom.XRSessionMode.kImmersiveVr)) {
        return vrMojom.XREnvironmentBlendMode.kOpaque;
      }
    }
  }

  _convertInteractionModeToEnum(interactionMode) {
    if (interactionMode in MockRuntime._interactionModeToMojoMap) {
      return MockRuntime._interactionModeToMojoMap[interactionMode];
    } else {
      return vrMojom.XRInteractionMode.kWorldSpace;
    }
  }

  _setViews(deviceViews, xOffset, views) {
    for (let i = 0; i < deviceViews.length; i++) {
      views[i] = this._getView(deviceViews[i], xOffset);
      xOffset += deviceViews[i].resolution.width;
    }

    return xOffset;
  }

  _findCameraImage(views) {
    const viewWithCamera = views.find(view => view.cameraImageInit);
    if (viewWithCamera) {
      //If we have one view with a camera resolution, all views should have the same camera resolution.
      const allViewsHaveSameCamera = views.every(
        view => isSameCameraImageInit(view.cameraImageInit, viewWithCamera.cameraImageInit));

      if (!allViewsHaveSameCamera) {
        throw new Error("If present, camera resolutions on each view must match each other!");
      }

      return viewWithCamera.cameraImageInit;
    }

    return null;
  }

  _onStageParametersUpdated() {
    // Indicate for the frame loop that the stage parameters have been updated.
    this.stageParametersId_++;
  }

  _getDefaultViews() {
    if (this.primaryViews_) {
      return this.primaryViews_;
    }

    const viewport_size = 20;
    return [{
        eye: vrMojom.XREye.kLeft,
        fieldOfView: {
          upDegrees: 48.316,
          downDegrees: 50.099,
          leftDegrees: 50.899,
          rightDegrees: 35.197
        },
        mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({
          position: [-0.032, 0, 0],
          orientation: [0, 0, 0, 1]
        })),
        viewport: { x: 0, y: 0, width: viewport_size, height: viewport_size }
      },
      {
        eye: vrMojom.XREye.kRight,
        fieldOfView: {
          upDegrees: 48.316,
          downDegrees: 50.099,
          leftDegrees: 50.899,
          rightDegrees: 35.197
        },
        mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({
          position: [0.032, 0, 0],
          orientation: [0, 0, 0, 1]
        })),
        viewport: { x: viewport_size, y: 0, width: viewport_size, height: viewport_size }
      }];
  }

  // This function converts between the matrix provided by the WebXR test API
  // and the internal data representation.
  _getView(fakeXRViewInit, xOffset) {
    let fov = null;

    if (fakeXRViewInit.fieldOfView) {
      fov = {
        upDegrees: fakeXRViewInit.fieldOfView.upDegrees,
        downDegrees: fakeXRViewInit.fieldOfView.downDegrees,
        leftDegrees: fakeXRViewInit.fieldOfView.leftDegrees,
        rightDegrees: fakeXRViewInit.fieldOfView.rightDegrees
      };
    } else {
      const m = fakeXRViewInit.projectionMatrix;

      function toDegrees(tan) {
        return Math.atan(tan) * 180 / Math.PI;
      }

      const leftTan = (1 - m[8]) / m[0];
      const rightTan = (1 + m[8]) / m[0];
      const upTan = (1 + m[9]) / m[5];
      const downTan = (1 - m[9]) / m[5];

      fov = {
        upDegrees: toDegrees(upTan),
        downDegrees: toDegrees(downTan),
        leftDegrees: toDegrees(leftTan),
        rightDegrees: toDegrees(rightTan)
      };
    }

    let viewEye = vrMojom.XREye.kNone;
    // The eye passed in corresponds to the values in the WebXR spec, which are
    // the strings "none", "left", and "right". They should be converted to the
    // corresponding values of XREye in vr_service.mojom.
    switch(fakeXRViewInit.eye) {
      case "none":
        viewEye = vrMojom.XREye.kNone;
        break;
      case "left":
        viewEye = vrMojom.XREye.kLeft;
        break;
      case "right":
        viewEye = vrMojom.XREye.kRight;
        break;
    }

    return {
      eye: viewEye,
      fieldOfView: fov,
      mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(fakeXRViewInit.viewOffset)),
      viewport: {
        x: xOffset,
        y: 0,
        width: fakeXRViewInit.resolution.width,
        height: fakeXRViewInit.resolution.height
      },
      isFirstPersonObserver: fakeXRViewInit.isFirstPersonObserver ? true : false,
      viewOffset: composeGFXTransform(fakeXRViewInit.viewOffset)
    };
  }

  _setFeatures(supportedFeatures) {
    function convertFeatureToMojom(feature) {
      if (feature in MockRuntime._featureToMojoMap) {
        return MockRuntime._featureToMojoMap[feature];
      } else {
        return xrSessionMojom.XRSessionFeature.INVALID;
      }
    }

    this.supportedFeatures_ = [];

    for (let i = 0; i < supportedFeatures.length; i++) {
      const feature = convertFeatureToMojom(supportedFeatures[i]);
      if (feature !== xrSessionMojom.XRSessionFeature.INVALID) {
        this.supportedFeatures_.push(feature);
      }
    }
  }

  // These methods are intended to be used by MockXRInputSource only.
  _addInputSource(source) {
    if (!this.input_sources_.has(source.source_id_)) {
      this.input_sources_.set(source.source_id_, source);
    }
  }

  _removeInputSource(source) {
    this.input_sources_.delete(source.source_id_);
  }

  // These methods are intended to be used by FakeXRAnchorController only.
  _deleteAnchorController(controllerId) {
    this.anchor_controllers_.delete(controllerId);
  }

  // Extension point for non-standard modules.
  _injectAdditionalFrameData(options, frameData) {
  }

  // Mojo function implementations.

  // XRFrameDataProvider implementation.
  getFrameData(options) {
    return new Promise((resolve) => {

      const populatePose = () => {
        const mojo_space_reset = this.send_mojo_space_reset_;
        this.send_mojo_space_reset_ = false;

        if (this.pose_) {
          this.pose_.poseIndex++;
        }

        // Setting the input_state to null tests a slightly different path than
        // the browser tests where if the last input source is removed, the device
        // code always sends up an empty array, but it's also valid mojom to send
        // up a null array.
        let input_state = null;
        if (this.input_sources_.size > 0) {
          input_state = [];
          for (const input_source of this.input_sources_.values()) {
            input_state.push(input_source._getInputSourceState());
          }
        }

        let frame_views = this.primaryViews_;
        for (let i = 0; i < this.primaryViews_.length; i++) {
          this.primaryViews_[i].mojoFromView =
            this._getMojoFromViewerWithOffset(this.primaryViews_[i].viewOffset);
        }
        if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) {
          for (let i = 0; i < this.secondaryViews_.length; i++) {
            this.secondaryViews_[i].mojoFromView =
              this._getMojoFromViewerWithOffset(this.secondaryViews_[i].viewOffset);
          }

          frame_views = frame_views.concat(this.secondaryViews_);
        }

        const frameData = {
          mojoFromViewer: this.pose_,
          views: frame_views,
          mojoSpaceReset: mojo_space_reset,
          inputState: input_state,
          timeDelta: {
            // window.performance.now() is in milliseconds, so convert to microseconds.
            microseconds: BigInt(Math.floor(window.performance.now() * 1000)),
          },
          frameId: this.next_frame_id_,
          bufferHolder: null,
          cameraImageSize: this.cameraImage_ ? {
            width: this.cameraImage_.width,
            height: this.cameraImage_.height
          } : null,
          renderingTimeRatio: 0,
          stageParameters: this.stageParameters_,
          stageParametersId: this.stageParametersId_,
          lightEstimationData: this.light_estimate_
        };

        this.next_frame_id_++;

        this._calculateHitTestResults(frameData);

        this._calculateAnchorInformation(frameData);

        this._calculateDepthInformation(frameData);

        this._injectAdditionalFrameData(options, frameData);

        resolve({frameData});
      };

      if(this.sessionOptions_.mode == xrSessionMojom.XRSessionMode.kInline) {
        // Inline sessions should not have a delay introduced since it causes them
        // to miss a vsync blink-side and delays propagation of changes that happened
        // within a rAFcb by one frame (e.g. setViewerOrigin() calls would take 2 frames
        // to propagate).
        populatePose();
      } else {
        // For immerive sessions, add additional delay to allow for anchor creation
        // promises to run.
        setTimeout(populatePose, 3);  // note: according to MDN, the timeout is not exact
      }
    });
  }

  getEnvironmentIntegrationProvider(environmentProviderRequest) {
    if (this.environmentProviderReceiver_) {
      this.environmentProviderReceiver_.$.close();
    }
    this.environmentProviderReceiver_ =
        new vrMojom.XREnvironmentIntegrationProviderReceiver(this);
    this.environmentProviderReceiver_.$.bindHandle(
        environmentProviderRequest.handle);
  }

  // XREnvironmentIntegrationProvider implementation:
  subscribeToHitTest(nativeOriginInformation, entityTypes, ray) {
    if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
      // Reject outside of AR.
      return Promise.resolve({
        result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC,
        subscriptionId : 0n
      });
    }

    if (!this._nativeOriginKnown(nativeOriginInformation)) {
      return Promise.resolve({
        result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC,
        subscriptionId : 0n
      });
    }

    // Reserve the id for hit test source:
    const id = this.next_hit_test_id_++;
    const hitTestParameters = { isTransient: false, profileName: null };
    const controller = new FakeXRHitTestSourceController(id);


    return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller)
      .then((succeeded) => {
        if(succeeded) {
          // Store the subscription information as-is (including controller):
          this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray, controller });

          return Promise.resolve({
            result : vrMojom.SubscribeToHitTestResult.SUCCESS,
            subscriptionId : id
          });
        } else {
          return Promise.resolve({
            result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC,
            subscriptionId : 0n
          });
        }
      });
  }

  subscribeToHitTestForTransientInput(profileName, entityTypes, ray){
    if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
      // Reject outside of AR.
      return Promise.resolve({
        result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC,
        subscriptionId : 0n
      });
    }

    const id = this.next_hit_test_id_++;
    const hitTestParameters = { isTransient: true, profileName: profileName };
    const controller = new FakeXRHitTestSourceController(id);

    // Check if we have hit test source creation callback.
    // If yes, ask it if the hit test source creation should succeed.
    // If no, for back-compat, assume the hit test source creation succeeded.
    return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller)
      .then((succeeded) => {
        if(succeeded) {
          // Store the subscription information as-is (including controller):
          this.transientHitTestSubscriptions_.set(id, { profileName, entityTypes, ray, controller });

          return Promise.resolve({
            result : vrMojom.SubscribeToHitTestResult.SUCCESS,
            subscriptionId : id
          });
        } else {
          return Promise.resolve({
            result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC,
            subscriptionId : 0n
          });
        }
      });
  }

  unsubscribeFromHitTest(subscriptionId) {
    let controller = null;
    if(this.transientHitTestSubscriptions_.has(subscriptionId)){
      controller = this.transientHitTestSubscriptions_.get(subscriptionId).controller;
      this.transientHitTestSubscriptions_.delete(subscriptionId);
    } else if(this.hitTestSubscriptions_.has(subscriptionId)){
      controller = this.hitTestSubscriptions_.get(subscriptionId).controller;
      this.hitTestSubscriptions_.delete(subscriptionId);
    }

    if(controller) {
      controller.deleted = true;
    }
  }

  createAnchor(nativeOriginInformation, nativeOriginFromAnchor) {
    return new Promise((resolve) => {
      if(this.anchor_creation_callback_ == null) {
        resolve({
          result : vrMojom.CreateAnchorResult.FAILURE,
          anchorId : 0n
        });

        return;
      }

      const mojoFromNativeOrigin = this._getMojoFromNativeOrigin(nativeOriginInformation);
      if(mojoFromNativeOrigin == null) {
        resolve({
          result : vrMojom.CreateAnchorResult.FAILURE,
          anchorId : 0n
        });

        return;
      }

      const mojoFromAnchor = XRMathHelper.mul4x4(mojoFromNativeOrigin, nativeOriginFromAnchor);

      const anchorCreationParameters = {
        requestedAnchorOrigin: mojoFromAnchor,
        isAttachedToEntity: false,
      };

      const anchorController = new FakeXRAnchorController();

      this.anchor_creation_callback_(anchorCreationParameters, anchorController)
            .then((result) => {
              if(result) {
                // If the test allowed the anchor creation,
                // store the anchor controller & return success.

                const anchor_id = this.next_anchor_id_;
                this.next_anchor_id_++;

                this.anchor_controllers_.set(anchor_id, anchorController);
                anchorController.device = this;
                anchorController.id = anchor_id;

                resolve({
                  result : vrMojom.CreateAnchorResult.SUCCESS,
                  anchorId : anchor_id
                });
              } else {
                // The test has rejected anchor creation.
                resolve({
                  result : vrMojom.CreateAnchorResult.FAILURE,
                  anchorId : 0n
                });
              }
            })
            .catch(() => {
              // The test threw an error, treat anchor creation as failed.
              resolve({
                result : vrMojom.CreateAnchorResult.FAILURE,
                anchorId : 0n
              });
            });
    });
  }

  createPlaneAnchor(planeFromAnchor, planeId) {
    return new Promise((resolve) => {

      // Not supported yet.

      resolve({
        result : vrMojom.CreateAnchorResult.FAILURE,
        anchorId : 0n,
      });
    });
  }

  detachAnchor(anchorId) {}

  // Utility function
  _requestRuntimeSession(sessionOptions) {
    return this._runtimeSupportsSession(sessionOptions).then((result) => {
      // The JavaScript bindings convert c_style_names to camelCase names.
      const options = {
        transportMethod:
            vrMojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER,
        waitForTransferNotification: true,
        waitForRenderNotification: true,
        waitForGpuFence: false,
      };

      let submit_frame_sink;
      if (result.supportsSession) {
        submit_frame_sink = {
          clientReceiver: this.presentation_provider_._getClientReceiver(),
          provider: this.presentation_provider_._bindProvider(sessionOptions),
          transportOptions: options
        };

        const dataProviderPtr = new vrMojom.XRFrameDataProviderRemote();
        this.dataProviderReceiver_ =
            new vrMojom.XRFrameDataProviderReceiver(this);
        this.dataProviderReceiver_.$.bindHandle(
            dataProviderPtr.$.bindNewPipeAndPassReceiver().handle);
        this.sessionOptions_ = sessionOptions;

        this.sessionClient_ = new vrMojom.XRSessionClientRemote();
        const clientReceiver = this.sessionClient_.$.bindNewPipeAndPassReceiver();

        const enabled_features = [];
        for (let i = 0; i < sessionOptions.requiredFeatures.length; i++) {
          const feature = sessionOptions.requiredFeatures[i];
          if (this._runtimeSupportsFeature(feature, sessionOptions)) {
            enabled_features.push(feature);
          } else {
            return Promise.resolve({session: null});
          }
        }

        for (let i =0; i < sessionOptions.optionalFeatures.length; i++) {
          const feature = sessionOptions.optionalFeatures[i];
          if (this._runtimeSupportsFeature(feature, sessionOptions)) {
            enabled_features.push(feature);
          }
        }

        this.enabledFeatures_ = enabled_features;

        return Promise.resolve({
          session: {
            submitFrameSink: submit_frame_sink,
            dataProvider: dataProviderPtr,
            clientReceiver: clientReceiver,
            enabledFeatures: enabled_features,
            deviceConfig: {
              defaultFramebufferScale: this.defaultFramebufferScale_,
              supportsViewportScaling: true,
              depthConfiguration: enabled_features.includes(
                                      xrSessionMojom.XRSessionFeature.DEPTH) ?
                  {
                    depthUsage: xrSessionMojom.XRDepthUsage.kCPUOptimized,
                    depthDataFormat:
                        xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha,
                  } :
                  null,
              views: this._getDefaultViews(),
            },
            enviromentBlendMode: this.enviromentBlendMode_,
            interactionMode: this.interactionMode_
          }
        });
      } else {
        return Promise.resolve({session: null});
      }
    });
  }

  _runtimeSupportsSession(options) {
    let result = this.supportedModes_.includes(options.mode);
    return Promise.resolve({
      supportsSession: result,
    });
  }

  _runtimeSupportsFeature(feature, options) {
    if (this.supportedFeatures_.indexOf(feature) === -1) {
      return false;
    }

    switch (feature) {
      case xrSessionMojom.XRSessionFeature.DEPTH:
        // This matches what Chrome can currently support.
        return options.depthOptions &&
               (options.depthOptions.usagePreferences.length == 0 ||
                options.depthOptions.usagePreferences.includes(
                  xrSessionMojom.XRDepthUsage.kCPUOptimized)) &&
               (options.depthOptions.dataFormatPreferences.length == 0 ||
                options.depthOptions.dataFormatPreferences.includes(
                 xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha));
      default:
        return true;
    }
  }

  // Private functions - utilities:
  _nativeOriginKnown(nativeOriginInformation){

    if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) {
      if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) {
        // Unknown input source.
        return false;
      }

      return true;
    } else if (nativeOriginInformation.referenceSpaceType !== undefined) {
      // Bounded_floor & unbounded ref spaces are not yet supported for AR:
      if (nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kUnbounded
       || nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kBoundedFloor) {
        return false;
      }

      return true;
    } else {
      // Planes and anchors are not yet supported by the mock interface.
      return false;
    }
  }

  // Private functions - anchors implementation:

  // Modifies passed in frameData to add anchor information.
  _calculateAnchorInformation(frameData) {
    if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
      return;
    }

    frameData.anchorsData = {allAnchorsIds: [], updatedAnchorsData: []};
    for(const [id, controller] of this.anchor_controllers_) {
      frameData.anchorsData.allAnchorsIds.push(id);

      // Send the entire anchor data over if there was a change since last GetFrameData().
      if(controller.dirty) {
        const anchorData = {id};
        if(!controller.paused) {
          anchorData.mojoFromAnchor = getPoseFromTransform(
              XRMathHelper.decomposeRigidTransform(
                  controller._getAnchorOrigin()));
        }

        controller._markProcessed();

        frameData.anchorsData.updatedAnchorsData.push(anchorData);
      }
    }
  }

  // Private functions - depth sensing implementation:

  // Modifies passed in frameData to add anchor information.
  _calculateDepthInformation(frameData) {
    if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
      return;
    }

    if (!this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.DEPTH)) {
      return;
    }

    let newDepthData;

    // If we don't have a current depth data, we'll return null
    // (i.e. no data is not a valid data, so it cannot be "StillValid").
    if (this.depthSensingData_ == null) {
      newDepthData = null;
    } else if(!this.depthSensingDataDirty_) {
      newDepthData = { dataStillValid: {}};
    } else {
      newDepthData = {
        updatedDepthData: {
          timeDelta: frameData.timeDelta,
          normTextureFromNormView: this.depthSensingData_.normDepthBufferFromNormView,
          rawValueToMeters: this.depthSensingData_.rawValueToMeters,
          size: { width: this.depthSensingData_.width, height: this.depthSensingData_.height },
          pixelData: { bytes: this.depthSensingData_.depthData }
        }
      };
    }

    for (let i = 0; i < this.primaryViews_.length; i++) {
      this.primaryViews_[i].depthData = newDepthData;
    }
    if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) {
      for (let i = 0; i < this.secondaryViews_.length; i++) {
        this.secondaryViews_[i].depthData = newDepthData;
      }
    }

    this.depthSensingDataDirty_ = false;
  }

  // Private functions - hit test implementation:

  // Returns a Promise<bool> that signifies whether hit test source creation should succeed.
  // If we have a hit test source creation callback installed, invoke it and return its result.
  // If it's not installed, for back-compat just return a promise that resolves to true.
  _shouldHitTestSourceCreationSucceed(hitTestParameters, controller) {
    if(this.hit_test_source_creation_callback_) {
      return this.hit_test_source_creation_callback_(hitTestParameters, controller);
    } else {
      return Promise.resolve(true);
    }
  }

  // Modifies passed in frameData to add hit test results.
  _calculateHitTestResults(frameData) {
    if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
      return;
    }

    frameData.hitTestSubscriptionResults = {results: [],
                                            transientInputResults: []};
    if (!this.world_) {
      return;
    }

    // Non-transient hit test:
    for (const [id, subscription] of this.hitTestSubscriptions_) {
      const mojo_from_native_origin = this._getMojoFromNativeOrigin(subscription.nativeOriginInformation);
      if (!mojo_from_native_origin) continue;

      const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace(
        subscription.ray,
        mojo_from_native_origin
      );

      const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes);
      frameData.hitTestSubscriptionResults.results.push(
          {subscriptionId: id, hitTestResults: results});
    }

    // Transient hit test:
    const mojo_from_viewer = this._getMojoFromViewer();

    for (const [id, subscription] of this.transientHitTestSubscriptions_) {
      const result = {subscriptionId: id,
                      inputSourceIdToHitTestResults: new Map()};

      // Find all input sources that match the profile name:
      const matching_input_sources = Array.from(this.input_sources_.values())
                                                        .filter(input_source => input_source.profiles_.includes(subscription.profileName));

      for (const input_source of matching_input_sources) {
        const mojo_from_native_origin = input_source._getMojoFromInputSource(mojo_from_viewer);

        const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace(
          subscription.ray,
          mojo_from_native_origin
        );

        const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes);

        result.inputSourceIdToHitTestResults.set(input_source.source_id_, results);
      }

      frameData.hitTestSubscriptionResults.transientInputResults.push(result);
    }
  }

  // Returns 2-element array [origin, direction] of a ray in mojo space.
  // |ray| is expressed relative to native origin.
  _transformRayToMojoSpace(ray, mojo_from_native_origin) {
    const ray_origin = {
      x: ray.origin.x,
      y: ray.origin.y,
      z: ray.origin.z,
      w: 1
    };
    const ray_direction = {
      x: ray.direction.x,
      y: ray.direction.y,
      z: ray.direction.z,
      w: 0
    };

    const mojo_ray_origin = XRMathHelper.transform_by_matrix(
      mojo_from_native_origin,
      ray_origin);
    const mojo_ray_direction = XRMathHelper.transform_by_matrix(
      mojo_from_native_origin,
      ray_direction);

    return [mojo_ray_origin, mojo_ray_direction];
  }

  // Hit tests the passed in ray (expressed as origin and direction) against the mocked world data.
  _hitTestWorld(origin, direction, entityTypes) {
    let result = [];

    for (const region of this.world_.hitTestRegions) {
      const partial_result = this._hitTestRegion(
        region,
        origin, direction,
        entityTypes);

      result = result.concat(partial_result);
    }

    return result.sort((lhs, rhs) => lhs.distance - rhs.distance).map((hitTest) => {
      delete hitTest.distance;
      return hitTest;
    });
  }

  // Hit tests the passed in ray (expressed as origin and direction) against world region.
  // |entityTypes| is a set of FakeXRRegionTypes.
  // |region| is FakeXRRegion.
  // Returns array of XRHitResults, each entry will be decorated with the distance from the ray origin (along the ray).
  _hitTestRegion(region, origin, direction, entityTypes) {
    const regionNameToMojoEnum = {
      "point": vrMojom.EntityTypeForHitTest.POINT,
      "plane": vrMojom.EntityTypeForHitTest.PLANE,
      "mesh":null
    };

    if (!entityTypes.includes(regionNameToMojoEnum[region.type])) {
      return [];
    }

    const result = [];
    for (const face of region.faces) {
      const maybe_hit = this._hitTestFace(face, origin, direction);
      if (maybe_hit) {
        result.push(maybe_hit);
      }
    }

    // The results should be sorted by distance and there should be no 2 entries with
    // the same distance from ray origin - that would mean they are the same point.
    // This situation is possible when a ray intersects the region through an edge shared
    // by 2 faces.
    return result.sort((lhs, rhs) => lhs.distance - rhs.distance)
                 .filter((val, index, array) => index === 0 || val.distance !== array[index - 1].distance);
  }

  // Hit tests the passed in ray (expressed as origin and direction) against a single face.
  // |face|, |origin|, and |direction| are specified in world (aka mojo) coordinates.
  // |face| is an array of DOMPointInits.
  // Returns null if the face does not intersect with the ray, otherwise the result is
  // an XRHitResult with matrix describing the pose of the intersection point.
  _hitTestFace(face, origin, direction) {
    const add = XRMathHelper.add;
    const sub = XRMathHelper.sub;
    const mul = XRMathHelper.mul;
    const normalize = XRMathHelper.normalize;
    const dot = XRMathHelper.dot;
    const cross = XRMathHelper.cross;
    const neg = XRMathHelper.neg;

    //1. Calculate plane normal in world coordinates.
    const point_A = face.vertices[0];
    const point_B = face.vertices[1];
    const point_C = face.vertices[2];

    const edge_AB = sub(point_B, point_A);
    const edge_AC = sub(point_C, point_A);

    const normal = normalize(cross(edge_AB, edge_AC));

    const numerator = dot(sub(point_A, origin), normal);
    const denominator = dot(direction, normal);

    if (Math.abs(denominator) < XRMathHelper.EPSILON) {
      // Planes are nearly parallel - there's either infinitely many intersection points or 0.
      // Both cases signify a "no hit" for us.
      return null;
    } else {
      // Single intersection point between the infinite plane and the line (*not* ray).
      // Need to calculate the hit test matrix taking into account the face vertices.
      const distance = numerator / denominator;
      if (distance < 0) {
        // Line - plane intersection exists, but not the half-line - plane does not.
        return null;
      } else {
        const intersection_point = add(origin, mul(distance, direction));
        // Since we are treating the face as a solid, flip the normal so that its
        // half-space will contain the ray origin.
        const y_axis = denominator > 0 ? neg(normal) : normal;

        let z_axis = null;
        const cos_direction_and_y_axis = dot(direction, y_axis);
        if (Math.abs(cos_direction_and_y_axis) > (1 - XRMathHelper.EPSILON)) {
          // Ray and the hit test normal are co-linear - try using the 'up' or 'right' vector's projection on the face plane as the Z axis.
          // Note: this edge case is currently not covered by the spec.
          const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0};
          const right = {x: 1.0, y: 0.0, z: 0.0, w: 0.0};

          z_axis = Math.abs(dot(up, y_axis)) > (1 - XRMathHelper.EPSILON)
                        ? sub(up, mul(dot(right, y_axis), y_axis))  // `up is also co-linear with hit test normal, use `right`
                        : sub(up, mul(dot(up, y_axis), y_axis));    // `up` is not co-linear with hit test normal, use it
        } else {
          // Project the ray direction onto the plane, negate it and use as a Z axis.
          z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away.
        }

        z_axis = normalize(z_axis);
        const x_axis = normalize(cross(y_axis, z_axis));

        // Filter out the points not in polygon.
        if (!XRMathHelper.pointInFace(intersection_point, face)) {
          return null;
        }

        const hitResult = {planeId: 0n};
        hitResult.distance = distance;  // Extend the object with additional information used by higher layers.
                                        // It will not be serialized over mojom.

        const matrix = new Array(16);

        matrix[0] = x_axis.x;
        matrix[1] = x_axis.y;
        matrix[2] = x_axis.z;
        matrix[3] = 0;

        matrix[4] = y_axis.x;
        matrix[5] = y_axis.y;
        matrix[6] = y_axis.z;
        matrix[7] = 0;

        matrix[8] = z_axis.x;
        matrix[9] = z_axis.y;
        matrix[10] = z_axis.z;
        matrix[11] = 0;

        matrix[12] = intersection_point.x;
        matrix[13] = intersection_point.y;
        matrix[14] = intersection_point.z;
        matrix[15] = 1;

        hitResult.mojoFromResult = getPoseFromTransform(
            XRMathHelper.decomposeRigidTransform(matrix));
        return hitResult;
      }
    }
  }

  _getMojoFromViewer() {
    if (!this.pose_) {
      return XRMathHelper.identity();
    }
    const transform = {
      position: [
        this.pose_.position.x,
        this.pose_.position.y,
        this.pose_.position.z],
      orientation: [
        this.pose_.orientation.x,
        this.pose_.orientation.y,
        this.pose_.orientation.z,
        this.pose_.orientation.w],
    };

    return getMatrixFromTransform(transform);
  }

  _getMojoFromViewerWithOffset(viewOffset) {
    return { matrix: XRMathHelper.mul4x4(this._getMojoFromViewer(), viewOffset.matrix) };
  }

  _getMojoFromNativeOrigin(nativeOriginInformation) {
    const mojo_from_viewer = this._getMojoFromViewer();

    if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) {
      if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) {
        return null;
      } else {
        const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId);
        return inputSource._getMojoFromInputSource(mojo_from_viewer);
      }
    } else if (nativeOriginInformation.referenceSpaceType !== undefined) {
      switch (nativeOriginInformation.referenceSpaceType) {
        case vrMojom.XRReferenceSpaceType.kLocal:
          return XRMathHelper.identity();
        case vrMojom.XRReferenceSpaceType.kLocalFloor:
          if (this.stageParameters_ == null || this.stageParameters_.mojoFromFloor == null) {
            console.warn("Standing transform not available.");
            return null;
          }
          return this.stageParameters_.mojoFromFloor.matrix;
        case vrMojom.XRReferenceSpaceType.kViewer:
          return mojo_from_viewer;
        case vrMojom.XRReferenceSpaceType.kBoundedFloor:
          return null;
        case vrMojom.XRReferenceSpaceType.kUnbounded:
          return null;
        default:
          throw new TypeError("Unrecognized XRReferenceSpaceType!");
      }
    } else {
      // Anchors & planes are not yet supported for hit test.
      return null;
    }
  }
}

class MockXRInputSource {
  constructor(fakeInputSourceInit, id, pairedDevice) {
    this.source_id_ = id;
    this.pairedDevice_ = pairedDevice;
    this.handedness_ = fakeInputSourceInit.handedness;
    this.target_ray_mode_ = fakeInputSourceInit.targetRayMode;

    if (fakeInputSourceInit.pointerOrigin == null) {
      throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required.");
    }

    this.setPointerOrigin(fakeInputSourceInit.pointerOrigin);
    this.setProfiles(fakeInputSourceInit.profiles);

    this.primary_input_pressed_ = false;
    if (fakeInputSourceInit.selectionStarted != null) {
      this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted;
    }

    this.primary_input_clicked_ = false;
    if (fakeInputSourceInit.selectionClicked != null) {
      this.primary_input_clicked_ = fakeInputSourceInit.selectionClicked;
    }

    this.primary_squeeze_pressed_ = false;
    this.primary_squeeze_clicked_ = false;

    this.mojo_from_input_ = null;
    if (fakeInputSourceInit.gripOrigin != null) {
      this.setGripOrigin(fakeInputSourceInit.gripOrigin);
    }

    // This properly handles if supportedButtons were not specified.
    this.setSupportedButtons(fakeInputSourceInit.supportedButtons);

    this.emulated_position_ = false;
    this.desc_dirty_ = true;
  }

  // WebXR Test API
  setHandedness(handedness) {
    if (this.handedness_ != handedness) {
      this.desc_dirty_ = true;
      this.handedness_ = handedness;
    }
  }

  setTargetRayMode(targetRayMode) {
    if (this.target_ray_mode_ != targetRayMode) {
      this.desc_dirty_ = true;
      this.target_ray_mode_ = targetRayMode;
    }
  }

  setProfiles(profiles) {
    this.desc_dirty_ = true;
    this.profiles_ = profiles;
  }

  setGripOrigin(transform, emulatedPosition = false) {
    // grip_origin was renamed to mojo_from_input in mojo
    this.mojo_from_input_ = composeGFXTransform(transform);
    this.emulated_position_ = emulatedPosition;

    // Technically, setting the grip shouldn't make the description dirty, but
    // the webxr-test-api sets our pointer as mojoFromPointer; however, we only
    // support it across mojom as inputFromPointer, so we need to recalculate it
    // whenever the grip moves.
    this.desc_dirty_ = true;
  }

  clearGripOrigin() {
    // grip_origin was renamed to mojo_from_input in mojo
    if (this.mojo_from_input_ != null) {
      this.mojo_from_input_ = null;
      this.emulated_position_ = false;
      this.desc_dirty_ = true;
    }
  }

  setPointerOrigin(transform, emulatedPosition = false) {
    // pointer_origin is mojo_from_pointer.
    this.desc_dirty_ = true;
    this.mojo_from_pointer_ = composeGFXTransform(transform);
    this.emulated_position_ = emulatedPosition;
  }

  disconnect() {
    this.pairedDevice_._removeInputSource(this);
  }

  reconnect() {
    this.pairedDevice_._addInputSource(this);
  }

  startSelection() {
    this.primary_input_pressed_ = true;
    if (this.gamepad_) {
      this.gamepad_.buttons[0].pressed = true;
      this.gamepad_.buttons[0].touched = true;
    }
  }

  endSelection() {
    if (!this.primary_input_pressed_) {
      throw new Error("Attempted to end selection which was not started");
    }

    this.primary_input_pressed_ = false;
    this.primary_input_clicked_ = true;

    if (this.gamepad_) {
      this.gamepad_.buttons[0].pressed = false;
      this.gamepad_.buttons[0].touched = false;
    }
  }

  simulateSelect() {
    this.primary_input_clicked_ = true;
  }

  setSupportedButtons(supportedButtons) {
    this.gamepad_ = null;
    this.supported_buttons_ = [];

    // If there are no supported buttons, we can stop now.
    if (supportedButtons == null || supportedButtons.length < 1) {
      return;
    }

    const supported_button_map = {};
    this.gamepad_ = this._getEmptyGamepad();
    for (let i = 0; i < supportedButtons.length; i++) {
      const buttonType = supportedButtons[i].buttonType;
      this.supported_buttons_.push(buttonType);
      supported_button_map[buttonType] = supportedButtons[i];
    }

    // Let's start by building the button state in order of priority:
    // Primary button is index 0.
    this.gamepad_.buttons.push({
      pressed: this.primary_input_pressed_,
      touched: this.primary_input_pressed_,
      value: this.primary_input_pressed_ ? 1.0 : 0.0
    });

    // Now add the rest of our buttons
    this._addGamepadButton(supported_button_map['grip']);
    this._addGamepadButton(supported_button_map['touchpad']);
    this._addGamepadButton(supported_button_map['thumbstick']);
    this._addGamepadButton(supported_button_map['optional-button']);
    this._addGamepadButton(supported_button_map['optional-thumbstick']);

    // Finally, back-fill placeholder buttons/axes
    for (let i = 0; i < this.gamepad_.buttons.length; i++) {
      if (this.gamepad_.buttons[i] == null) {
        this.gamepad_.buttons[i] = {
          pressed: false,
          touched: false,
          value: 0
        };
      }
    }

    for (let i=0; i < this.gamepad_.axes.length; i++) {
      if (this.gamepad_.axes[i] == null) {
        this.gamepad_.axes[i] = 0;
      }
    }
  }

  updateButtonState(buttonState) {
    if (this.supported_buttons_.indexOf(buttonState.buttonType) == -1) {
      throw new Error("Tried to update state on an unsupported button");
    }

    const buttonIndex = this._getButtonIndex(buttonState.buttonType);
    const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType);

    if (buttonIndex == -1) {
      throw new Error("Unknown Button Type!");
    }

    // is this a 'squeeze' button?
    if (buttonIndex === this._getButtonIndex('grip')) {
      // squeeze
      if (buttonState.pressed) {
        this.primary_squeeze_pressed_ = true;
      } else if (this.gamepad_.buttons[buttonIndex].pressed) {
        this.primary_squeeze_clicked_ = true;
        this.primary_squeeze_pressed_ = false;
      } else {
        this.primary_squeeze_clicked_ = false;
        this.primary_squeeze_pressed_ = false;
      }
    }

    this.gamepad_.buttons[buttonIndex].pressed = buttonState.pressed;
    this.gamepad_.buttons[buttonIndex].touched = buttonState.touched;
    this.gamepad_.buttons[buttonIndex].value = buttonState.pressedValue;

    if (axesStartIndex != -1) {
      this.gamepad_.axes[axesStartIndex] = buttonState.xValue == null ? 0.0 : buttonState.xValue;
      this.gamepad_.axes[axesStartIndex + 1] = buttonState.yValue == null ? 0.0 : buttonState.yValue;
    }
  }

  // DOM Overlay Extensions
  setOverlayPointerPosition(x, y) {
    this.overlay_pointer_position_ = {x: x, y: y};
  }

  // Helpers for Mojom
  _getInputSourceState() {
    const input_state = {};

    input_state.sourceId = this.source_id_;
    input_state.isAuxiliary = false;

    input_state.primaryInputPressed = this.primary_input_pressed_;
    input_state.primaryInputClicked = this.primary_input_clicked_;

    input_state.primarySqueezePressed = this.primary_squeeze_pressed_;
    input_state.primarySqueezeClicked = this.primary_squeeze_clicked_;
    // Setting the input source's "clicked" state should generate one "select"
    // event. Reset the input value to prevent it from continuously generating
    // events.
    this.primary_input_clicked_ = false;
    // Setting the input source's "clicked" state should generate one "squeeze"
    // event. Reset the input value to prevent it from continuously generating
    // events.
    this.primary_squeeze_clicked_ = false;

    input_state.mojoFromInput = this.mojo_from_input_;

    input_state.gamepad = this.gamepad_;

    input_state.emulatedPosition = this.emulated_position_;

    if (this.desc_dirty_) {
      const input_desc = {};

      switch (this.target_ray_mode_) {
        case 'gaze':
          input_desc.targetRayMode = vrMojom.XRTargetRayMode.GAZING;
          break;
        case 'tracked-pointer':
          input_desc.targetRayMode = vrMojom.XRTargetRayMode.POINTING;
          break;
        case 'screen':
          input_desc.targetRayMode = vrMojom.XRTargetRayMode.TAPPING;
          break;
        default:
          throw new Error('Unhandled target ray mode ' + this.target_ray_mode_);
      }

      switch (this.handedness_) {
        case 'left':
          input_desc.handedness = vrMojom.XRHandedness.LEFT;
          break;
        case 'right':
          input_desc.handedness = vrMojom.XRHandedness.RIGHT;
          break;
        default:
          input_desc.handedness = vrMojom.XRHandedness.NONE;
          break;
      }

      // Mojo requires us to send the pointerOrigin as relative to the grip
      // space. If we don't have a grip space, we'll just assume that there
      // is a grip at identity. This allows tests to simulate controllers that
      // are really just a pointer with no tracked grip, though we will end up
      // exposing that grip space.
      let mojo_from_input = XRMathHelper.identity();
      switch (this.target_ray_mode_) {
        case 'gaze':
        case 'screen':
          // For gaze and screen space, we won't have a mojo_from_input; however
          // the "input" position is just the viewer, so use mojo_from_viewer.
          mojo_from_input = this.pairedDevice_._getMojoFromViewer();
          break;
        case 'tracked-pointer':
          // If we have a tracked grip position (e.g. mojo_from_input), then use
          // that. If we don't, then we'll just set the pointer offset directly,
          // using identity as set above.
          if (this.mojo_from_input_) {
            mojo_from_input = this.mojo_from_input_.matrix;
          }
          break;
        default:
          throw new Error('Unhandled target ray mode ' + this.target_ray_mode_);
      }

      // To convert mojo_from_pointer to input_from_pointer, we need:
      // input_from_pointer = input_from_mojo * mojo_from_pointer
      // Since we store mojo_from_input, we need to invert it here before
      // multiplying.
      let input_from_mojo = XRMathHelper.inverse(mojo_from_input);
      input_desc.inputFromPointer = {};
      input_desc.inputFromPointer.matrix =
        XRMathHelper.mul4x4(input_from_mojo, this.mojo_from_pointer_.matrix);

      input_desc.profiles = this.profiles_;

      input_state.description = input_desc;

      this.desc_dirty_ = false;
    }

    // Pointer data for DOM Overlay, set by setOverlayPointerPosition()
    if (this.overlay_pointer_position_) {
      input_state.overlayPointerPosition = this.overlay_pointer_position_;
      this.overlay_pointer_position_ = null;
    }

    return input_state;
  }

  _getEmptyGamepad() {
    // Mojo complains if some of the properties on Gamepad are null, so set
    // everything to reasonable defaults that tests can override.
    const gamepad = {
      connected: true,
      id: [],
      timestamp: 0n,
      axes: [],
      buttons: [],
      touchEvents: [],
      mapping: GamepadMapping.GamepadMappingStandard,
      displayId: 0,
    };

    switch (this.handedness_) {
      case 'left':
      gamepad.hand = GamepadHand.GamepadHandLeft;
      break;
      case 'right':
      gamepad.hand = GamepadHand.GamepadHandRight;
      break;
      default:
      gamepad.hand = GamepadHand.GamepadHandNone;
      break;
    }

    return gamepad;
  }

  _addGamepadButton(buttonState) {
    if (buttonState == null) {
      return;
    }

    const buttonIndex = this._getButtonIndex(buttonState.buttonType);
    const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType);

    if (buttonIndex == -1) {
      throw new Error("Unknown Button Type!");
    }

    this.gamepad_.buttons[buttonIndex] = {
      pressed: buttonState.pressed,
      touched: buttonState.touched,
      value: buttonState.pressedValue
    };

    // Add x/y value if supported.
    if (axesStartIndex != -1) {
      this.gamepad_.axes[axesStartIndex] = (buttonState.xValue == null ? 0.0 : buttonSate.xValue);
      this.gamepad_.axes[axesStartIndex + 1] = (buttonState.yValue == null ? 0.0 : buttonSate.yValue);
    }
  }

  // General Helper methods
  _getButtonIndex(buttonType) {
    switch (buttonType) {
      case 'grip':
        return 1;
      case 'touchpad':
        return 2;
      case 'thumbstick':
        return 3;
      case 'optional-button':
        return 4;
      case 'optional-thumbstick':
        return 5;
      default:
        return -1;
    }
  }

  _getAxesStartIndex(buttonType) {
    switch (buttonType) {
      case 'touchpad':
        return 0;
      case 'thumbstick':
        return 2;
      case 'optional-thumbstick':
        return 4;
      default:
        return -1;
    }
  }

  _getMojoFromInputSource(mojo_from_viewer) {
    return this.mojo_from_pointer_.matrix;
  }
}

// Mojo helper classes
class FakeXRHitTestSourceController {
  constructor(id) {
    this.id_ = id;
    this.deleted_ = false;
  }

  get deleted() {
    return this.deleted_;
  }

  // Internal setter:
  set deleted(value) {
    this.deleted_ = value;
  }
}

class MockXRPresentationProvider {
  constructor() {
    this.receiver_ = null;
    this.submit_frame_count_ = 0;
    this.missing_frame_count_ = 0;
  }

  _bindProvider() {
    const provider = new vrMojom.XRPresentationProviderRemote();

    if (this.receiver_) {
      this.receiver_.$.close();
    }
    this.receiver_ = new vrMojom.XRPresentationProviderReceiver(this);
    this.receiver_.$.bindHandle(provider.$.bindNewPipeAndPassReceiver().handle);
    return provider;
  }

  _getClientReceiver() {
    this.submitFrameClient_ = new vrMojom.XRPresentationClientRemote();
    return this.submitFrameClient_.$.bindNewPipeAndPassReceiver();
  }

  // XRPresentationProvider mojo implementation
  updateLayerBounds(frameId, leftBounds, rightBounds, sourceSize) {}

  submitFrameMissing(frameId, mailboxHolder, timeWaited) {
    this.missing_frame_count_++;
  }

  submitFrame(frameId, mailboxHolder, timeWaited) {
    this.submit_frame_count_++;

    // Trigger the submit completion callbacks here. WARNING: The
    // Javascript-based mojo mocks are *not* re-entrant. It's OK to
    // wait for these notifications on the next frame, but waiting
    // within the current frame would never finish since the incoming
    // calls would be queued until the current execution context finishes.
    this.submitFrameClient_.onSubmitFrameTransferred(true);
    this.submitFrameClient_.onSubmitFrameRendered();
  }

  submitFrameWithTextureHandle(frameId, texture, syncToken) {}

  submitFrameDrawnIntoTexture(frameId, syncToken, timeWaited) {}

  // Utility methods
  _close() {
    if (this.receiver_) {
      this.receiver_.$.close();
    }
  }
}

// Export these into the global object as a side effect of importing this
// module.
self.ChromeXRTest = ChromeXRTest;
self.MockRuntime = MockRuntime;
self.MockVRService = MockVRService;
self.SubscribeToHitTestResult = vrMojom.SubscribeToHitTestResult;

navigator.xr.test = new ChromeXRTest();
