/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
});

/** Provides functionality for creating and sending DOM events. */
export const event = {};

const _eventUtils = new WeakMap();

function _getEventUtils(win) {
  if (!_eventUtils.has(win)) {
    const eventUtilsObject = {
      window: win,
      parent: win,
      _EU_Ci: Ci,
      _EU_Cc: Cc,
    };
    Services.scriptloader.loadSubScript(
      "chrome://remote/content/external/EventUtils.js",
      eventUtilsObject
    );
    _eventUtils.set(win, eventUtilsObject);
  }
  return _eventUtils.get(win);
}

event.MouseEvents = {
  click: 0,
  dblclick: 1,
  mousedown: 2,
  mouseup: 3,
  mouseover: 4,
  mouseout: 5,
};

event.Modifiers = {
  shiftKey: 0,
  ctrlKey: 1,
  altKey: 2,
  metaKey: 3,
};

event.MouseButton = {
  isPrimary(button) {
    return button === 0;
  },
  isAuxiliary(button) {
    return button === 1;
  },
  isSecondary(button) {
    return button === 2;
  },
};

/**
 * Synthesize a mouse event in `win` at a point.
 *
 * If the type is specified in `event`, a mouse event of that type is
 * fired. Otherwise, a mousedown followed by a mouseup is performed.
 *
 * @param {number} left - Value for the X offset in CSS pixels.
 * @param {number} top - Value for the Y offset in CSS pixels.
 * @param {module:EventUtils~MouseEventData} event - Details of the mouse event
 *     to dispatch.
 * @param {DOMWindow} win - DOM window used to dispatch the event.
 *
 * @returns {Promise<boolean>} Promise that resolves to a boolean,
 *     indicating whether the event had preventDefault() called on it.
 */
event.synthesizeMouseAtPoint = function (left, top, event, win) {
  if (!event.asyncEnabled) {
    return Promise.resolve(
      _getEventUtils(win).synthesizeMouseAtPoint(left, top, event, win)
    );
  }

  // A callback must be used when handling events with the `asyncEnabled`
  // flag set to `true`, as these events are synthesized in the parent process.
  // We need to wait for them to be fully dispatched to the content process
  // before continuing.
  const { promise, resolve } = Promise.withResolvers();
  const preventDefaultFlag = _getEventUtils(win).synthesizeMouseAtPoint(
    left,
    top,
    event,
    win,
    () => resolve()
  );

  return promise.then(() => preventDefaultFlag);
};

/**
 * Synthesize one or more touch events in `win` at the points.
 *
 * If the type is specified in `event`, a touch event of that type is
 * fired. Otherwise, a touchstart followed by a touchend is performed.
 *
 * @param {module:EventUtils~TouchEventData} event - Details of the touch event(s)
 *     to dispatch.
 * @param {DOMWindow} win - DOM window used to dispatch the event.
 *
 * @returns {Promise<boolean>} Promise that resolves to a boolean,
 *     indicating whether the event had preventDefault() called on it.
 */
event.synthesizeTouchAtPoint = function (event, win) {
  const touchData = {
    type: event.type,
    asyncEnabled: event.asyncEnabled,
    id: event.id,
    rx: event.rx,
    ry: event.ry,
    angle: event.angle,
    force: event.force,
    tiltX: event.tiltx,
    tiltY: event.tilty,
    twist: event.twist,
    altitudeAngle: event.altitudeAngle,
    azimuthAngle: event.azimuthAngle,
    modifiers: _getEventUtils(win)._parseModifiers(event),
  };

  if (!event.asyncEnabled) {
    return Promise.resolve(
      _getEventUtils(win).synthesizeTouchAtPoint(
        event.x,
        event.y,
        touchData,
        win
      )
    );
  }

  // A callback must be used when handling events with the `asyncEnabled`
  // flag set to `true`, as these events are synthesized in the parent process.
  // We need to wait for them to be fully dispatched to the content process
  // before continuing.
  const { promise, resolve } = Promise.withResolvers();
  const preventDefaultFlag = _getEventUtils(win).synthesizeTouchAtPoint(
    event.x,
    event.y,
    touchData,
    win,
    () => resolve()
  );

  return promise.then(() => preventDefaultFlag);
};

/**
 * Synthesize a wheel event in `win` at a point, without flushing layout.
 *
 * @param {number} left - Floating-point value for the X offset in CSS pixels.
 * @param {number} top - Floating-point value for the Y offset in CSS pixels.
 * @param {module:EventUtils~WheelEventData} event - Details of the wheel event
 *     to dispatch.
 * @param {DOMWindow} win - DOM window used to dispatch the event.
 *
 * @returns {Promise} - Promise that resolves once the event has been dispatched.
 */
event.synthesizeWheelAtPoint = function (left, top, event, win) {
  const dpr = win.devicePixelRatio;

  // All delta properties expect the value in device pixels while the
  // WebDriver specification uses CSS pixels.
  // TODO: check if the conversion needs to be done at the platform level
  if (typeof event.deltaX !== "undefined") {
    event.deltaX *= dpr;
  }
  if (typeof event.deltaY !== "undefined") {
    event.deltaY *= dpr;
  }
  if (typeof event.deltaZ !== "undefined") {
    event.deltaZ *= dpr;
  }

  return new Promise(resolve =>
    _getEventUtils(win).synthesizeWheelAtPoint(left, top, event, win, resolve)
  );
};

/**
 * Synthesize a keydown event for a single key.
 *
 * @param {object} key
 *     Key data as returned by keyData.getData
 * @param {Window} win
 *     Window object.
 */
event.sendKeyDown = function (key, win) {
  event.sendSingleKey(key, win, "keydown");
};

/**
 * Synthesize a keyup event for a single key.
 *
 * @param {object} key
 *     Key data as returned by keyData.getData
 * @param {Window} win
 *     Window object.
 */
event.sendKeyUp = function (key, win) {
  event.sendSingleKey(key, win, "keyup");
};

/**
 * Synthesize a key event for a single key.
 *
 * @param {object} key
 *     Key data as returned by keyData.getData
 * @param {Window} win
 *     Window object.
 * @param {string=} type
 *     Event to emit. By default the full keydown/keypressed/keyup event
 *     sequence is emitted.
 */
event.sendSingleKey = function (key, win, type = null) {
  let keyValue = key.key;
  if (!key.printable) {
    keyValue = `KEY_${keyValue}`;
  }
  const event = {
    code: key.code,
    location: key.location,
    altKey: key.altKey ?? false,
    shiftKey: key.shiftKey ?? false,
    ctrlKey: key.ctrlKey ?? false,
    metaKey: key.metaKey ?? false,
    repeat: key.repeat ?? false,
  };
  if (type) {
    event.type = type;
  }
  _getEventUtils(win).synthesizeKey(keyValue, event, win);
};

/**
 * Send a string as a series of keypresses.
 *
 * @param {string} keyString
 *     Sequence of characters to send as key presses
 * @param {Window} win
 *     Window object
 */
event.sendKeys = function (keyString, win) {
  const modifiers = {};
  for (let modifier in event.Modifiers) {
    modifiers[modifier] = false;
  }

  for (let keyValue of keyString) {
    // keyValue will contain enough to represent the UTF-16 encoding of a single abstract character
    // i.e. either a single scalar value, or a surrogate pair
    if (modifiers.shiftKey) {
      keyValue = lazy.keyData.getShiftedKey(keyValue);
    }
    const data = lazy.keyData.getData(keyValue);
    const key = { ...data, ...modifiers };
    if (data.modifier) {
      // Negating the state of the modifier here is not spec compliant but
      // makes us compatible to Chrome's behavior for now. That's fine unless
      // we know the correct behavior.
      //
      // @see: https://github.com/w3c/webdriver/issues/1734
      modifiers[data.modifier] = !modifiers[data.modifier];
    }
    event.sendSingleKey(key, win);
  }
};

event.sendEvent = function (eventType, el, modifiers = {}, opts = {}) {
  opts.canBubble = opts.canBubble || true;

  let doc = el.ownerDocument || el.document;
  let ev = doc.createEvent("Event");

  ev.shiftKey = modifiers.shift;
  ev.metaKey = modifiers.meta;
  ev.altKey = modifiers.alt;
  ev.ctrlKey = modifiers.ctrl;

  ev.initEvent(eventType, opts.canBubble, true);
  el.dispatchEvent(ev);
};

event.mouseover = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("mouseover", el, modifiers, opts);
};

event.mousemove = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("mousemove", el, modifiers, opts);
};

event.mousedown = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("mousedown", el, modifiers, opts);
};

event.mouseup = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("mouseup", el, modifiers, opts);
};

event.cancel = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("cancel", el, modifiers, opts);
};

event.click = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("click", el, modifiers, opts);
};

event.change = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("change", el, modifiers, opts);
};

event.input = function (el, modifiers = {}, opts = {}) {
  return event.sendEvent("input", el, modifiers, opts);
};
