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

"use strict";

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Task.jsm");

Cu.import("chrome://marionette/content/assert.js");
Cu.import("chrome://marionette/content/element.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/event.js");
Cu.import("chrome://marionette/content/interaction.js");

this.EXPORTED_SYMBOLS = ["action"];

// 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
 * virtualised device input to the web browser.
 */
this.action = {
  Pause: "pause",
  KeyDown: "keyDown",
  KeyUp: "keyUp",
  PointerDown: "pointerDown",
  PointerUp: "pointerUp",
  PointerMove: "pointerMove",
  PointerCancel: "pointerCancel",
};

const ACTIONS = {
  none: new Set([action.Pause]),
  key: new Set([action.Pause, action.KeyDown, action.KeyUp]),
  pointer: new Set([
    action.Pause,
    action.PointerDown,
    action.PointerUp,
    action.PointerMove,
    action.PointerCancel,
  ]),
};

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

/** Map from raw key (codepoint) to normalized key value */
const NORMALIZED_KEY_LOOKUP = {
  "\uE000": "Unidentified",
  "\uE001": "Cancel",
  "\uE002": "Help",
  "\uE003": "Backspace",
  "\uE004": "Tab",
  "\uE005": "Clear",
  "\uE006": "Enter",
  "\uE007": "Enter",
  "\uE008": "Shift",
  "\uE009": "Control",
  "\uE00A": "Alt",
  "\uE00B": "Pause",
  "\uE00C": "Escape",
  "\uE00D": " ",
  "\uE00E": "PageUp",
  "\uE00F": "PageDown",
  "\uE010": "End",
  "\uE011": "Home",
  "\uE012": "ArrowLeft",
  "\uE013": "ArrowUp",
  "\uE014": "ArrowRight",
  "\uE015": "ArrowDown",
  "\uE016": "Insert",
  "\uE017": "Delete",
  "\uE018": ";",
  "\uE019": "=",
  "\uE01A": "0",
  "\uE01B": "1",
  "\uE01C": "2",
  "\uE01D": "3",
  "\uE01E": "4",
  "\uE01F": "5",
  "\uE020": "6",
  "\uE021": "7",
  "\uE022": "8",
  "\uE023": "9",
  "\uE024": "*",
  "\uE025": "+",
  "\uE026": ",",
  "\uE027": "-",
  "\uE028": ".",
  "\uE029": "/",
  "\uE031": "F1",
  "\uE032": "F2",
  "\uE033": "F3",
  "\uE034": "F4",
  "\uE035": "F5",
  "\uE036": "F6",
  "\uE037": "F7",
  "\uE038": "F8",
  "\uE039": "F9",
  "\uE03A": "F10",
  "\uE03B": "F11",
  "\uE03C": "F12",
  "\uE03D": "Meta",
  "\uE040": "ZenkakuHankaku",
  "\uE050": "Shift",
  "\uE051": "Control",
  "\uE052": "Alt",
  "\uE053": "Meta",
  "\uE054": "PageUp",
  "\uE055": "PageDown",
  "\uE056": "End",
  "\uE057": "Home",
  "\uE058": "ArrowLeft",
  "\uE059": "ArrowUp",
  "\uE05A": "ArrowRight",
  "\uE05B": "ArrowDown",
  "\uE05C": "Insert",
  "\uE05D": "Delete",
};

/** Map from raw key (codepoint) to key location */
const KEY_LOCATION_LOOKUP = {
  "\uE007": 1,
  "\uE008": 1,
  "\uE009": 1,
  "\uE00A": 1,
  "\uE01A": 3,
  "\uE01B": 3,
  "\uE01C": 3,
  "\uE01D": 3,
  "\uE01E": 3,
  "\uE01F": 3,
  "\uE020": 3,
  "\uE021": 3,
  "\uE022": 3,
  "\uE023": 3,
  "\uE024": 3,
  "\uE025": 3,
  "\uE026": 3,
  "\uE027": 3,
  "\uE028": 3,
  "\uE029": 3,
  "\uE03D": 1,
  "\uE050": 2,
  "\uE051": 2,
  "\uE052": 2,
  "\uE053": 2,
  "\uE054": 3,
  "\uE055": 3,
  "\uE056": 3,
  "\uE057": 3,
  "\uE058": 3,
  "\uE059": 3,
  "\uE05A": 3,
  "\uE05B": 3,
  "\uE05C": 3,
  "\uE05D": 3,
};

const KEY_CODE_LOOKUP = {
  "\uE00A": "AltLeft",
  "\uE052": "AltRight",
  "\uE015": "ArrowDown",
  "\uE012": "ArrowLeft",
  "\uE014": "ArrowRight",
  "\uE013": "ArrowUp",
  "`": "Backquote",
  "~": "Backquote",
  "\\": "Backslash",
  "|": "Backslash",
  "\uE003": "Backspace",
  "[": "BracketLeft",
  "{": "BracketLeft",
  "]": "BracketRight",
  "}": "BracketRight",
  ",": "Comma",
  "<": "Comma",
  "\uE009": "ControlLeft",
  "\uE051": "ControlRight",
  "\uE017": "Delete",
  ")": "Digit0",
  "0": "Digit0",
  "!": "Digit1",
  "1": "Digit1",
  "2": "Digit2",
  "@": "Digit2",
  "#": "Digit3",
  "3": "Digit3",
  "$": "Digit4",
  "4": "Digit4",
  "%": "Digit5",
  "5": "Digit5",
  "6": "Digit6",
  "^": "Digit6",
  "&": "Digit7",
  "7": "Digit7",
  "*": "Digit8",
  "8": "Digit8",
  "(": "Digit9",
  "9": "Digit9",
  "\uE010": "End",
  "\uE006": "Enter",
  "+": "Equal",
  "=": "Equal",
  "\uE00C": "Escape",
  "\uE031": "F1",
  "\uE03A": "F10",
  "\uE03B": "F11",
  "\uE03C": "F12",
  "\uE032": "F2",
  "\uE033": "F3",
  "\uE034": "F4",
  "\uE035": "F5",
  "\uE036": "F6",
  "\uE037": "F7",
  "\uE038": "F8",
  "\uE039": "F9",
  "\uE002": "Help",
  "\uE011": "Home",
  "\uE016": "Insert",
  "<": "IntlBackslash",
  ">": "IntlBackslash",
  "A": "KeyA",
  "a": "KeyA",
  "B": "KeyB",
  "b": "KeyB",
  "C": "KeyC",
  "c": "KeyC",
  "D": "KeyD",
  "d": "KeyD",
  "E": "KeyE",
  "e": "KeyE",
  "F": "KeyF",
  "f": "KeyF",
  "G": "KeyG",
  "g": "KeyG",
  "H": "KeyH",
  "h": "KeyH",
  "I": "KeyI",
  "i": "KeyI",
  "J": "KeyJ",
  "j": "KeyJ",
  "K": "KeyK",
  "k": "KeyK",
  "L": "KeyL",
  "l": "KeyL",
  "M": "KeyM",
  "m": "KeyM",
  "N": "KeyN",
  "n": "KeyN",
  "O": "KeyO",
  "o": "KeyO",
  "P": "KeyP",
  "p": "KeyP",
  "Q": "KeyQ",
  "q": "KeyQ",
  "R": "KeyR",
  "r": "KeyR",
  "S": "KeyS",
  "s": "KeyS",
  "T": "KeyT",
  "t": "KeyT",
  "U": "KeyU",
  "u": "KeyU",
  "V": "KeyV",
  "v": "KeyV",
  "W": "KeyW",
  "w": "KeyW",
  "X": "KeyX",
  "x": "KeyX",
  "Y": "KeyY",
  "y": "KeyY",
  "Z": "KeyZ",
  "z": "KeyZ",
  "-": "Minus",
  "_": "Minus",
  "\uE01A": "Numpad0",
  "\uE05C": "Numpad0",
  "\uE01B": "Numpad1",
  "\uE056": "Numpad1",
  "\uE01C": "Numpad2",
  "\uE05B": "Numpad2",
  "\uE01D": "Numpad3",
  "\uE055": "Numpad3",
  "\uE01E": "Numpad4",
  "\uE058": "Numpad4",
  "\uE01F": "Numpad5",
  "\uE020": "Numpad6",
  "\uE05A": "Numpad6",
  "\uE021": "Numpad7",
  "\uE057": "Numpad7",
  "\uE022": "Numpad8",
  "\uE059": "Numpad8",
  "\uE023": "Numpad9",
  "\uE054": "Numpad9",
  "\uE024": "NumpadAdd",
  "\uE026": "NumpadComma",
  "\uE028": "NumpadDecimal",
  "\uE05D": "NumpadDecimal",
  "\uE029": "NumpadDivide",
  "\uE007": "NumpadEnter",
  "\uE024": "NumpadMultiply",
  "\uE026": "NumpadSubtract",
  "\uE03D": "OSLeft",
  "\uE053": "OSRight",
  "\uE01E": "PageDown",
  "\uE01F": "PageUp",
  ".": "Period",
  ">": "Period",
  "\"": "Quote",
  "'": "Quote",
  ":": "Semicolon",
  ";": "Semicolon",
  "\uE008": "ShiftLeft",
  "\uE050": "ShiftRight",
  "/": "Slash",
  "?": "Slash",
  "\uE00D": "Space",
  "  ": "Space",
  "\uE004": "Tab",
};

/** Represents possible values for a pointer-move origin. */
action.PointerOrigin = {
  Viewport: "viewport",
  Pointer: "pointer",
};

/**
 * Look up a PointerOrigin.
 *
 * @param {?} obj
 *     Origin for a pointerMove action.
 *
 * @return {?}
 *     A pointer origin that is either "viewport" (default), "pointer", or a
 *     web-element reference.
 *
 * @throws {InvalidArgumentError}
 *     If |obj| is not a valid origin.
 */
action.PointerOrigin.get = function(obj) {
  let origin = obj;
  if (typeof obj == "undefined") {
    origin = this.Viewport;
  } else if (typeof obj == "string") {
    let name = capitalize(obj);
    assert.in(name, this, error.pprint`Unknown pointer-move origin: ${obj}`);
    origin = this[name];
  } else if (!element.isWebElementReference(obj)) {
    throw new InvalidArgumentError("Expected 'origin' to be a string or a " +
      `web element reference, got: ${obj}`);
  }
  return origin;
};

/** Represents possible subtypes for a pointer input source. */
action.PointerType = {
  Mouse: "mouse",
  // TODO For now, only mouse is supported
  //Pen: "pen",
  //Touch: "touch",
};

/**
 * Look up a PointerType.
 *
 * @param {string} str
 *     Name of pointer type.
 *
 * @return {string}
 *     A pointer type for processing pointer parameters.
 *
 * @throws {InvalidArgumentError}
 *     If |str| is not a valid pointer type.
 */
action.PointerType.get = function (str) {
  let name = capitalize(str);
  assert.in(name, this, error.pprint`Unknown pointerType: ${str}`);
  return this[name];
};

/**
 * Input state associated with current session. This is a map between input ID and
 * the device state for that input source, with one entry for each active input source.
 *
 * Initialized in listener.js
 */
action.inputStateMap = undefined;

/**
 * List of |action.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.
 *
 * Initialized in listener.js
 */
action.inputsToCancel = undefined;

/**
 * Represents device state for an input source.
 */
class InputState {
  constructor() {
    this.type = this.constructor.name.toLowerCase();
  }

  /**
   * Check equality of this InputState object with another.
   *
   * @para{?} other
   *     Object representing an input state.
   * @return {boolean}
   *     True if |this| has the same |type| as |other|.
   */
  is(other) {
    if (typeof other == "undefined") {
      return false;
    }
    return this.type === other.type;
  }

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

  /**
   * @param {?} obj
   *     Object with property |type| and optionally |parameters| or |pointerType|,
   *     representing an action sequence or an action item.
   *
   * @return {action.InputState}
   *     An |action.InputState| object for the type of the |actionSequence|.
   *
   * @throws {InvalidArgumentError}
   *     If |actionSequence.type| is not valid.
   */
  static fromJson(obj) {
    let type = obj.type;
    assert.in(type, ACTIONS, error.pprint`Unknown action type: ${type}`);
    let name = type == "none" ? "Null" : capitalize(type);
    if (name == "Pointer") {
      if (!obj.pointerType && (!obj.parameters || !obj.parameters.pointerType)) {
        throw new InvalidArgumentError(
            error.pprint`Expected obj to have pointerType, got: ${obj}`);
      }
      let pointerType = obj.pointerType || obj.parameters.pointerType;
      return new action.InputState[name](pointerType);
    } else {
      return new action.InputState[name]();
    }
  }
}

/** Possible kinds of |InputState| for supported input sources. */
action.InputState = {};

/**
 * Input state associated with a keyboard-type device.
 */
action.InputState.Key = class Key extends InputState {
  constructor() {
    super();
    this.pressed = new Set();
    this.alt = false;
    this.shift = false;
    this.ctrl = false;
    this.meta = false;
  }

  /**
   * 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 InvalidArgumentError("Expected 'key' to be one of " +
          `${Object.keys(MODIFIER_NAME_LOOKUP)}; got: ${key}`);
    }
  }

  /**
   * Check whether |key| is pressed.
   *
   * @param {string} key
   *     Normalized key value.
   *
   * @return {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.
   *
   * @return {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.
   *
   * @return {boolean}
   *     True if |key| was present before removal, false otherwise.
   */
  release(key) {
    return this.pressed.delete(key);
  }
};

/**
 * Input state not associated with a specific physical device.
 */
action.InputState.Null = class Null extends InputState {
  constructor() {
    super();
    this.type = "none";
  }
};

/**
 * Input state associated with a pointer-type input device.
 *
 * @param {string} subtype
 *     Kind of pointing device: mouse, pen, touch.
 *
 * @throws {InvalidArgumentError}
 *     If subtype is undefined or an invalid pointer type.
 */
action.InputState.Pointer = class Pointer extends InputState {
  constructor(subtype) {
    super();
    this.pressed = new Set();
    assert.defined(subtype, error.pprint`Expected subtype to be defined, got: ${subtype}`);
    this.subtype = action.PointerType.get(subtype);
    this.x = 0;
    this.y = 0;
  }

  /**
   * Check whether |button| is pressed.
   *
   * @param {number} button
   *     Positive integer that refers to a mouse button.
   *
   * @return {boolean}
   *     True if |button| is in set of pressed buttons.
   */
  isPressed(button) {
    assert.positiveInteger(button);
    return this.pressed.has(button);
  }

  /**
   * Add |button| to the set of pressed keys.
   *
   * @param {number} button
   *     Positive integer that refers to a mouse button.
   *
   * @return {Set}
   *     Set of pressed buttons.
   */
  press(button) {
    assert.positiveInteger(button);
    return this.pressed.add(button);
  }

   /**
   * Remove |button| from the set of pressed buttons.
   *
   * @param {number} button
   *     A positive integer that refers to a mouse button.
   *
   * @return {boolean}
   *     True if |button| was present before removals, false otherwise.
   */
  release(button) {
    assert.positiveInteger(button);
    return this.pressed.delete(button);
  }
};

/**
 * Repesents an action for dispatch. Used in |action.Chain| and |action.Sequence|.
 *
 * @param {string} id
 *     Input source ID.
 * @param {string} type
 *     Action type: none, key, pointer.
 * @param {string} subtype
 *     Action subtype: pause, keyUp, keyDown, pointerUp, pointerDown, pointerMove, pointerCancel.
 *
 * @throws {InvalidArgumentError}
 *      If any parameters are undefined.
 */
action.Action = class {
  constructor(id, type, subtype) {
    if ([id, type, subtype].includes(undefined)) {
      throw new InvalidArgumentError("Missing id, type or subtype");
    }
    for (let attr of [id, type, subtype]) {
      assert.string(attr, error.pprint`Expected string, got: ${attr}`);
    }
    this.id = id;
    this.type = type;
    this.subtype = subtype;
  };

  toString() {
    return `[action ${this.type}]`;
  }

  /**
   * @param {?} actionSequence
   *     Object representing sequence of actions from one input source.
   * @param {?} actionItem
   *     Object representing a single action from |actionSequence|.
   *
   * @return {action.Action}
   *     An action that can be dispatched; corresponds to |actionItem|.
   *
   * @throws {InvalidArgumentError}
   *     If any |actionSequence| or |actionItem| attributes are invalid.
   * @throws {UnsupportedOperationError}
   *     If |actionItem.type| is |pointerCancel|.
   */
  static fromJson(actionSequence, actionItem) {
    let type = actionSequence.type;
    let id = actionSequence.id;
    let subtypes = ACTIONS[type];
    if (!subtypes) {
      throw new InvalidArgumentError("Unknown type: " + type);
    }
    let subtype = actionItem.type;
    if (!subtypes.has(subtype)) {
      throw new InvalidArgumentError(`Unknown subtype for ${type} action: ${subtype}`);
    }

    let item = new action.Action(id, type, subtype);
    if (type === "pointer") {
      action.processPointerAction(id,
          action.PointerParameters.fromJson(actionSequence.parameters), item);
    }

    switch (item.subtype) {
      case action.KeyUp:
      case action.KeyDown:
        let key = actionItem.value;
        // TODO countGraphemes
        // TODO key.value could be a single code point like "\uE012" (see rawKey)
        // or "grapheme cluster"
        assert.string(key,
            error.pprint("Expected 'value' to be a string that represents single code point " +
                `or grapheme cluster, got: ${key}`));
        item.value = key;
        break;

      case action.PointerDown:
      case action.PointerUp:
        assert.positiveInteger(actionItem.button,
            error.pprint`Expected 'button' (${actionItem.button}) to be >= 0`);
        item.button = actionItem.button;
        break;

      case action.PointerMove:
        item.duration = actionItem.duration;
        if (typeof item.duration != "undefined"){
          assert.positiveInteger(item.duration,
              error.pprint`Expected 'duration' (${item.duration}) to be >= 0`);
        }
        item.origin = action.PointerOrigin.get(actionItem.origin);
        item.x = actionItem.x;
        if (typeof item.x != "undefined") {
          assert.integer(item.x, error.pprint`Expected 'x' (${item.x}) to be an Integer`);
        }
        item.y = actionItem.y;
        if (typeof item.y != "undefined") {
          assert.integer(item.y, error.pprint`Expected 'y' (${item.y}) to be an Integer`);
        }
        break;

      case action.PointerCancel:
        throw new UnsupportedOperationError();
        break;

      case action.Pause:
        item.duration = actionItem.duration;
        if (typeof item.duration != "undefined") {
          assert.positiveInteger(item.duration,
              error.pprint`Expected 'duration' (${item.duration}) to be >= 0`);
        }
        break;
    }

    return item;
  }
};

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

  /**
   * @param {Array.<?>} actions
   *     Array of objects that each represent an action sequence.
   *
   * @return {action.Chain}
   *     Transpose of |actions| such that actions to be performed in a single tick
   *     are grouped together.
   *
   * @throws {InvalidArgumentError}
   *     If |actions| is not an Array.
   */
  static fromJson(actions) {
    assert.array(actions,
        error.pprint`Expected 'actions' to be an Array, got: ${actions}`);
    let actionsByTick = new action.Chain();
    //  TODO check that each actionSequence in actions refers to a different input ID
    for (let actionSequence of actions) {
      let inputSourceActions = action.Sequence.fromJson(actionSequence);
      for (let i = 0; i < inputSourceActions.length; i++) {
        // new tick
        if (actionsByTick.length < (i + 1)) {
          actionsByTick.push([]);
        }
        actionsByTick[i].push(inputSourceActions[i]);
      }
    }
    return actionsByTick;
  }
};

/**
 * Represents one input source action sequence; this is essentially an |Array.<action.Action>|.
 */
action.Sequence = class extends Array {
  toString() {
    return `[sequence ${super.toString()}]`;
  }

  /**
   * @param {?} actionSequence
   *     Object that represents a sequence action items for one input source.
   *
   * @return {action.Sequence}
   *     Sequence of actions that can be dispatched.
   *
   * @throws {InvalidArgumentError}
   *     If |actionSequence.id| is not a string or it's aleady mapped
   *     to an |action.InputState} incompatible with |actionSequence.type|.
   *     If |actionSequence.actions| is not an Array.
   */
  static fromJson(actionSequence) {
    // used here to validate 'type' in addition to InputState type below
    let inputSourceState = InputState.fromJson(actionSequence);
    let id = actionSequence.id;
    assert.defined(id, "Expected 'id' to be defined");
    assert.string(id, error.pprint`Expected 'id' to be a string, got: ${id}`);
    let actionItems = actionSequence.actions;
    assert.array(actionItems,
        error.pprint("Expected 'actionSequence.actions' to be an Array, " +
            `got: ${actionSequence.actions}`));
    if (!action.inputStateMap.has(id)) {
      action.inputStateMap.set(id, inputSourceState);
    } else if (!action.inputStateMap.get(id).is(inputSourceState)) {
      throw new InvalidArgumentError(
          `Expected ${id} to be mapped to ${inputSourceState}, ` +
          `got: ${action.inputStateMap.get(id)}`);
    }
    let actions = new action.Sequence();
    for (let actionItem of actionItems) {
      actions.push(action.Action.fromJson(actionSequence, actionItem));
    }
    return actions;
  }
};

/**
 * Represents parameters in an action for a pointer input source.
 *
 * @param {string=} pointerType
 *     Type of pointing device. If the parameter is undefined, "mouse" is used.
 */
action.PointerParameters = class {
  constructor(pointerType = "mouse") {
    this.pointerType = action.PointerType.get(pointerType);
  }

  toString() {
    return `[pointerParameters ${this.pointerType}]`;
  }

  /**
   * @param {?} parametersData
   *     Object that represents pointer parameters.
   *
   * @return {action.PointerParameters}
   *     Validated pointer paramters.
   */
  static fromJson(parametersData) {
    if (typeof parametersData == "undefined") {
      return new action.PointerParameters();
    } else {
      return new action.PointerParameters(parametersData.pointerType);
    }
  }
};

/**
 * Adds |pointerType| attribute to Action |act|. Helper function
 * for |action.Action.fromJson|.
 *
 * @param {string} id
 *     Input source ID.
 * @param {action.PointerParams} pointerParams
 *     Input source pointer parameters.
 * @param {action.Action} act
 *     Action to be updated.
 *
 * @throws {InvalidArgumentError}
 *     If |id| is already mapped to an |action.InputState| that is
 *     not compatible with |act.type| or |pointerParams.pointerType|.
 */
action.processPointerAction = function processPointerAction(id, pointerParams, act) {
  if (action.inputStateMap.has(id) && action.inputStateMap.get(id).type !== act.type) {
    throw new InvalidArgumentError(
        `Expected 'id' ${id} to be mapped to InputState whose type is ` +
        `${action.inputStateMap.get(id).type}, got: ${act.type}`);
  }
  let pointerType = pointerParams.pointerType;
  if (action.inputStateMap.has(id) && action.inputStateMap.get(id).subtype !== pointerType) {
    throw new InvalidArgumentError(
        `Expected 'id' ${id} to be mapped to InputState whose subtype is ` +
        `${action.inputStateMap.get(id).subtype}, got: ${pointerType}`);
  }
  act.pointerType = pointerParams.pointerType;
};

/** Collect properties associated with KeyboardEvent */
action.Key = class {
  constructor(rawKey) {
    this.key = NORMALIZED_KEY_LOOKUP[rawKey] || rawKey;
    this.code =  KEY_CODE_LOOKUP[rawKey];
    this.location = KEY_LOCATION_LOOKUP[rawKey] || 0;
    this.altKey = false;
    this.shiftKey = false;
    this.ctrlKey = false;
    this.metaKey = false;
    this.repeat = false;
    this.isComposing = false;
    // keyCode will be computed by event.sendKeyDown
  }

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

/** Collect properties associated with MouseEvent */
action.Mouse = class {
  constructor(type, button = 0) {
    this.type = type;
    assert.positiveInteger(button);
    this.button = button;
    this.buttons = 0;
  }

  update(inputState) {
    let allButtons = Array.from(inputState.pressed);
    this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0);
  }
};

/**
 * Dispatch a chain of actions over |chain.length| ticks.
 *
 * This is done by creating a Promise for each tick that resolves once all the
 * Promises for individual tick-actions are resolved. The next tick's actions are
 * not dispatched until the Promise for the current tick is resolved.
 *
 * @param {action.Chain} chain
 *     Actions grouped by tick; each element in |chain| is a sequence of
 *     actions for one tick.
 * @param {element.Store} seenEls
 *     Element store.
 * @param {?} container
 *     Object with |frame| attribute of type |nsIDOMWindow|.
 *
 * @return {Promise}
 *     Promise for dispatching all actions in |chain|.
 */
action.dispatch = function(chain, seenEls, container) {
  let chainEvents = Task.spawn(function*() {
    for (let tickActions of chain) {
      yield action.dispatchTickActions(
        tickActions, action.computeTickDuration(tickActions), seenEls, container);
    }
  });
  return chainEvents;
};

/**
 * Dispatch sequence of actions for one 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 {Array.<action.Action>} tickActions
 *     List of actions for one tick.
 * @param {number} tickDuration
 *     Duration in milliseconds of this tick.
 * @param {element.Store} seenEls
 *     Element store.
 * @param {?} container
 *     Object with |frame| attribute of type |nsIDOMWindow|.
 *
 * @return {Promise}
 *     Promise for dispatching all tick-actions and pending DOM events.
 */
action.dispatchTickActions = function(tickActions, tickDuration, seenEls, container) {
  let pendingEvents = tickActions.map(toEvents(tickDuration, seenEls, container));
  return Promise.all(pendingEvents).then(
      () => interaction.flushEventLoop(container.frame));
};

/**
 * Compute tick duration in milliseconds for a collection of actions.
 *
 * @param {Array.<action.Action>} tickActions
 *     List of actions for one tick.
 *
 * @return {number}
 *     Longest action duration in |tickActions| if any, or 0.
 */
action.computeTickDuration = function(tickActions) {
  let max = 0;
  for (let a of tickActions) {
    let affectsWallClockTime = a.subtype == action.Pause ||
        (a.type == "pointer" && a.subtype == action.PointerMove);
    if (affectsWallClockTime && a.duration) {
      max = Math.max(a.duration, max);
    }
  }
  return max;
};

/**
 * Compute viewport coordinates of pointer target based on given origin.
 *
 * @param {action.Action} a
 *     Action that specifies pointer origin and x and y coordinates of target.
 * @param {action.InputState} inputState
 *     Input state that specifies current x and y coordinates of pointer.
 * @param {Map.<string, number>=} center
 *     Object representing x and y coordinates of an element center-point.
 *     This is only used if |a.origin| is a web element reference.
 *
 * @return {Map.<string, number>}
 *     x and y coordinates of pointer destination.
 */
action.computePointerDestination = function(a, inputState, center = undefined) {
  let {x, y} = a;
  switch (a.origin) {
    case action.PointerOrigin.Viewport:
      break;
    case action.PointerOrigin.Pointer:
      x += inputState.x;
      y += inputState.y;
      break;
    default:
      // origin represents web element
      assert.defined(center);
      assert.in("x", center);
      assert.in("y", center);
      x += center.x;
      y += center.y;
  }
  return {"x": x, "y": y};
};

/**
 * Create a closure to use as a map from action definitions to Promise events.
 *
 * @param {number} tickDuration
 *     Duration in milliseconds of this tick.
 * @param {element.Store} seenEls
 *     Element store.
 * @param {?} container
 *     Object with |frame| attribute of type |nsIDOMWindow|.
 *
 * @return {function(action.Action): Promise}
 *     Function that takes an action and returns a Promise for dispatching
 *     the event that corresponds to that action.
 */
function toEvents(tickDuration, seenEls, container) {
  return function (a) {
    let inputState = action.inputStateMap.get(a.id);
    switch (a.subtype) {
      case action.KeyUp:
        return dispatchKeyUp(a, inputState, container.frame);

      case action.KeyDown:
        return dispatchKeyDown(a, inputState, container.frame);

      case action.PointerDown:
        return dispatchPointerDown(a, inputState, container.frame);

      case action.PointerUp:
        return dispatchPointerUp(a, inputState, container.frame);

      case action.PointerMove:
        return dispatchPointerMove(a, inputState, tickDuration, seenEls, container);

      case action.PointerCancel:
        throw new UnsupportedOperationError();

      case action.Pause:
        return dispatchPause(a, tickDuration);
    }
  };
}

/**
 * Dispatch a keyDown action equivalent to pressing a key on a keyboard.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {action.InputState} inputState
 *     Input state for this action's input source.
 * @param {nsIDOMWindow} win
 *     Current window.
 *
 * @return {Promise}
 *     Promise to dispatch at least a keydown event, and keypress if appropriate.
 */
function dispatchKeyDown(a, inputState, win) {
  return new Promise(resolve => {
    let keyEvent = new action.Key(a.value);
    keyEvent.repeat = inputState.isPressed(keyEvent.key);
    inputState.press(keyEvent.key);
    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
      inputState.setModState(keyEvent.key, true);
    }
    // Append a copy of |a| with keyUp subtype
    action.inputsToCancel.push(Object.assign({}, a, {subtype: action.KeyUp}));
    keyEvent.update(inputState);
    event.sendKeyDown(a.value, keyEvent, win);

    resolve();
  });
}

/**
 * Dispatch a keyUp action equivalent to releasing a key on a keyboard.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {action.InputState} inputState
 *     Input state for this action's input source.
 * @param {nsIDOMWindow} win
 *     Current window.
 *
 * @return {Promise}
 *     Promise to dispatch a keyup event.
 */
function dispatchKeyUp(a, inputState, win) {
  return new Promise(resolve => {
    let keyEvent = new action.Key(a.value);
    if (!inputState.isPressed(keyEvent.key)) {
      resolve();
      return;
    }
    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
      inputState.setModState(keyEvent.key, false);
    }
    inputState.release(keyEvent.key);
    keyEvent.update(inputState);
    event.sendKeyUp(a.value, keyEvent, win);

    resolve();
  });
}

/**
 * Dispatch a pointerDown action equivalent to pressing a pointer-device
 * button.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {action.InputState} inputState
 *     Input state for this action's input source.
 * @param {nsIDOMWindow} win
 *     Current window.
 *
 * @return {Promise}
 *     Promise to dispatch at least a pointerdown event.
 */
function dispatchPointerDown(a, inputState, win) {
  return new Promise(resolve => {
    if (inputState.isPressed(a.button)) {
      resolve();
      return;
    }
    inputState.press(a.button);
    // Append a copy of |a| with pointerUp subtype
    action.inputsToCancel.push(Object.assign({}, a, {subtype: action.PointerUp}));
    switch (inputState.subtype) {
      case action.PointerType.Mouse:
        let mouseEvent = new action.Mouse("mousedown", a.button);
        mouseEvent.update(inputState);
        event.synthesizeMouseAtPoint(inputState.x, inputState.y, mouseEvent, win);
        break;
      case action.PointerType.Pen:
      case action.PointerType.Touch:
        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
        break;
      default:
        throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
    }
    resolve();
  });
}

/**
 * Dispatch a pointerUp action equivalent to releasing a pointer-device
 * button.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {action.InputState} inputState
 *     Input state for this action's input source.
 * @param {nsIDOMWindow} win
 *     Current window.
 *
 * @return {Promise}
 *     Promise to dispatch at least a pointerup event.
 */
function dispatchPointerUp(a, inputState, win) {
  return new Promise(resolve => {
    if (!inputState.isPressed(a.button)) {
      resolve();
      return;
    }
    inputState.release(a.button);
    switch (inputState.subtype) {
      case action.PointerType.Mouse:
        let mouseEvent = new action.Mouse("mouseup", a.button);
        mouseEvent.update(inputState);
        event.synthesizeMouseAtPoint(inputState.x, inputState.y,
            mouseEvent, win);
        break;
      case action.PointerType.Pen:
      case action.PointerType.Touch:
        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
      default:
        throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
    }
    resolve();
  });
}

/**
 * Dispatch a pointerMove action equivalent to moving pointer device in a line.
 *
 * If the action duration is 0, the pointer jumps immediately to the target coordinates.
 * Otherwise, events are synthesized to mimic a pointer travelling in a discontinuous,
 * approximately straight line, with the pointer coordinates being updated around 60
 * times per second.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {action.InputState} inputState
 *     Input state for this action's input source.
 * @param {element.Store} seenEls
 *     Element store.
 * @param {?} container
 *     Object with |frame| attribute of type |nsIDOMWindow|.
 *
 * @return {Promise}
 *     Promise to dispatch at least one pointermove event, as well as mousemove events
 *     as appropriate.
 */
function dispatchPointerMove(a, inputState, tickDuration, seenEls, container) {
  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  // interval between pointermove increments in ms, based on common vsync
  const fps60 = 17;
  return new Promise(resolve => {
    const start = Date.now();
    const [startX, startY] = [inputState.x, inputState.y];
    let target = action.computePointerDestination(a, inputState,
        getElementCenter(a.origin, seenEls, container));
    const [targetX, targetY] = [target.x, target.y];
    if (!inViewPort(targetX, targetY, container.frame)) {
      throw new MoveTargetOutOfBoundsError(
          `(${targetX}, ${targetY}) is out of bounds of viewport ` +
          `width (${container.frame.innerWidth}) and height (${container.frame.innerHeight})`);
    }

    const duration = typeof a.duration == "undefined" ? tickDuration : a.duration;
    if (duration === 0) {
      // move pointer to destination in one step
      performOnePointerMove(inputState, targetX, targetY, container.frame);
      resolve();
      return;
    }

    const distanceX = targetX - startX;
    const distanceY = targetY - startY;
    const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
    let intermediatePointerEvents = Task.spawn(function* () {
      // wait |fps60| ms before performing first incremental pointer move
      yield new Promise(resolveTimer =>
          timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
      );
      let durationRatio = Math.floor(Date.now() - start) / duration;
      const epsilon = fps60 / duration / 10;
      while ((1 - durationRatio) > epsilon) {
        let x = Math.floor(durationRatio * distanceX + startX);
        let y = Math.floor(durationRatio * distanceY + startY);
        performOnePointerMove(inputState, x, y, container.frame);
        // wait |fps60| ms before performing next pointer move
        yield new Promise(resolveTimer =>
            timer.initWithCallback(resolveTimer, fps60, ONE_SHOT));
        durationRatio = Math.floor(Date.now() - start) / duration;
      }
    });
    // perform last pointer move after all incremental moves are resolved and
    // durationRatio is close enough to 1
    intermediatePointerEvents.then(() => {
      performOnePointerMove(inputState, targetX, targetY, container.frame);
      resolve();
    });

  });
}

function performOnePointerMove(inputState, targetX, targetY, win) {
  if (targetX == inputState.x && targetY == inputState.y) {
    return;
  }
  switch (inputState.subtype) {
    case action.PointerType.Mouse:
      let mouseEvent = new action.Mouse("mousemove");
      mouseEvent.update(inputState);
      //TODO both pointermove (if available) and mousemove
      event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
      break;
    case action.PointerType.Pen:
    case action.PointerType.Touch:
      throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
    default:
        throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
  }
  inputState.x = targetX;
  inputState.y = targetY;
}

/**
 * Dispatch a pause action equivalent waiting for |a.duration| milliseconds, or a
 * default time interval of |tickDuration|.
 *
 * @param {action.Action} a
 *     Action to dispatch.
 * @param {number} tickDuration
 *     Duration in milliseconds of this tick.
 *
 * @return {Promise}
 *     Promise that is resolved after the specified time interval.
 */
function dispatchPause(a, tickDuration) {
  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  let duration = typeof a.duration == "undefined" ? tickDuration : a.duration;
  return new Promise(resolve =>
      timer.initWithCallback(resolve, duration, Ci.nsITimer.TYPE_ONE_SHOT)
  );
}

// helpers

function capitalize(str) {
  assert.string(str);
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function inViewPort(x, y, win) {
  assert.number(x, `Expected x to be finite number`);
  assert.number(y, `Expected y to be finite number`);
  // Viewport includes scrollbars if rendered.
  return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight);
}

function getElementCenter(elementReference, seenEls, container) {
  if (element.isWebElementReference(elementReference)) {
    let uuid = elementReference[element.Key] || elementReference[element.LegacyKey];
    let el = seenEls.get(uuid, container);
    return element.coordinates(el);
  }
}
