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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",

  AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
  AsyncQueue: "chrome://remote/content/shared/AsyncQueue.sys.mjs",
  dom: "chrome://remote/content/shared/DOM.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  event: "chrome://remote/content/shared/webdriver/Event.sys.mjs",
  keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  pprint: "chrome://remote/content/shared/Format.sys.mjs",
  Sleep: "chrome://remote/content/marionette/sync.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());

// TODO? With ES 2016 and Symbol you can make a safer approximation
// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
/**
 * Implements WebDriver Actions API: a low-level interface for providing
 * virtualized device input to the web browser.
 *
 * Typical usage is to construct an action chain and then dispatch it:
 * const state = new actions.State();
 * const chain = await actions.Chain.fromJSON(state, protocolData);
 * await chain.dispatch(state, window);
 *
 * @namespace
 */
export const actions = {};

// Max interval between two clicks that should result in a dblclick or a tripleclick (in ms)
export const CLICK_INTERVAL = 640;

/** Map from normalized key value to UI Events modifier key name */
const MODIFIER_NAME_LOOKUP = {
  Alt: "alt",
  Shift: "shift",
  Control: "ctrl",
  Meta: "meta",
};

// Flag, that indicates if an async widget event should be used when dispatching a mouse event.
XPCOMUtils.defineLazyPreferenceGetter(
  actions,
  "useAsyncMouseEvents",
  "remote.events.async.mouse.enabled",
  false
);

// Flag, that indicates if an async widget event should be used when dispatching a touch event.
XPCOMUtils.defineLazyPreferenceGetter(
  actions,
  "useAsyncTouchEvents",
  "remote.events.async.touch.enabled",
  false
);

// Flag, that indicates if an async widget event should be used when dispatching a wheel scroll event.
XPCOMUtils.defineLazyPreferenceGetter(
  actions,
  "useAsyncWheelEvents",
  "remote.events.async.wheel.enabled",
  false
);

/**
 * Object containing various callback functions to be used when deserializing
 * action sequences and dispatching these.
 *
 * @typedef {object} ActionsOptions
 * @property {Function} isElementOrigin
 *     Function to check if it's a valid origin element.
 * @property {Function} getElementOrigin
 *     Function to retrieve the element reference for an element origin.
 * @property {Function} assertInViewPort
 *     Function to check if the coordinates [x, y] are in the visible viewport.
 * @property {Function} dispatchEvent
 *     Function to use for dispatching events.
 * @property {Function} getClientRects
 *     Function that retrieves the client rects for an element.
 * @property {Function} getInViewCentrePoint
 *     Function that calculates the in-view center point for the given
 *     coordinates [x, y].
 */

/**
 * State associated with actions.
 *
 * Typically each top-level navigable in a WebDriver session should have a
 * single State object.
 */
actions.State = class {
  #actionsQueue;

  /**
   * Creates a new {@link State} instance.
   */
  constructor() {
    // A queue that ensures that access to the input state is serialized.
    this.#actionsQueue = new lazy.AsyncQueue();

    // Tracker for mouse button clicks.
    this.clickTracker = new ClickTracker();

    /**
     * A map between input ID and the device state for that input
     * source, with one entry for each active input source.
     *
     * Maps string => InputSource
     */
    this.inputStateMap = new Map();

    /**
     * List of {@link Action} associated with current session.  Used to
     * manage dispatching events when resetting the state of the input sources.
     * Reset operations are assumed to be idempotent.
     */
    this.inputsToCancel = new TickActions();

    // Map between string input id and numeric pointer id.
    this.pointerIdMap = new Map();
  }

  /**
   * Returns the list of inputs to cancel when releasing the actions.
   *
   * @returns {TickActions}
   *     The inputs to cancel.
   */
  get inputCancelList() {
    return this.inputsToCancel;
  }

  toString() {
    return `[object ${this.constructor.name} ${JSON.stringify(this)}]`;
  }

  /**
   * Enqueue a new action task.
   *
   * @param {Function} task
   *     The task to queue.
   *
   * @returns {Promise}
   *     Promise that resolves when the task is completed, with the resolved
   *     value being the result of the task.
   */
  enqueueAction(task) {
    return this.#actionsQueue.enqueue(task);
  }

  /**
   * Get the state for a given input source.
   *
   * @param {string} id
   *     Id of the input source.
   *
   * @returns {InputSource}
   *     State of the input source.
   */
  getInputSource(id) {
    return this.inputStateMap.get(id);
  }

  /**
   * Find or add state for an input source.
   *
   * The caller should verify that the returned state is the expected type.
   *
   * @param {string} id
   *     Id of the input source.
   * @param {InputSource} newInputSource
   *     State of the input source.
   *
   * @returns {InputSource}
   *     The input source.
   */
  getOrAddInputSource(id, newInputSource) {
    let inputSource = this.getInputSource(id);

    if (inputSource === undefined) {
      this.inputStateMap.set(id, newInputSource);
      inputSource = newInputSource;
    }

    return inputSource;
  }

  /**
   * Iterate over all input states of a given type.
   *
   * @param {string} type
   *     Type name of the input source (e.g. "pointer").
   *
   * @returns {Iterator<string, InputSource>}
   *     Iterator over id and input source.
   */
  *inputSourcesByType(type) {
    for (const [id, inputSource] of this.inputStateMap) {
      if (inputSource.type === type) {
        yield [id, inputSource];
      }
    }
  }

  /**
   * Get a numerical pointer id for a given pointer.
   *
   * Pointer ids are positive integers. Mouse pointers are typically
   * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each
   * pointer gets a unique id.
   *
   * @param {string} id
   *     Id of the pointer.
   * @param {string} type
   *     Type of the pointer.
   *
   * @returns {number}
   *     The numerical pointer id.
   */
  getPointerId(id, type) {
    let pointerId = this.pointerIdMap.get(id);

    if (pointerId === undefined) {
      // Reserve pointer ids 0 and 1 for mouse pointers
      const idValues = Array.from(this.pointerIdMap.values());

      if (type === "mouse") {
        for (const mouseId of [0, 1]) {
          if (!idValues.includes(mouseId)) {
            pointerId = mouseId;
            break;
          }
        }
      }

      if (pointerId === undefined) {
        pointerId = Math.max(1, ...idValues) + 1;
      }
      this.pointerIdMap.set(id, pointerId);
    }

    return pointerId;
  }
};

/**
 * Tracker for mouse button clicks.
 */
export class ClickTracker {
  #count;
  #lastButtonClicked;
  #timer;

  /**
   * Creates a new {@link ClickTracker} instance.
   */
  constructor() {
    this.#count = 0;
    this.#lastButtonClicked = null;
  }

  get count() {
    return this.#count;
  }

  #cancelTimer() {
    lazy.clearTimeout(this.#timer);
  }

  #startTimer() {
    this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL);
  }

  /**
   * Reset tracking mouse click counter.
   */
  reset() {
    this.#cancelTimer();
    this.#count = 0;
    this.#lastButtonClicked = null;
  }

  /**
   * Track |button| click to identify possible double or triple click.
   *
   * @param {number} button
   *     A positive integer that refers to a mouse button.
   */
  setClick(button) {
    this.#cancelTimer();

    if (
      this.#lastButtonClicked === null ||
      this.#lastButtonClicked === button
    ) {
      this.#count++;
    } else {
      this.#count = 1;
    }

    this.#lastButtonClicked = button;
    this.#startTimer();
  }
}

/**
 * Device state for an input source.
 */
class InputSource {
  #id;
  static type = null;

  /**
   * Creates a new {@link InputSource} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   */
  constructor(id) {
    this.#id = id;
    this.type = this.constructor.type;
  }

  toString() {
    return `[object ${this.constructor.name} id: ${this.#id} type: ${
      this.type
    }]`;
  }

  /**
   * Unmarshals a JSON Object to an {@link InputSource}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-get-or-create-an-input-source
   *
   * @param {State} actionState
   *     Actions state.
   * @param {Sequence} actionSequence
   *     Actions for a specific input source.
   *
   * @returns {InputSource}
   *     An {@link InputSource} object for the type of the
   *     action {@link Sequence}.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> is invalid.
   */
  static fromJSON(actionState, actionSequence) {
    const { id, type } = actionSequence;

    lazy.assert.string(
      id,
      lazy.pprint`Expected "id" to be a string, got ${id}`
    );

    const cls = inputSourceTypes.get(type);
    if (cls === undefined) {
      throw new lazy.error.InvalidArgumentError(
        lazy.pprint`Expected known action type, got ${type}`
      );
    }

    const sequenceInputSource = cls.fromJSON(actionState, actionSequence);
    const inputSource = actionState.getOrAddInputSource(
      id,
      sequenceInputSource
    );

    if (inputSource.type !== type) {
      throw new lazy.error.InvalidArgumentError(
        lazy.pprint`Expected input source ${id} to be ` +
          `type ${inputSource.type}, got ${type}`
      );
    }
  }
}

/**
 * Input state not associated with a specific physical device.
 */
class NullInputSource extends InputSource {
  static type = "none";

  /**
   * Unmarshals a JSON Object to a {@link NullInputSource}.
   *
   * @param {State} actionState
   *     Actions state.
   * @param {Sequence} actionSequence
   *     Actions for a specific input source.
   *
   * @returns {NullInputSource}
   *     A {@link NullInputSource} object for the type of the
   *     action {@link Sequence}.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> is invalid.
   */
  static fromJSON(actionState, actionSequence) {
    const { id } = actionSequence;

    return new this(id);
  }
}

/**
 * Input state associated with a keyboard-type device.
 */
class KeyInputSource extends InputSource {
  static type = "key";

  /**
   * Creates a new {@link KeyInputSource} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   */
  constructor(id) {
    super(id);

    this.pressed = new Set();
    this.alt = false;
    this.shift = false;
    this.ctrl = false;
    this.meta = false;
  }

  /**
   * Unmarshals a JSON Object to a {@link KeyInputSource}.
   *
   * @param {State} actionState
   *     Actions state.
   * @param {Sequence} actionSequence
   *     Actions for a specific input source.
   *
   * @returns {KeyInputSource}
   *     A {@link KeyInputSource} object for the type of the
   *     action {@link Sequence}.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> is invalid.
   */
  static fromJSON(actionState, actionSequence) {
    const { id } = actionSequence;

    return new this(id);
  }

  /**
   * Update modifier state according to |key|.
   *
   * @param {string} key
   *     Normalized key value of a modifier key.
   * @param {boolean} value
   *     Value to set the modifier attribute to.
   *
   * @throws {InvalidArgumentError}
   *     If |key| is not a modifier.
   */
  setModState(key, value) {
    if (key in MODIFIER_NAME_LOOKUP) {
      this[MODIFIER_NAME_LOOKUP[key]] = value;
    } else {
      throw new lazy.error.InvalidArgumentError(
        lazy.pprint`Expected "key" to be one of ${Object.keys(
          MODIFIER_NAME_LOOKUP
        )}, got ${key}`
      );
    }
  }

  /**
   * Check whether |key| is pressed.
   *
   * @param {string} key
   *     Normalized key value.
   *
   * @returns {boolean}
   *     True if |key| is in set of pressed keys.
   */
  isPressed(key) {
    return this.pressed.has(key);
  }

  /**
   * Add |key| to the set of pressed keys.
   *
   * @param {string} key
   *     Normalized key value.
   *
   * @returns {boolean}
   *     True if |key| is in list of pressed keys.
   */
  press(key) {
    return this.pressed.add(key);
  }

  /**
   * Remove |key| from the set of pressed keys.
   *
   * @param {string} key
   *     Normalized key value.
   *
   * @returns {boolean}
   *     True if |key| was present before removal, false otherwise.
   */
  release(key) {
    return this.pressed.delete(key);
  }
}

/**
 * Input state associated with a pointer-type device.
 */
class PointerInputSource extends InputSource {
  #initialized;
  #x;
  #y;

  static type = "pointer";

  /**
   * Creates a new {@link PointerInputSource} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {Pointer} pointer
   *     The specific {@link Pointer} type associated with this input source.
   */
  constructor(id, pointer) {
    super(id);

    this.pointer = pointer;
    this.pressed = new Set();

    this.#initialized = false;
    this.#x = 0;
    this.#y = 0;
  }

  get initialized() {
    return this.#initialized;
  }

  get x() {
    return this.#x;
  }

  get y() {
    return this.#y;
  }

  /**
   * Check whether |button| is pressed.
   *
   * @param {number} button
   *     Positive integer that refers to a mouse button.
   *
   * @returns {boolean}
   *     True if |button| is in set of pressed buttons.
   */
  isPressed(button) {
    lazy.assert.positiveInteger(
      button,
      lazy.pprint`Expected "button" to be a positive integer, got ${button}`
    );

    return this.pressed.has(button);
  }

  moveTo(x, y) {
    this.#initialized = true;
    this.#x = x;
    this.#y = y;
  }

  /**
   * Add |button| to the set of pressed keys.
   *
   * @param {number} button
   *     Positive integer that refers to a mouse button.
   */
  press(button) {
    lazy.assert.positiveInteger(
      button,
      lazy.pprint`Expected "button" to be a positive integer, got ${button}`
    );

    this.pressed.add(button);
  }

  /**
   * Remove |button| from the set of pressed buttons.
   *
   * @param {number} button
   *     A positive integer that refers to a mouse button.
   *
   * @returns {boolean}
   *     True if |button| was present before removals, false otherwise.
   */
  release(button) {
    lazy.assert.positiveInteger(
      button,
      lazy.pprint`Expected "button" to be a positive integer, got ${button}`
    );

    return this.pressed.delete(button);
  }

  /**
   * Unmarshals a JSON Object to a {@link PointerInputSource}.
   *
   * @param {State} actionState
   *     Actions state.
   * @param {Sequence} actionSequence
   *     Actions for a specific input source.
   *
   * @returns {PointerInputSource}
   *     A {@link PointerInputSource} object for the type of the
   *     action {@link Sequence}.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> is invalid.
   */
  static fromJSON(actionState, actionSequence) {
    const { id, parameters } = actionSequence;
    let pointerType = "mouse";

    if (parameters !== undefined) {
      lazy.assert.object(
        parameters,
        lazy.pprint`Expected "parameters" to be an object, got ${parameters}`
      );

      if (parameters.pointerType !== undefined) {
        pointerType = lazy.assert.string(
          parameters.pointerType,
          lazy.pprint(
            `Expected "pointerType" to be a string, got ${parameters.pointerType}`
          )
        );

        if (!["mouse", "pen", "touch"].includes(pointerType)) {
          throw new lazy.error.InvalidArgumentError(
            lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"`
          );
        }
      }
    }

    const pointerId = actionState.getPointerId(id, pointerType);
    const pointer = Pointer.fromJSON(pointerId, pointerType);

    return new this(id, pointer);
  }
}

/**
 * Input state associated with a wheel-type device.
 */
class WheelInputSource extends InputSource {
  static type = "wheel";

  /**
   * Unmarshals a JSON Object to a {@link WheelInputSource}.
   *
   * @param {State} actionState
   *     Actions state.
   * @param {Sequence} actionSequence
   *     Actions for a specific input source.
   *
   * @returns {WheelInputSource}
   *     A {@link WheelInputSource} object for the type of the
   *     action {@link Sequence}.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> is invalid.
   */
  static fromJSON(actionState, actionSequence) {
    const { id } = actionSequence;

    return new this(id);
  }
}

const inputSourceTypes = new Map();
for (const cls of [
  NullInputSource,
  KeyInputSource,
  PointerInputSource,
  WheelInputSource,
]) {
  inputSourceTypes.set(cls.type, cls);
}

/**
 * Representation of a coordinate origin
 */
class Origin {
  /**
   * Viewport coordinates of the origin of this coordinate system.
   *
   * This is overridden in subclasses to provide a class-specific origin.
   */
  getOriginCoordinates() {
    throw new Error(
      `originCoordinates not defined for ${this.constructor.name}`
    );
  }

  /**
   * Convert [x, y] coordinates to viewport coordinates.
   *
   * @param {InputSource} inputSource
   *     State of the current input device
   * @param {Array<number>} coords
   *     Coordinates [x, y] of the target relative to the origin.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Array<number>}
   *     Viewport coordinates [x, y].
   */
  async getTargetCoordinates(inputSource, coords, options) {
    const [x, y] = coords;
    const origin = await this.getOriginCoordinates(inputSource, options);

    return [origin.x + x, origin.y + y];
  }

  /**
   * Unmarshals a JSON Object to an {@link Origin}.
   *
   * @param {string|Element=} origin
   *     Type of origin, one of "viewport", "pointer", {@link Element}
   *     or undefined.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<Origin>}
   *     Promise that resolves to an {@link Origin} object
   *     representing the origin.
   *
   * @throws {InvalidArgumentError}
   *     If <code>origin</code> isn't a valid origin.
   */
  static async fromJSON(origin, options) {
    const { context, getElementOrigin, isElementOrigin } = options;

    if (origin === undefined || origin === "viewport") {
      return new ViewportOrigin();
    }

    if (origin === "pointer") {
      return new PointerOrigin();
    }

    if (isElementOrigin(origin)) {
      const element = await getElementOrigin(origin, context);

      return new ElementOrigin(element);
    }

    throw new lazy.error.InvalidArgumentError(
      `Expected "origin" to be undefined, "viewport", "pointer", ` +
        lazy.pprint`or an element, got: ${origin}`
    );
  }
}

class ViewportOrigin extends Origin {
  getOriginCoordinates() {
    return { x: 0, y: 0 };
  }
}

class PointerOrigin extends Origin {
  getOriginCoordinates(inputSource) {
    return { x: inputSource.x, y: inputSource.y };
  }
}

/**
 * Representation of an element origin.
 */
class ElementOrigin extends Origin {
  /**
   * Creates a new {@link ElementOrigin} instance.
   *
   * @param {Element} element
   *     The element providing the coordinate origin.
   */
  constructor(element) {
    super();

    this.element = element;
  }

  /**
   * Retrieve the coordinates of the origin's in-view center point.
   *
   * @param {InputSource} _inputSource
   *     [unused] Current input device.
   * @param {ActionsOptions} options
   *
   * @returns {Promise<Array<number>>}
   *     Promise that resolves to the coordinates [x, y].
   */
  async getOriginCoordinates(_inputSource, options) {
    const { context, getClientRects, getInViewCentrePoint } = options;

    const clientRects = await getClientRects(this.element, context);

    // The spec doesn't handle this case: https://github.com/w3c/webdriver/issues/1642
    if (!clientRects.length) {
      throw new lazy.error.MoveTargetOutOfBoundsError(
        lazy.pprint`Origin element ${this.element} is not displayed`
      );
    }

    /*
     * Note: This diverges from the Webdriver spec. See more information in
     * https://github.com/w3c/webdriver/issues/1961
     * */
    return getInViewCentrePoint(
      lazy.dom.getFirstNonZeroRect(clientRects),
      context
    );
  }
}

/**
 * Represents the behavior of a single input source at a single
 * point in time.
 */
class Action {
  /** Type of the input source associated with this action */
  static type = null;
  /** Type of action specific to the input source */
  static subtype = null;
  /** Whether this kind of action affects the overall duration of a tick */
  affectsWallClockTime = false;

  /**
   * Creates a new {@link Action} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   */
  constructor(id) {
    this.id = id;
    this.type = this.constructor.type;
    this.subtype = this.constructor.subtype;
  }

  toString() {
    return `[${this.constructor.name} ${this.type}:${this.subtype}]`;
  }

  /**
   * Dispatch the action to the relevant window.
   *
   * This is overridden by subclasses to implement the type-specific
   * dispatch of the action.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  dispatch() {
    throw new Error(
      `Action subclass ${this.constructor.name} must override dispatch()`
    );
  }

  /**
   * Unmarshals a JSON Object to an {@link Action}.
   *
   * @param {string} type
   *     Type of {@link InputSource}.
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<Action>}
   *     Promise that resolves to an action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static fromJSON(type, id, actionItem, options) {
    lazy.assert.object(
      actionItem,
      lazy.pprint`Expected "action" to be an object, got ${actionItem}`
    );

    const subtype = actionItem.type;
    const subtypeMap = actionTypes.get(type);

    if (subtypeMap === undefined) {
      throw new lazy.error.InvalidArgumentError(
        lazy.pprint`Expected known action type, got ${type}`
      );
    }

    let cls = subtypeMap.get(subtype);
    // Non-device specific actions can happen for any action type
    if (cls === undefined) {
      cls = actionTypes.get("none").get(subtype);
    }
    if (cls === undefined) {
      throw new lazy.error.InvalidArgumentError(
        lazy.pprint`Expected known subtype for type ${type}, got ${subtype}`
      );
    }

    return cls.fromJSON(id, actionItem, options);
  }
}

/**
 * Action not associated with a specific input device.
 */
class NullAction extends Action {
  static type = "none";
}

/**
 * Action that waits for a given duration.
 */
class PauseAction extends NullAction {
  static subtype = "pause";
  affectsWallClockTime = true;

  /**
   * Creates a new {@link PauseAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {number} options.duration
   *     Time to pause, in ms.
   */
  constructor(id, options) {
    super(id);

    const { duration } = options;
    this.duration = duration;
  }

  /**
   * Dispatch pause action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     Length of the current tick, in ms.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  dispatch(state, inputSource, tickDuration) {
    const ms = this.duration ?? tickDuration;

    lazy.logger.trace(
      ` Dispatch ${this.constructor.name} with ${this.id} ${ms}`
    );

    return lazy.Sleep(ms);
  }

  /**
   * Unmarshals a JSON Object to a {@link PauseAction}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-a-null-action
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   *
   * @returns {PauseAction}
   *     A pause action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static fromJSON(id, actionItem) {
    const { duration } = actionItem;

    if (duration !== undefined) {
      lazy.assert.positiveInteger(
        duration,
        lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
      );
    }

    return new this(id, { duration });
  }
}

/**
 * Action associated with a keyboard input device
 */
class KeyAction extends Action {
  static type = "key";

  /**
   * Creates a new {@link KeyAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {string} options.value
   *     The key character.
   */
  constructor(id, options) {
    super(id);

    const { value } = options;

    this.value = value;
  }

  getEventData(inputSource) {
    let value = this.value;

    if (inputSource.shift) {
      value = lazy.keyData.getShiftedKey(value);
    }

    return new KeyEventData(value);
  }

  /**
   * Unmarshals a JSON Object to a {@link KeyAction}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-a-key-action
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   *
   * @returns {KeyAction}
   *     A key action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static fromJSON(id, actionItem) {
    const { value } = actionItem;

    lazy.assert.string(
      value,
      'Expected "value" to be a string that represents single code point ' +
        lazy.pprint`or grapheme cluster, got ${value}`
    );

    let segmenter = new Intl.Segmenter();
    lazy.assert.that(v => {
      let graphemeIterator = segmenter.segment(v)[Symbol.iterator]();
      // We should have exactly one grapheme cluster, so the first iterator
      // value must be defined, but the second one must be undefined
      return (
        graphemeIterator.next().value !== undefined &&
        graphemeIterator.next().value === undefined
      );
    }, `Expected "value" to be a string that represents single code point or grapheme cluster, got "${value}"`)(
      value
    );

    return new this(id, { value });
  }
}

/**
 * Action equivalent to pressing a key on a keyboard.
 *
 * @param {string} id
 *     Id of {@link InputSource}.
 * @param {object} options
 * @param {string} options.value
 *     The key character.
 */
class KeyDownAction extends KeyAction {
  static subtype = "keyDown";

  /**
   * Dispatch a keydown action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { context, dispatchEvent } = options;

    lazy.logger.trace(
      ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
    );

    const keyEvent = this.getEventData(inputSource);
    keyEvent.repeat = inputSource.isPressed(keyEvent.key);
    inputSource.press(keyEvent.key);

    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
      inputSource.setModState(keyEvent.key, true);
    }

    keyEvent.update(state, inputSource);

    await dispatchEvent("synthesizeKeyDown", context, {
      x: inputSource.x,
      y: inputSource.y,
      eventData: keyEvent,
    });

    // Append a copy of |this| with keyUp subtype if event dispatched
    state.inputsToCancel.push(new KeyUpAction(this.id, this));
  }
}

/**
 * Action equivalent to releasing a key on a keyboard.
 *
 * @param {string} id
 *     Id of {@link InputSource}.
 * @param {object} options
 * @param {string} options.value
 *     The key character.
 */
class KeyUpAction extends KeyAction {
  static subtype = "keyUp";

  /**
   * Dispatch a keyup action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { context, dispatchEvent } = options;

    lazy.logger.trace(
      ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
    );

    const keyEvent = this.getEventData(inputSource);

    if (!inputSource.isPressed(keyEvent.key)) {
      return;
    }

    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
      inputSource.setModState(keyEvent.key, false);
    }

    inputSource.release(keyEvent.key);
    keyEvent.update(state, inputSource);

    await dispatchEvent("synthesizeKeyUp", context, {
      x: inputSource.x,
      y: inputSource.y,
      eventData: keyEvent,
    });
  }
}

/**
 * Action associated with a pointer input device
 */
class PointerAction extends Action {
  static type = "pointer";

  /**
   * Creates a new {@link PointerAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {number=} options.width
   *     Width of pointer in pixels.
   * @param {number=} options.height
   *     Height of pointer in pixels.
   * @param {number=} options.pressure
   *     Pressure of pointer.
   * @param {number=} options.tangentialPressure
   *     Tangential pressure of pointer.
   * @param {number=} options.tiltX
   *     X tilt angle of pointer.
   * @param {number=} options.tiltY
   *     Y tilt angle of pointer.
   * @param {number=} options.twist
   *     Twist angle of pointer.
   * @param {number=} options.altitudeAngle
   *     Altitude angle of pointer.
   * @param {number=} options.azimuthAngle
   *     Azimuth angle of pointer.
   */
  constructor(id, options) {
    super(id);

    const {
      width,
      height,
      pressure,
      tangentialPressure,
      tiltX,
      tiltY,
      twist,
      altitudeAngle,
      azimuthAngle,
    } = options;

    this.width = width;
    this.height = height;
    this.pressure = pressure;
    this.tangentialPressure = tangentialPressure;
    this.tiltX = tiltX;
    this.tiltY = tiltY;
    this.twist = twist;
    this.altitudeAngle = altitudeAngle;
    this.azimuthAngle = azimuthAngle;
  }

  /**
   * Validate properties common to all pointer types.
   *
   * @param {object} actionItem
   *     Object representing a single pointer action.
   *
   * @returns {object}
   *     Properties of the pointer action; contains `width`, `height`,
   *     `pressure`, `tangentialPressure`, `tiltX`, `tiltY`, `twist`,
   *     `altitudeAngle`, and `azimuthAngle`.
   */
  static validateCommon(actionItem) {
    const {
      width,
      height,
      pressure,
      tangentialPressure,
      tiltX,
      tiltY,
      twist,
      altitudeAngle,
      azimuthAngle,
    } = actionItem;

    if (width !== undefined) {
      lazy.assert.positiveInteger(
        width,
        lazy.pprint`Expected "width" to be a positive integer, got ${width}`
      );
    }
    if (height !== undefined) {
      lazy.assert.positiveInteger(
        height,
        lazy.pprint`Expected "height" to be a positive integer, got ${height}`
      );
    }
    if (pressure !== undefined) {
      lazy.assert.numberInRange(
        pressure,
        [0, 1],
        lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}`
      );
    }
    if (tangentialPressure !== undefined) {
      lazy.assert.numberInRange(
        tangentialPressure,
        [-1, 1],
        'Expected "tangentialPressure" to be in range -1 to 1, ' +
          lazy.pprint`got ${tangentialPressure}`
      );
    }
    if (tiltX !== undefined) {
      lazy.assert.integerInRange(
        tiltX,
        [-90, 90],
        lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}`
      );
    }
    if (tiltY !== undefined) {
      lazy.assert.integerInRange(
        tiltY,
        [-90, 90],
        lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}`
      );
    }
    if (twist !== undefined) {
      lazy.assert.integerInRange(
        twist,
        [0, 359],
        lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}`
      );
    }
    if (altitudeAngle !== undefined) {
      lazy.assert.numberInRange(
        altitudeAngle,
        [0, Math.PI / 2],
        'Expected "altitudeAngle" to be in range 0 to π/2, ' +
          lazy.pprint`got ${altitudeAngle}`
      );
    }
    if (azimuthAngle !== undefined) {
      lazy.assert.numberInRange(
        azimuthAngle,
        [0, 2 * Math.PI],
        'Expected "azimuthAngle" to be in range 0 to 2*π, ' +
          lazy.pprint`got ${azimuthAngle}`
      );
    }

    return {
      width,
      height,
      pressure,
      tangentialPressure,
      tiltX,
      tiltY,
      twist,
      altitudeAngle,
      azimuthAngle,
    };
  }
}

/**
 * Action associated with a pointer input device being depressed.
 */
class PointerDownAction extends PointerAction {
  static subtype = "pointerDown";

  /**
   * Creates a new {@link PointerAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {number} options.button
   *     Button being pressed. For devices without buttons (e.g. touch),
   *     this should be 0.
   * @param {number=} options.width
   *     Width of pointer in pixels.
   * @param {number=} options.height
   *     Height of pointer in pixels.
   * @param {number=} options.pressure
   *     Pressure of pointer.
   * @param {number=} options.tangentialPressure
   *     Tangential pressure of pointer.
   * @param {number=} options.tiltX
   *     X tilt angle of pointer.
   * @param {number=} options.tiltY
   *     Y tilt angle of pointer.
   * @param {number=} options.twist
   *     Twist angle of pointer.
   * @param {number=} options.altitudeAngle
   *     Altitude angle of pointer.
   * @param {number=} options.azimuthAngle
   *     Azimuth angle of pointer.
   */
  constructor(id, options) {
    super(id, options);

    const { button } = options;
    this.button = button;
  }

  /**
   * Dispatch a pointerdown action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    if (inputSource.isPressed(this.button)) {
      return;
    }

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` +
        `button: ${this.button} async: ${actions.useAsyncMouseEvents}`
    );

    inputSource.press(this.button);

    await inputSource.pointer.pointerDown(state, inputSource, this, options);

    // Append a copy of |this| with pointerUp subtype if event dispatched
    state.inputsToCancel.push(new PointerUpAction(this.id, this));
  }

  /**
   * Unmarshals a JSON Object to a {@link PointerDownAction}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   *
   * @returns {PointerDownAction}
   *     A pointer down action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static fromJSON(id, actionItem) {
    const { button } = actionItem;

    lazy.assert.positiveInteger(
      button,
      lazy.pprint`Expected "button" to be a positive integer, got ${button}`
    );

    const props = PointerAction.validateCommon(actionItem);
    props.button = button;

    return new this(id, props);
  }
}

/**
 * Action associated with a pointer input device being released.
 */
class PointerUpAction extends PointerAction {
  static subtype = "pointerUp";

  /**
   * Creates a new {@link PointerUpAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {number} options.button
   *     Button being pressed. For devices without buttons (e.g. touch),
   *     this should be 0.
   * @param {number=} options.width
   *     Width of pointer in pixels.
   * @param {number=} options.height
   *     Height of pointer in pixels.
   * @param {number=} options.pressure
   *     Pressure of pointer.
   * @param {number=} options.tangentialPressure
   *     Tangential pressure of pointer.
   * @param {number=} options.tiltX
   *     X tilt angle of pointer.
   * @param {number=} options.tiltY
   *     Y tilt angle of pointer.
   * @param {number=} options.twist
   *     Twist angle of pointer.
   * @param {number=} options.altitudeAngle
   *     Altitude angle of pointer.
   * @param {number=} options.azimuthAngle
   *     Azimuth angle of pointer.
   */
  constructor(id, options) {
    super(id, options);

    const { button } = options;
    this.button = button;
  }

  /**
   * Dispatch a pointerup action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    if (!inputSource.isPressed(this.button)) {
      return;
    }

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` +
        `button: ${this.button} async: ${actions.useAsyncMouseEvents}`
    );

    inputSource.release(this.button);

    await inputSource.pointer.pointerUp(state, inputSource, this, options);
  }

  /**
   * Unmarshals a JSON Object to a {@link PointerUpAction}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   *
   * @returns {PointerUpAction}
   *     A pointer up action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static fromJSON(id, actionItem) {
    const { button } = actionItem;

    lazy.assert.positiveInteger(
      button,
      lazy.pprint`Expected "button" to be a positive integer, got ${button}`
    );

    const props = PointerAction.validateCommon(actionItem);
    props.button = button;

    return new this(id, props);
  }
}

/**
 * Action associated with a pointer input device being moved.
 */
class PointerMoveAction extends PointerAction {
  static subtype = "pointerMove";
  affectsWallClockTime = true;

  /**
   * Creates a new {@link PointerMoveAction} instance.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {number} options.origin
   *     {@link Origin} of target coordinates.
   * @param {number} options.x
   *     X value of scroll coordinates.
   * @param {number} options.y
   *     Y value of scroll coordinates.
   * @param {number=} options.width
   *     Width of pointer in pixels.
   * @param {number=} options.height
   *     Height of pointer in pixels.
   * @param {number=} options.pressure
   *     Pressure of pointer.
   * @param {number=} options.tangentialPressure
   *     Tangential pressure of pointer.
   * @param {number=} options.tiltX
   *     X tilt angle of pointer.
   * @param {number=} options.tiltY
   *     Y tilt angle of pointer.
   * @param {number=} options.twist
   *     Twist angle of pointer.
   * @param {number=} options.altitudeAngle
   *     Altitude angle of pointer.
   * @param {number=} options.azimuthAngle
   *     Azimuth angle of pointer.
   */
  constructor(id, options) {
    super(id, options);

    const { duration, origin, x, y } = options;
    this.duration = duration;

    this.origin = origin;
    this.x = x;
    this.y = y;
  }

  /**
   * Dispatch a pointermove action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { assertInViewPort, context, toBrowserWindowCoordinates } = options;

    let moveCoordinates = await this.origin.getTargetCoordinates(
      inputSource,
      [this.x, this.y],
      options
    );

    await assertInViewPort(moveCoordinates, context);

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` +
        `x: ${moveCoordinates[0]} y: ${moveCoordinates[1]} ` +
        `async: ${actions.useAsyncMouseEvents}`
    );

    // Only convert coordinates if these are for a content process, and are not
    // relative to an already initialized pointer source.
    if (
      !(this.origin instanceof PointerOrigin && inputSource.initialized) &&
      context.isContent &&
      actions.useAsyncMouseEvents
    ) {
      moveCoordinates = await toBrowserWindowCoordinates(
        moveCoordinates,
        context
      );
    }

    return moveOverTime(
      [[inputSource.x, inputSource.y]],
      [moveCoordinates],
      this.duration ?? tickDuration,
      async _target =>
        await this.performPointerMoveStep(state, inputSource, _target, options)
    );
  }

  /**
   * Perform one part of a pointer move corresponding to a specific emitted event.
   *
   * @param {State} state
   *     The {@link State} of actions.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {Array<Array<number>>} targets
   *     Array of [x, y] arrays specifying the viewport coordinates to move to.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   */
  async performPointerMoveStep(state, inputSource, targets, options) {
    if (targets.length !== 1) {
      throw new Error(
        "PointerMoveAction.performPointerMoveStep requires a single target"
      );
    }

    const target = targets[0];
    if (target[0] == inputSource.x && target[1] == inputSource.y) {
      return;
    }

    lazy.logger.trace(
      `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}`
    );

    await inputSource.pointer.pointerMove(
      state,
      inputSource,
      this,
      target[0],
      target[1],
      options
    );

    inputSource.moveTo(target[0], target[1]);
  }

  /**
   * Unmarshals a JSON Object to a {@link PointerMoveAction}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-move-action
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<PointerMoveAction>}
   *     A pointer move action that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static async fromJSON(id, actionItem, options) {
    const { duration, origin, x, y } = actionItem;

    if (duration !== undefined) {
      lazy.assert.positiveInteger(
        duration,
        lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
      );
    }

    const originObject = await Origin.fromJSON(origin, options);

    lazy.assert.number(
      x,
      lazy.pprint`Expected "x" to be a finite number, got ${x}`
    );
    lazy.assert.number(
      y,
      lazy.pprint`Expected "y" to be a finite number, got ${y}`
    );

    const props = PointerAction.validateCommon(actionItem);
    props.duration = duration;
    props.origin = originObject;
    props.x = x;
    props.y = y;

    return new this(id, props);
  }
}

/**
 * Action associated with a wheel input device.
 */
class WheelAction extends Action {
  static type = "wheel";
}

/**
 * Action associated with scrolling a scroll wheel
 */
class WheelScrollAction extends WheelAction {
  static subtype = "scroll";
  affectsWallClockTime = true;

  /**
   * Creates a new {@link WheelScrollAction} instance.
   *
   * @param {number} id
   *     Id of {@link InputSource}.
   * @param {object} options
   * @param {Origin} options.origin
   *     {@link Origin} of target coordinates.
   * @param {number} options.x
   *     X value of scroll coordinates.
   * @param {number} options.y
   *     Y value of scroll coordinates.
   * @param {number} options.deltaX
   *     Number of CSS pixels to scroll in X direction.
   * @param {number} options.deltaY
   *     Number of CSS pixels to scroll in Y direction.
   */
  constructor(id, options) {
    super(id);

    const { duration, origin, x, y, deltaX, deltaY } = options;

    this.duration = duration;
    this.origin = origin;
    this.x = x;
    this.y = y;
    this.deltaX = deltaX;
    this.deltaY = deltaY;
  }

  /**
   * Unmarshals a JSON Object to a {@link WheelScrollAction}.
   *
   * @param {string} id
   *     Id of {@link InputSource}.
   * @param {object} actionItem
   *     Object representing a single action.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<WheelScrollAction>}
   *     Promise that resolves to a wheel scroll action
   *     that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionItem</code> attribute is invalid.
   */
  static async fromJSON(id, actionItem, options) {
    const { duration, origin, x, y, deltaX, deltaY } = actionItem;

    if (duration !== undefined) {
      lazy.assert.positiveInteger(
        duration,
        lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
      );
    }

    const originObject = await Origin.fromJSON(origin, options);

    if (originObject instanceof PointerOrigin) {
      throw new lazy.error.InvalidArgumentError(
        `"pointer" origin not supported for "wheel" input source.`
      );
    }

    lazy.assert.integer(
      x,
      lazy.pprint`Expected "x" to be an Integer, got ${x}`
    );
    lazy.assert.integer(
      y,
      lazy.pprint`Expected "y" to be an Integer, got ${y}`
    );
    lazy.assert.integer(
      deltaX,
      lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}`
    );
    lazy.assert.integer(
      deltaY,
      lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}`
    );

    return new this(id, {
      duration,
      origin: originObject,
      x,
      y,
      deltaX,
      deltaY,
    });
  }

  /**
   * Dispatch a wheel scroll action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { assertInViewPort, context, toBrowserWindowCoordinates } = options;

    let scrollCoordinates = await this.origin.getTargetCoordinates(
      inputSource,
      [this.x, this.y],
      options
    );

    await assertInViewPort(scrollCoordinates, context);

    // Only convert coordinates if those are for a content process
    if (context.isContent && actions.useAsyncWheelEvents) {
      const origin = await toBrowserWindowCoordinates(
        scrollCoordinates,
        context
      );

      // The deltas are specified in CSS pixels by the WebDriver specification,
      // but the synthesized wheel event is dispatched in the top-level widget's
      // coordinate space, which has the visual viewport scale (resolution)
      // applied. Convert the deltas through the same transform as the origin,
      // so that a non-unit resolution doesn't make the resulting scroll offset
      // wrong by that factor. The translation cancels in the difference, leaving
      // only the scale.
      const target = await toBrowserWindowCoordinates(
        [
          scrollCoordinates[0] + this.deltaX,
          scrollCoordinates[1] + this.deltaY,
        ],
        context
      );
      this.deltaX = target[0] - origin[0];
      this.deltaY = target[1] - origin[1];

      scrollCoordinates = origin;
    }

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} with id: ${this.id} ` +
        `pageX: ${scrollCoordinates[0]} pageY: ${scrollCoordinates[1]} ` +
        `deltaX: ${this.deltaX} deltaY: ${this.deltaY} async: ${actions.useAsyncWheelEvents}`
    );

    const startX = 0;
    const startY = 0;
    // This is an action-local state that holds the amount of scroll completed
    const deltaPosition = [startX, startY];

    return moveOverTime(
      [[startX, startY]],
      [[this.deltaX, this.deltaY]],
      this.duration ?? tickDuration,
      async deltaTarget =>
        await this.performOneWheelScroll(
          state,
          scrollCoordinates,
          deltaPosition,
          deltaTarget,
          options
        )
    );
  }

  /**
   * Perform one part of a wheel scroll corresponding to a specific emitted event.
   *
   * @param {State} state
   *     The {@link State} of actions.
   * @param {Array<number>} scrollCoordinates
   *     The viewport coordinates [x, y] of the scroll action.
   * @param {Array<number>} deltaPosition
   *     [deltaX, deltaY] coordinates of the scroll before this event.
   * @param {Array<Array<number>>} deltaTargets
   *     Array of [deltaX, deltaY] coordinates to scroll to.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   */
  async performOneWheelScroll(
    state,
    scrollCoordinates,
    deltaPosition,
    deltaTargets,
    options
  ) {
    const { context, dispatchEvent } = options;

    if (deltaTargets.length !== 1) {
      throw new Error("Can only scroll one wheel at a time");
    }
    if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) {
      return;
    }

    const deltaTarget = deltaTargets[0];
    const deltaX = deltaTarget[0] - deltaPosition[0];
    const deltaY = deltaTarget[1] - deltaPosition[1];
    const eventData = new WheelEventData({
      deltaX,
      deltaY,
      deltaZ: 0,
    });
    eventData.update(state);

    lazy.logger.trace(
      `WheelScrollAction.performOneWheelScrollStep [${deltaX},${deltaY}]`
    );

    await dispatchEvent("synthesizeWheelAtPoint", context, {
      x: scrollCoordinates[0],
      y: scrollCoordinates[1],
      eventData,
    });

    // Update the current scroll position for the caller
    deltaPosition[0] = deltaTarget[0];
    deltaPosition[1] = deltaTarget[1];
  }
}

/**
 * Group of actions representing behavior of all touch pointers during
 * a single tick.
 *
 * For touch pointers, we need to call into the platform once with all
 * the actions so that they are regarded as simultaneous. This means
 * we don't use the `dispatch()` method on the underlying actions, but
 * instead use one on this group object.
 */
class TouchActionGroup {
  static type = null;

  /**
   * Creates a new {@link TouchActionGroup} instance.
   */
  constructor() {
    this.type = this.constructor.type;
    this.actions = new Map();
  }

  static forType(type) {
    const cls = touchActionGroupTypes.get(type);

    return new cls();
  }

  /**
   * Add action corresponding to a specific pointer to the group.
   *
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {Action} action
   *     Action to add to the group.
   */
  addPointer(inputSource, action) {
    if (action.subtype !== this.type) {
      throw new Error(
        `Added action of unexpected type, got ${action.subtype}, expected ${this.type}`
      );
    }

    this.actions.set(action.id, [inputSource, action]);
  }

  /**
   * Dispatch the action group to the relevant window.
   *
   * This is overridden by subclasses to implement the type-specific
   * dispatch of the action.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  dispatch() {
    throw new Error(
      "TouchActionGroup subclass missing dispatch implementation"
    );
  }
}

/**
 * Calculate and convert coordinates for a touch action.
 *
 * @param {PointerAction} action
 *     The action containing x, y, and origin properties.
 * @param {InputSource} actionInputSource
 *     The input source for this action.
 * @param {ActionsOptions} options
 *     Configuration of actions dispatch.
 *
 * @returns {Promise<Array<number>>}
 *     Array of [x, y] coordinates, converted if necessary.
 */
async function getTouchCoordinates(action, actionInputSource, options) {
  const { assertInViewPort, context, toBrowserWindowCoordinates } = options;

  let target = await action.origin.getTargetCoordinates(
    actionInputSource,
    [action.x, action.y],
    options
  );

  await assertInViewPort(target, context);

  // Only convert coordinates if these are for a content process, and are not
  // relative to an already initialized pointer source.
  if (
    !(
      action.origin instanceof PointerOrigin && actionInputSource.initialized
    ) &&
    context.isContent &&
    actions.useAsyncTouchEvents
  ) {
    target = await toBrowserWindowCoordinates(target, context);
  }

  return target;
}

/**
 * Group of actions representing behavior of all touch pointers
 * depressed during a single tick.
 */
class PointerDownTouchActionGroup extends TouchActionGroup {
  static type = "pointerDown";

  /**
   * Dispatch a pointerdown touch action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { context, dispatchEvent, toBrowserWindowCoordinates } = options;

    if (inputSource !== null) {
      throw new Error(
        "Expected null inputSource for PointerDownTouchActionGroup.dispatch"
      );
    }

    // Only include pointers that are not already depressed
    const filteredActions = Array.from(this.actions.values()).filter(
      ([actionInputSource, action]) =>
        !actionInputSource.isPressed(action.button)
    );

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} with ${filteredActions.map(x => x[1].id)} async: ${actions.useAsyncTouchEvents}`
    );

    if (filteredActions.length) {
      const eventData = new TouchEventData("touchstart");

      for (const [actionInputSource, action] of filteredActions) {
        // If the pointer hasn't been moved yet, its initial (0, 0) viewport
        // coordinates need to be converted to browser window space for async
        // touch dispatch in a content process.
        if (
          !actionInputSource.initialized &&
          context.isContent &&
          actions.useAsyncTouchEvents
        ) {
          const target = await toBrowserWindowCoordinates(
            [actionInputSource.x, actionInputSource.y],
            context
          );
          actionInputSource.moveTo(target[0], target[1]);
        }

        eventData.addPointerEventData(actionInputSource, action);
        actionInputSource.press(action.button);
        eventData.update(state, actionInputSource);
      }

      // Touch start events must include all depressed touch pointers
      for (const [id, pointerInputSource] of state.inputSourcesByType(
        "pointer"
      )) {
        if (
          pointerInputSource.pointer.type === "touch" &&
          !this.actions.has(id) &&
          pointerInputSource.isPressed(0)
        ) {
          eventData.addPointerEventData(pointerInputSource, {});
          eventData.update(state, pointerInputSource);
        }
      }

      await dispatchEvent("synthesizeTouchAtPoint", context, { eventData });

      for (const [, action] of filteredActions) {
        // Append a copy of |action| with pointerUp subtype if event dispatched
        state.inputsToCancel.push(new PointerUpAction(action.id, action));
      }
    }
  }
}

/**
 * Group of actions representing behavior of all touch pointers
 * moved during a single tick.
 */
class PointerMoveTouchActionGroup extends TouchActionGroup {
  static type = "pointerMove";

  /**
   * Dispatch a pointermove touch action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    if (inputSource !== null) {
      throw new Error(
        "Expected null inputSource for PointerMoveTouchActionGroup.dispatch"
      );
    }

    const moveActions = Array.from(this.actions.values());

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} with ${moveActions.map(
        x => x[1].id
      )} async: ${actions.useAsyncTouchEvents}`
    );

    let startCoords = [];
    let targetCoords = [];

    for (const [actionInputSource, action] of moveActions) {
      const target = await getTouchCoordinates(
        action,
        actionInputSource,
        options
      );

      startCoords.push([actionInputSource.x, actionInputSource.y]);
      targetCoords.push(target);
    }

    // Touch move events must include all depressed touch pointers, even if they are static
    // This can end up generating pointermove events even for static pointers, but Gecko
    // seems to generate a lot of pointermove events anyway, so this seems like the lesser
    // problem.
    // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206
    const staticTouchPointers = [];
    for (const [id, pointerInputSource] of state.inputSourcesByType(
      "pointer"
    )) {
      if (
        pointerInputSource.pointer.type === "touch" &&
        !this.actions.has(id) &&
        pointerInputSource.isPressed(0)
      ) {
        staticTouchPointers.push(pointerInputSource);
      }
    }

    return moveOverTime(
      startCoords,
      targetCoords,
      this.duration ?? tickDuration,
      async currentTargetCoords =>
        await this.performPointerMoveStep(
          state,
          staticTouchPointers,
          currentTargetCoords,
          options
        )
    );
  }

  /**
   * Perform one part of a pointer move corresponding to a specific emitted event.
   *
   * @param {State} state
   *     The {@link State} of actions.
   * @param {Array<PointerInputSource>} staticTouchPointers
   *     Array of PointerInputSource objects for pointers that aren't
   *     involved in the touch move.
   * @param {Array<Array<number>>} targetCoords
   *     Array of [x, y] arrays specifying the viewport coordinates to move to.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   */
  async performPointerMoveStep(
    state,
    staticTouchPointers,
    targetCoords,
    options
  ) {
    const { context, dispatchEvent } = options;

    if (targetCoords.length !== this.actions.size) {
      throw new Error("Expected one target per pointer");
    }

    const perPointerData = Array.from(this.actions.values()).map(
      ([inputSource, action], i) => {
        const target = targetCoords[i];
        return [inputSource, action, target];
      }
    );
    const reachedTarget = perPointerData.every(
      ([inputSource, , target]) =>
        target[0] === inputSource.x && target[1] === inputSource.y
    );

    if (reachedTarget) {
      return;
    }

    const eventData = new TouchEventData("touchmove");
    for (const [inputSource, action, target] of perPointerData) {
      inputSource.moveTo(target[0], target[1]);
      eventData.addPointerEventData(inputSource, action);
      eventData.update(state, inputSource);
    }

    for (const inputSource of staticTouchPointers) {
      eventData.addPointerEventData(inputSource, {});
      eventData.update(state, inputSource);
    }

    await dispatchEvent("synthesizeTouchAtPoint", context, { eventData });
  }
}

/**
 * Group of actions representing behavior of all touch pointers
 * released during a single tick.
 */
class PointerUpTouchActionGroup extends TouchActionGroup {
  static type = "pointerUp";

  /**
   * Dispatch a pointerup touch action.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {number} tickDuration
   *     [unused] Length of the current tick, in ms.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action is complete.
   */
  async dispatch(state, inputSource, tickDuration, options) {
    const { context, dispatchEvent } = options;

    if (inputSource !== null) {
      throw new Error(
        "Expected null inputSource for PointerUpTouchActionGroup.dispatch"
      );
    }

    // Only include pointers that are not already depressed
    const filteredActions = Array.from(this.actions.values()).filter(
      ([actionInputSource, action]) =>
        actionInputSource.isPressed(action.button)
    );

    lazy.logger.trace(
      `Dispatch ${this.constructor.name} with ${filteredActions.map(x => x[1].id)} async: ${actions.useAsyncTouchEvents}`
    );

    if (filteredActions.length) {
      const eventData = new TouchEventData("touchend");
      for (const [actionInputSource, action] of filteredActions) {
        eventData.addPointerEventData(actionInputSource, action);
        actionInputSource.release(action.button);
        eventData.update(state, actionInputSource);
      }

      await dispatchEvent("synthesizeTouchAtPoint", context, { eventData });
    }
  }
}

const touchActionGroupTypes = new Map();
for (const cls of [
  PointerDownTouchActionGroup,
  PointerUpTouchActionGroup,
  PointerMoveTouchActionGroup,
]) {
  touchActionGroupTypes.set(cls.type, cls);
}

/**
 * Split a transition from startCoord to targetCoord linearly over duration.
 *
 * startCoords and targetCoords are lists of [x,y] positions in some space
 * (e.g. screen position or scroll delta). This function will linearly
 * interpolate intermediate positions, sending out roughly one event
 * per frame to simulate moving between startCoord and targetCoord in
 * a time of tickDuration milliseconds. The callback function is
 * responsible for actually emitting the event, given the current
 * position in the coordinate space.
 *
 * @param {Array<Array>} startCoords
 *     Array of initial [x, y] coordinates for each input source involved
 *     in the move.
 * @param {Array<Array<number>>} targetCoords
 *     Array of target [x, y] coordinates for each input source involved
 *     in the move.
 * @param {number} duration
 *     Time in ms the move will take.
 * @param {Function} callback
 *     Function that actually performs the move. This takes a single parameter
 *     which is an array of [x, y] coordinates corresponding to the move
 *     targets.
 */
async function moveOverTime(startCoords, targetCoords, duration, callback) {
  lazy.logger.trace(
    `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}`
  );

  if (startCoords.length !== targetCoords.length) {
    throw new Error(
      "Expected equal number of start coordinates and target coordinates"
    );
  }

  if (
    !startCoords.every(item => item.length == 2) ||
    !targetCoords.every(item => item.length == 2)
  ) {
    throw new Error(
      "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates."
    );
  }

  if (duration === 0) {
    // transition to destination in one step
    await callback(targetCoords);
    return;
  }

  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  // interval between transitions in ms, based on common vsync
  const fps60 = 17;

  const distances = targetCoords.map((targetCoord, i) => {
    const startCoord = startCoords[i];
    return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]];
  });
  const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
  const startTime = Date.now();
  const transitions = (async () => {
    // wait |fps60| ms before performing first incremental transition
    await new Promise(resolveTimer =>
      timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
    );

    let durationRatio = Math.floor(Date.now() - startTime) / duration;
    const epsilon = fps60 / duration / 10;
    while (1 - durationRatio > epsilon) {
      const intermediateTargets = startCoords.map((startCoord, i) => {
        let distance = distances[i];
        return [
          Math.floor(durationRatio * distance[0] + startCoord[0]),
          Math.floor(durationRatio * distance[1] + startCoord[1]),
        ];
      });

      await Promise.all([
        callback(intermediateTargets),

        // wait |fps60| ms before performing next transition
        new Promise(resolveTimer =>
          timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
        ),
      ]);

      durationRatio = Math.floor(Date.now() - startTime) / duration;
    }
  })();

  await transitions;

  // perform last transition after all incremental moves are resolved and
  // durationRatio is close enough to 1
  await callback(targetCoords);
}

const actionTypes = new Map();
for (const cls of [
  KeyDownAction,
  KeyUpAction,
  PauseAction,
  PointerDownAction,
  PointerUpAction,
  PointerMoveAction,
  WheelScrollAction,
]) {
  if (!actionTypes.has(cls.type)) {
    actionTypes.set(cls.type, new Map());
  }
  actionTypes.get(cls.type).set(cls.subtype, cls);
}

/**
 * Implementation of the behavior of a specific type of pointer.
 *
 * @abstract
 */
class Pointer {
  /** Type of pointer */
  static type = null;

  /**
   * Creates a new {@link Pointer} instance.
   *
   * @param {number} id
   *     Numeric pointer id.
   */
  constructor(id) {
    this.id = id;
    this.type = this.constructor.type;
  }

  /**
   * Implementation of depressing the pointer.
   */
  pointerDown() {
    throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`);
  }

  /**
   * Implementation of releasing the pointer.
   */
  pointerUp() {
    throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`);
  }

  /**
   * Implementation of moving the pointer.
   */
  pointerMove() {
    throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`);
  }

  /**
   * Unmarshals a JSON Object to a {@link Pointer}.
   *
   * @param {number} pointerId
   *     Numeric pointer id.
   * @param {string} pointerType
   *     Pointer type.
   *
   * @returns {Pointer}
   *     An instance of the Pointer class for {@link pointerType}.
   *
   * @throws {InvalidArgumentError}
   *     If {@link pointerType} is not a valid pointer type.
   */
  static fromJSON(pointerId, pointerType) {
    const cls = pointerTypes.get(pointerType);

    if (cls === undefined) {
      throw new lazy.error.InvalidArgumentError(
        'Expected "pointerType" type to be one of ' +
          lazy.pprint`${pointerTypes}, got ${pointerType}`
      );
    }

    return new cls(pointerId);
  }
}

/**
 * Implementation of mouse pointer behavior.
 */
class MousePointer extends Pointer {
  static type = "mouse";

  /**
   * Emits a pointer down event.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {PointerDownAction} action
   *     The pointer down action to perform.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that resolves when the event has been dispatched.
   */
  async pointerDown(state, inputSource, action, options) {
    const { context, dispatchEvent } = options;

    const mouseEvent = new MouseEventData("mousedown", {
      button: action.button,
    });
    mouseEvent.update(state, inputSource);

    if (mouseEvent.ctrlKey && lazy.AppInfo.isMac) {
      mouseEvent.button = 2;
      state.clickTracker.reset();
    } else {
      mouseEvent.clickCount = state.clickTracker.count + 1;
    }

    await dispatchEvent("synthesizeMouseAtPoint", context, {
      x: inputSource.x,
      y: inputSource.y,
      eventData: mouseEvent,
    });

    if (
      lazy.event.MouseButton.isSecondary(mouseEvent.button) ||
      (mouseEvent.ctrlKey && lazy.AppInfo.isMac)
    ) {
      const contextMenuEvent = { ...mouseEvent, type: "contextmenu" };

      await dispatchEvent("synthesizeMouseAtPoint", context, {
        x: inputSource.x,
        y: inputSource.y,
        eventData: contextMenuEvent,
      });
    }
  }

  /**
   * Emits a pointer up event.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {PointerUpAction} action
   *     The pointer up action to perform.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that resolves when the event has been dispatched.
   */
  async pointerUp(state, inputSource, action, options) {
    const { context, dispatchEvent } = options;

    const mouseEvent = new MouseEventData("mouseup", {
      button: action.button,
    });
    mouseEvent.update(state, inputSource);

    state.clickTracker.setClick(action.button);
    mouseEvent.clickCount = state.clickTracker.count;

    await dispatchEvent("synthesizeMouseAtPoint", context, {
      x: inputSource.x,
      y: inputSource.y,
      eventData: mouseEvent,
    });
  }

  /**
   * Emits a pointer down event.
   *
   * @param {State} state
   *     The {@link State} of the action.
   * @param {InputSource} inputSource
   *     Current input device.
   * @param {PointerMoveAction} action
   *     The pointer down action to perform.
   * @param {number} targetX
   *     Target x position to move the pointer to.
   * @param {number} targetY
   *     Target y position to move the pointer to.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that resolves when the event has been dispatched.
   */
  async pointerMove(state, inputSource, action, targetX, targetY, options) {
    const { context, dispatchEvent } = options;

    const mouseEvent = new MouseEventData("mousemove");
    mouseEvent.update(state, inputSource);

    await dispatchEvent("synthesizeMouseAtPoint", context, {
      x: targetX,
      y: targetY,
      eventData: mouseEvent,
    });

    state.clickTracker.reset();
  }
}

/*
 * The implementation here is empty because touch actions have to go via the
 * TouchActionGroup. So if we end up calling these methods that's a bug in
 * the code.
 */
class TouchPointer extends Pointer {
  static type = "touch";
}

/*
 * Placeholder for future pen type pointer support.
 */
class PenPointer extends Pointer {
  static type = "pen";
}

const pointerTypes = new Map();
for (const cls of [MousePointer, TouchPointer, PenPointer]) {
  pointerTypes.set(cls.type, cls);
}

/**
 * Represents a series of ticks, specifying which actions to perform at
 * each tick.
 */
actions.Chain = class extends Array {
  toString() {
    return `[chain ${super.toString()}]`;
  }

  /**
   * Dispatch the action chain to the relevant window.
   *
   * @param {State} state
   *     The {@link State} of actions.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that is resolved once the action chain is complete.
   */
  dispatch(state, options) {
    let i = 1;

    const chainEvents = (async () => {
      for (const tickActions of this) {
        lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`);
        await tickActions.dispatch(state, options);
      }
    })();

    // Reset the current click tracker counter. We shouldn't be able to simulate
    // a double click with multiple action chains.
    state.clickTracker.reset();

    return chainEvents;
  }

  /* eslint-disable no-shadow */ // Shadowing is intentional for `actions`.
  /**
   *
   * Unmarshals a JSON Object to a {@link Chain}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence
   *
   * @param {State} actionState
   *     The {@link State} of actions.
   * @param {Array<object>} actions
   *     Array of objects that each represent an action sequence.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<Chain>}
   *     Promise resolving to an object that allows dispatching
   *     a chain of actions.
   *
   * @throws {InvalidArgumentError}
   *     If <code>actions</code> doesn't correspond to a valid action chain.
   */
  static async fromJSON(actionState, actions, options) {
    lazy.assert.array(
      actions,
      lazy.pprint`Expected "actions" to be an array, got ${actions}`
    );

    const actionsByTick = new this();
    for (const actionSequence of actions) {
      lazy.assert.object(
        actionSequence,
        'Expected "actions" item to be an object, ' +
          lazy.pprint`got ${actionSequence}`
      );

      const inputSourceActions = await Sequence.fromJSON(
        actionState,
        actionSequence,
        options
      );

      for (let i = 0; i < inputSourceActions.length; i++) {
        // new tick
        if (actionsByTick.length < i + 1) {
          actionsByTick.push(new TickActions());
        }
        actionsByTick[i].push(inputSourceActions[i]);
      }
    }

    return actionsByTick;
  }
  /* eslint-enable no-shadow */
};

/**
 * Represents the action for each input device to perform in a single tick.
 */
class TickActions extends Array {
  /**
   * Tick duration in milliseconds.
   *
   * @returns {number}
   *     Longest action duration in |tickActions| if any, or 0.
   */
  getDuration() {
    let max = 0;

    for (const action of this) {
      if (action.affectsWallClockTime && action.duration) {
        max = Math.max(action.duration, max);
      }
    }

    return max;
  }

  /**
   * Dispatch sequence of actions for this tick.
   *
   * This creates a Promise for one tick that resolves once the Promise
   * for each tick-action is resolved, which takes at least |tickDuration|
   * milliseconds.  The resolved set of events for each tick is followed by
   * firing of pending DOM events.
   *
   * Note that the tick-actions are dispatched in order, but they may have
   * different durations and therefore may not end in the same order.
   *
   * @param {State} state
   *     The {@link State} of actions.
   * @param {ActionsOptions} options
   *     Configuration of actions dispatch.
   *
   * @returns {Promise}
   *     Promise that resolves when tick is complete.
   */
  dispatch(state, options) {
    const tickDuration = this.getDuration();
    const tickActions = this.groupTickActions(state);
    const pendingEvents = tickActions.map(([inputSource, action]) =>
      action.dispatch(state, inputSource, tickDuration, options)
    );

    return Promise.all(pendingEvents);
  }

  /**
   * Group together actions from input sources that have to be
   * dispatched together.
   *
   * The actual transformation here is to group together touch pointer
   * actions into {@link TouchActionGroup} instances.
   *
   * @param {State} state
   *     The {@link State} of actions.
   *
   * @returns {Array<Array<InputSource?,Action|TouchActionGroup>>}
   *    Array of pairs. For ungrouped actions each element is
   *    [InputSource, Action] For touch actions there are multiple
   *    pointers handled at once, so the first item of the array is
   *    null, meaning the group has to perform its own handling of the
   *    relevant state, and the second element is a TouchActionGroup.
   */
  groupTickActions(state) {
    const touchActions = new Map();
    const groupedActions = [];

    for (const action of this) {
      const inputSource = state.getInputSource(action.id);
      if (action.type == "pointer" && inputSource.pointer.type === "touch") {
        lazy.logger.debug(
          `Grouping action ${action.type} ${action.id} ${action.subtype}`
        );
        let group = touchActions.get(action.subtype);
        if (group === undefined) {
          group = TouchActionGroup.forType(action.subtype);
          touchActions.set(action.subtype, group);
          groupedActions.push([null, group]);
        }
        group.addPointer(inputSource, action);
      } else {
        groupedActions.push([inputSource, action]);
      }
    }

    return groupedActions;
  }
}

/**
 * Represents one input source action sequence; this is essentially an
 * |Array<Action>|.
 *
 * This is a temporary object only used when constructing an {@link
 * action.Chain}.
 */
class Sequence extends Array {
  toString() {
    return `[sequence ${super.toString()}]`;
  }

  /**
   * Unmarshals a JSON Object to a {@link Sequence}.
   *
   * @see https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence
   *
   * @param {State} actionState
   *     The {@link State} of actions.
   * @param {object} actionSequence
   *     Protocol representation of the actions for a specific input source.
   * @param {ActionsOptions} options
   *     Configuration for actions.
   *
   * @returns {Promise<Array<Array<InputSource, Action | TouchActionGroup>>>}
   *     Promise that resolves to an object that allows dispatching a
   *     sequence of actions.
   *
   * @throws {InvalidArgumentError}
   *     If the <code>actionSequence</code> doesn't correspond to a valid action sequence.
   */
  static async fromJSON(actionState, actionSequence, options) {
    // used here to validate 'type' in addition to InputSource type below
    const { actions: actionsFromSequence, id, type } = actionSequence;

    // type and id get validated in InputSource.fromJSON
    lazy.assert.array(
      actionsFromSequence,
      'Expected "actionSequence.actions" to be an array, ' +
        lazy.pprint`got ${actionSequence.actions}`
    );

    // This sets the input state in the global state map, if it's new.
    InputSource.fromJSON(actionState, actionSequence);

    const sequence = new this();
    for (const actionItem of actionsFromSequence) {
      sequence.push(await Action.fromJSON(type, id, actionItem, options));
    }

    return sequence;
  }
}

/**
 * Representation of an input event.
 *
 * @param {object} [options={}]
 * @param {boolean} [options.altKey] - If set to `true`, the Alt key will be
 *     considered pressed.
 * @param {boolean} [options.ctrlKey] - If set to `true`, the Ctrl key will be
 *     considered pressed.
 * @param {boolean} [options.metaKey] - If set to `true`, the Meta key will be
 *     considered pressed.
 * @param {boolean} [options.shiftKey] - If set to `true`, the Shift key will be
 *     considered pressed.
 */
class InputEventData {
  constructor(options = {}) {
    const { altKey, ctrlKey, metaKey, shiftKey } = options;

    this.altKey = altKey;
    this.ctrlKey = ctrlKey;
    this.metaKey = metaKey;
    this.shiftKey = shiftKey;
  }

  /**
   * Update the input data based on global and input state
   */
  update(state) {
    for (const [, otherInputSource] of state.inputSourcesByType("key")) {
      // set modifier properties based on whether any corresponding keys are
      // pressed on any key input source
      this.altKey = otherInputSource.alt || this.altKey;
      this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
      this.metaKey = otherInputSource.meta || this.metaKey;
      this.shiftKey = otherInputSource.shift || this.shiftKey;
    }
  }

  toString() {
    return `${this.constructor.name} ${JSON.stringify(this)}`;
  }
}

/**
 * Representation of a key input event.
 */
class KeyEventData extends InputEventData {
  /**
   * Creates a new {@link KeyEventData} instance.
   *
   * @param {string} rawKey
   *     The key value.
   */
  constructor(rawKey) {
    super();

    const { key, code, location, printable } = lazy.keyData.getData(rawKey);

    this.key = key;
    this.code = code;
    this.location = location;
    this.printable = printable;
    this.repeat = false;
    // keyCode will be computed by event.sendKeyDown
  }

  update(state, inputSource) {
    this.altKey = inputSource.alt;
    this.shiftKey = inputSource.shift;
    this.ctrlKey = inputSource.ctrl;
    this.metaKey = inputSource.meta;
  }
}

/**
 * Representation of a pointer input event.
 */
class PointerEventData extends InputEventData {
  /**
   * Creates a new {@link PointerEventData} instance.
   *
   * @param {string} type
   *     The event type.
   */
  constructor(type) {
    super();

    this.type = type;
    this.buttons = 0;
  }

  update(state, inputSource) {
    // set modifier properties based on whether any corresponding keys are
    // pressed on any key input source
    for (const [, otherInputSource] of state.inputSourcesByType("key")) {
      this.altKey = otherInputSource.alt || this.altKey;
      this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
      this.metaKey = otherInputSource.meta || this.metaKey;
      this.shiftKey = otherInputSource.shift || this.shiftKey;
    }
    const allButtons = Array.from(inputSource.pressed);
    this.buttons = allButtons.reduce(
      (a, i) => a + PointerEventData.getButtonFlag(i),
      0
    );
  }

  /**
   * Return a flag for buttons which indicates a button is pressed.
   *
   * @param {integer} button
   *     The mouse button number.
   */
  static getButtonFlag(button) {
    switch (button) {
      case 1:
        return 4;
      case 2:
        return 2;
      default:
        return Math.pow(2, button);
    }
  }
}

/**
 * Representation of a mouse input event.
 */
class MouseEventData extends PointerEventData {
  /**
   * Creates a new {@link MouseEventData} instance.
   *
   * @param {string} type
   *     The event type.
   * @param {object=} options
   * @param {number=} options.button
   *     The number of the mouse button. Defaults to 0.
   */
  constructor(type, options = {}) {
    super(type);

    const { button = 0 } = options;

    this.button = button;
    this.buttons = 0;

    // Some WPTs try to synthesize DnD only with mouse events.  However,
    // Gecko waits DnD events directly and non-WPT-tests use Gecko specific
    // test API to synthesize DnD.  Therefore, we want new path only for
    // synthesized events coming from the webdriver.
    this.allowToHandleDragDrop = true;
  }

  update(state, inputSource) {
    super.update(state, inputSource);

    this.id = inputSource.pointer.id;
  }
}

/**
 * Representation of a wheel input event.
 */
class WheelEventData extends InputEventData {
  /**
   * Creates a new {@link WheelEventData} instance.
   *
   * @param {object} [options={}]
   * @param {number} [options.deltaX=0] - Floating-point value in CSS pixels to
   *     scroll in the x direction.
   * @param {number} [options.deltaY=0] - Floating-point value in CSS pixels to
   *     scroll in the y direction.
   *
   * @see event.synthesizeWheelAtPoint
   * @see InputEventData
   */
  constructor(options) {
    super(options);

    const { deltaX, deltaY } = options;
    this.deltaX = deltaX;
    this.deltaY = deltaY;
    this.deltaZ = 0;
  }
}

/**
 * Representation of one or more touch events.
 */
class TouchEventData extends PointerEventData {
  #setGlobalState;

  /**
   * Creates a new {@link TouchEventData} instance.
   *
   * @param {string} type
   *     The event type.
   */
  constructor(type) {
    super(type);

    this.id = [];
    this.x = [];
    this.y = [];
    this.rx = [];
    this.ry = [];
    this.angle = [];
    this.force = [];
    this.tiltx = [];
    this.tilty = [];
    this.twist = [];
    this.altitudeAngle = [];
    this.azimuthAngle = [];

    this.#setGlobalState = false;
  }

  /**
   * Add the data from one pointer to the event.
   *
   * @param {InputSource} inputSource
   *     The state of the pointer.
   * @param {PointerAction} action
   *     Action for the pointer.
   */
  addPointerEventData(inputSource, action) {
    this.x.push(inputSource.x);
    this.y.push(inputSource.y);
    this.id.push(inputSource.pointer.id);
    this.rx.push(action.width);
    this.ry.push(action.height);
    this.angle.push(0);
    this.force.push(action.pressure);
    this.tiltx.push(action.tiltX);
    this.tilty.push(action.tiltY);
    this.twist.push(action.twist);
    this.altitudeAngle.push(action.altitudeAngle);
    this.azimuthAngle.push(action.azimuthAngle);
  }

  update(state, inputSource) {
    // We call update once per input source, but only want to update global state once.
    // Instead of introducing a new lifecycle method, or changing the API to allow multiple
    // input sources in a single call, use a small bit of state to avoid repeatedly setting
    // global state.
    if (!this.#setGlobalState) {
      // set modifier properties based on whether any corresponding keys are
      // pressed on any key input source
      for (const [, otherInputSource] of state.inputSourcesByType("key")) {
        this.altKey = otherInputSource.alt || this.altKey;
        this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
        this.metaKey = otherInputSource.meta || this.metaKey;
        this.shiftKey = otherInputSource.shift || this.shiftKey;
      }
      this.#setGlobalState = true;
    }

    // Note that we currently emit Touch events that don't have this property
    // but pointer events should have a `buttons` property, so we'll compute it
    // anyway.
    const allButtons = Array.from(inputSource.pressed);
    this.buttons =
      this.buttons |
      allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0);
  }
}

// Helpers

/**
 * Assert that target is in the viewport of win.
 *
 * @param {Array<number>} target
 *     Coordinates [x, y] of the target relative to the viewport.
 * @param {WindowProxy} win
 *     The target window.
 *
 * @throws {MoveTargetOutOfBoundsError}
 *     If target is outside the viewport.
 */
export function assertTargetInViewPort(target, win) {
  const [x, y] = target;

  lazy.assert.number(
    x,
    lazy.pprint`Expected "x" to be finite number, got ${x}`
  );
  lazy.assert.number(
    y,
    lazy.pprint`Expected "y" to be finite number, got ${y}`
  );

  // Viewport includes scrollbars if rendered.
  if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) {
    throw new lazy.error.MoveTargetOutOfBoundsError(
      `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` +
        `(${win.innerWidth}, ${win.innerHeight})`
    );
  }
}
