/* 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 { action, CLICK_INTERVAL, ClickTracker } = ChromeUtils.importESModule(
  "chrome://remote/content/shared/webdriver/Actions.sys.mjs"
);

const { setTimeout } = ChromeUtils.importESModule(
  "resource://gre/modules/Timer.sys.mjs"
);

const XHTMLNS = "http://www.w3.org/1999/xhtml";

const domEl = {
  nodeType: 1,
  ELEMENT_NODE: 1,
  namespaceURI: XHTMLNS,
};

add_task(function test_createInputState() {
  for (let type of ["none", "key", "pointer" /*"wheel"*/]) {
    const state = new action.State();
    const id = "device";
    const actionSequence = {
      type,
      id,
      actions: [],
    };
    action.Chain.fromJSON(state, [actionSequence]);
    equal(state.inputStateMap.size, 1);
    equal(state.inputStateMap.get(id).constructor.type, type);
  }
});

add_task(async function test_defaultPointerParameters() {
  let state = new action.State();
  const inputTickActions = [
    { type: "pointer", subtype: "pointerDown", button: 0 },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions)
  );
  const pointerAction = chain[0][0];
  equal(
    state.getInputSource(pointerAction.id).pointer.constructor.type,
    "mouse"
  );
});

add_task(async function test_processPointerParameters() {
  for (let subtype of ["pointerDown", "pointerUp"]) {
    for (let pointerType of [2, true, {}, []]) {
      const inputTickActions = [
        {
          type: "pointer",
          parameters: { pointerType },
          subtype,
          button: 0,
        },
      ];
      let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`;
      await checkFromJSONErrors(
        inputTickActions,
        /Expected "pointerType" to be a string/,
        message
      );
    }

    for (let pointerType of ["", "foo"]) {
      const inputTickActions = [
        {
          type: "pointer",
          parameters: { pointerType },
          subtype,
          button: 0,
        },
      ];
      let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`;
      await checkFromJSONErrors(
        inputTickActions,
        /Expected "pointerType" to be one of/,
        message
      );
    }
  }

  for (let pointerType of ["mouse" /*"touch"*/]) {
    let state = new action.State();
    const inputTickActions = [
      {
        type: "pointer",
        parameters: { pointerType },
        subtype: "pointerDown",
        button: 0,
      },
    ];
    const chain = await action.Chain.fromJSON(
      state,
      chainForTick(inputTickActions)
    );
    const pointerAction = chain[0][0];
    equal(
      state.getInputSource(pointerAction.id).pointer.constructor.type,
      pointerType
    );
  }
});

add_task(async function test_processPointerDownAction() {
  for (let button of [-1, "a"]) {
    const inputTickActions = [
      { type: "pointer", subtype: "pointerDown", button },
    ];
    await checkFromJSONErrors(
      inputTickActions,
      /Expected "button" to be a positive integer/,
      `pointerDown with {button: ${button}}`
    );
  }
  let state = new action.State();
  const inputTickActions = [
    { type: "pointer", subtype: "pointerDown", button: 5 },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions)
  );
  equal(chain[0][0].button, 5);
});

add_task(async function test_validateActionDurationAndCoordinates() {
  for (let [type, subtype] of [
    ["none", "pause"],
    ["pointer", "pointerMove"],
  ]) {
    for (let duration of [-1, "a"]) {
      const inputTickActions = [{ type, subtype, duration }];
      await checkFromJSONErrors(
        inputTickActions,
        /Expected "duration" to be a positive integer/,
        `{subtype} with {duration: ${duration}}`
      );
    }
  }
  for (let name of ["x", "y"]) {
    const actionItem = {
      type: "pointer",
      subtype: "pointerMove",
      duration: 5000,
    };
    actionItem[name] = "a";
    await checkFromJSONErrors(
      [actionItem],
      /Expected ".*" to be an integer/,
      `${name}: "a", subtype: pointerMove`
    );
  }
});

add_task(async function test_processPointerMoveActionOriginStringValidation() {
  for (let origin of ["", "viewports", "pointers"]) {
    const inputTickActions = [
      {
        type: "pointer",
        x: 0,
        y: 0,
        duration: 5000,
        subtype: "pointerMove",
        origin,
      },
    ];
    await checkFromJSONErrors(
      inputTickActions,
      /Expected "origin" to be undefined, "viewport", "pointer", or an element/,
      `actionItem.origin: ${origin}`,
      { isElementOrigin: () => false }
    );
  }
});

add_task(async function test_processPointerMoveActionOriginElementValidation() {
  const element = { foo: "bar" };
  const inputTickActions = [
    {
      type: "pointer",
      x: 0,
      y: 0,
      duration: 5000,
      subtype: "pointerMove",
      origin: element,
    },
  ];

  // invalid element origin
  await checkFromJSONErrors(
    inputTickActions,
    /Expected "origin" to be undefined, "viewport", "pointer", or an element/,
    `actionItem.origin: (${getTypeString(element)})`,
    { isElementOrigin: elem => "foo1" in elem }
  );

  let state = new action.State();
  const actionsOptions = {
    isElementOrigin: elem => "foo" in elem,
    getElementOrigin: elem => elem,
  };

  // valid element origin
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    actionsOptions
  );
  deepEqual(chain[0][0].origin, { element });
});

add_task(async function test_processPointerMoveActionDefaultOrigin() {
  let state = new action.State();
  const inputTickActions = [
    { type: "pointer", x: 0, y: 0, duration: 5000, subtype: "pointerMove" },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    {}
  );
  // The default is viewport coordinates which have an origin at [0,0] and don't depend on inputSource
  deepEqual(chain[0][0].origin.getOriginCoordinates(null, null), {
    x: 0,
    y: 0,
  });
});

add_task(async function test_processPointerMoveAction() {
  let state = new action.State();
  const actionItems = [
    {
      duration: 5000,
      type: "pointerMove",
      origin: undefined,
      x: 0,
      y: 0,
    },
    {
      duration: undefined,
      type: "pointerMove",
      origin: domEl,
      x: 0,
      y: 0,
    },
    {
      duration: 5000,
      type: "pointerMove",
      x: 1,
      y: 2,
      origin: undefined,
    },
  ];
  const actionSequence = {
    id: "some_id",
    type: "pointer",
    actions: actionItems,
  };
  let actionsOptions = {
    isElementOrigin: elem => elem == domEl,
    getElementOrigin: elem => elem,
  };

  let chain = await action.Chain.fromJSON(
    state,
    [actionSequence],
    actionsOptions
  );
  equal(chain.length, actionItems.length);
  for (let i = 0; i < actionItems.length; i++) {
    let actual = chain[i][0];
    let expected = actionItems[i];
    equal(actual.duration, expected.duration);
    equal(actual.x, expected.x);
    equal(actual.y, expected.y);

    let originClass;
    if (expected.origin === undefined || expected.origin == "viewport") {
      originClass = "ViewportOrigin";
    } else if (expected.origin === "pointer") {
      originClass = "PointerOrigin";
    } else {
      originClass = "ElementOrigin";
    }
    deepEqual(actual.origin.constructor.name, originClass);
  }
});

add_task(async function test_computePointerDestinationViewport() {
  const state = new action.State();
  const inputTickActions = [
    {
      type: "pointer",
      subtype: "pointerMove",
      x: 100,
      y: 200,
      origin: "viewport",
    },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    {}
  );
  const actionItem = chain[0][0];
  const inputSource = state.getInputSource(actionItem.id);
  // these values should not affect the outcome
  inputSource.x = "99";
  inputSource.y = "10";
  const target = await actionItem.origin.getTargetCoordinates(
    inputSource,
    [actionItem.x, actionItem.y],
    null
  );
  equal(actionItem.x, target[0]);
  equal(actionItem.y, target[1]);
});

add_task(async function test_computePointerDestinationPointer() {
  const state = new action.State();
  const inputTickActions = [
    {
      type: "pointer",
      subtype: "pointerMove",
      x: 100,
      y: 200,
      origin: "pointer",
    },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    {}
  );
  const actionItem = chain[0][0];
  const inputSource = state.getInputSource(actionItem.id);
  inputSource.x = 10;
  inputSource.y = 99;
  const target = await actionItem.origin.getTargetCoordinates(
    inputSource,
    [actionItem.x, actionItem.y],
    null
  );
  equal(actionItem.x + inputSource.x, target[0]);
  equal(actionItem.y + inputSource.y, target[1]);
});

add_task(async function test_processPointerAction() {
  for (let pointerType of ["mouse", "touch"]) {
    const actionItems = [
      {
        duration: 2000,
        type: "pause",
      },
      {
        type: "pointerMove",
        duration: 2000,
        x: 0,
        y: 0,
      },
      {
        type: "pointerUp",
        button: 1,
      },
    ];
    let actionSequence = {
      type: "pointer",
      id: "some_id",
      parameters: {
        pointerType,
      },
      actions: actionItems,
    };
    const state = new action.State();
    const chain = await action.Chain.fromJSON(state, [actionSequence], {});
    equal(chain.length, actionItems.length);
    for (let i = 0; i < actionItems.length; i++) {
      const actual = chain[i][0];
      const expected = actionItems[i];
      equal(actual.type, expected.type === "pause" ? "none" : "pointer");
      equal(actual.subtype, expected.type);
      equal(actual.id, actionSequence.id);
      if (expected.type === "pointerUp") {
        equal(actual.button, expected.button);
      } else {
        equal(actual.duration, expected.duration);
      }
      if (expected.type !== "pause") {
        equal(
          state.getInputSource(actual.id).pointer.constructor.type,
          pointerType
        );
      }
    }
  }
});

add_task(async function test_processPauseAction() {
  for (let type of ["none", "key", "pointer"]) {
    const state = new action.State();
    const actionSequence = {
      type,
      id: "some_id",
      actions: [{ type: "pause", duration: 5000 }],
    };
    const actions = await action.Chain.fromJSON(state, [actionSequence], {});
    const actionItem = actions[0][0];
    equal(actionItem.type, "none");
    equal(actionItem.subtype, "pause");
    equal(actionItem.id, "some_id");
    equal(actionItem.duration, 5000);
  }
  const state = new action.State();
  const actionSequence = {
    type: "none",
    id: "some_id",
    actions: [{ type: "pause" }],
  };
  const actions = await action.Chain.fromJSON(state, [actionSequence], {});
  const actionItem = actions[0][0];
  equal(actionItem.duration, undefined);
});

add_task(async function test_processActionSubtypeValidation() {
  for (let type of ["none", "key", "pointer"]) {
    const message = `type: ${type}, subtype: dancing`;
    const inputTickActions = [{ type, subtype: "dancing" }];
    await checkFromJSONErrors(
      inputTickActions,
      new RegExp(`Expected known subtype for type`),
      message
    );
  }
});

add_task(async function test_processKeyActionDown() {
  for (let value of [-1, undefined, [], ["a"], { length: 1 }, null]) {
    const inputTickActions = [{ type: "key", subtype: "keyDown", value }];
    const message = `actionItem.value: (${getTypeString(value)})`;
    await checkFromJSONErrors(
      inputTickActions,
      /Expected "value" to be a string that represents single code point/,
      message
    );
  }

  const state = new action.State();
  const actionSequence = {
    type: "key",
    id: "keyboard",
    actions: [{ type: "keyDown", value: "\uE004" }],
  };
  const actions = await action.Chain.fromJSON(state, [actionSequence], {});
  const actionItem = actions[0][0];

  equal(actionItem.type, "key");
  equal(actionItem.id, "keyboard");
  equal(actionItem.subtype, "keyDown");
  equal(actionItem.value, "\ue004");
});

add_task(async function test_processInputSourceActionSequenceValidation() {
  await checkFromJSONErrors(
    [{ type: "swim", subtype: "pause", id: "some id" }],
    /Expected known action type/,
    "actionSequence type: swim"
  );

  await checkFromJSONErrors(
    [{ type: "none", subtype: "pause", id: -1 }],
    /Expected "id" to be a string/,
    "actionSequence id: -1"
  );

  await checkFromJSONErrors(
    [{ type: "none", subtype: "pause", id: undefined }],
    /Expected "id" to be a string/,
    "actionSequence id: undefined"
  );

  const state = new action.State();
  const actionSequence = [
    { type: "none", subtype: "pause", id: "some_id", actions: -1 },
  ];
  const errorRegex = /Expected "actionSequence.actions" to be an array/;
  const message = "actionSequence actions: -1";

  await Assert.rejects(
    action.Chain.fromJSON(state, actionSequence, {}),
    /InvalidArgumentError/,
    message
  );
  await Assert.rejects(
    action.Chain.fromJSON(state, actionSequence, {}),
    errorRegex,
    message
  );
});

add_task(async function test_processInputSourceActionSequence() {
  const state = new action.State();
  const actionItem = { type: "pause", duration: 5 };
  const actionSequence = {
    type: "none",
    id: "some id",
    actions: [actionItem],
  };
  const chain = await action.Chain.fromJSON(state, [actionSequence], {});
  equal(chain.length, 1);
  const tickActions = chain[0];
  equal(tickActions.length, 1);
  equal(tickActions[0].type, "none");
  equal(tickActions[0].subtype, "pause");
  equal(tickActions[0].duration, 5);
  equal(tickActions[0].id, "some id");
});

add_task(async function test_processInputSourceActionSequencePointer() {
  const state = new action.State();
  const actionItem = { type: "pointerDown", button: 1 };
  const actionSequence = {
    type: "pointer",
    id: "9",
    actions: [actionItem],
    parameters: {
      pointerType: "mouse", // TODO "pen"
    },
  };
  const chain = await action.Chain.fromJSON(state, [actionSequence], {});
  equal(chain.length, 1);
  const tickActions = chain[0];
  equal(tickActions.length, 1);
  equal(tickActions[0].type, "pointer");
  equal(tickActions[0].subtype, "pointerDown");
  equal(tickActions[0].button, 1);
  equal(tickActions[0].id, "9");
  const inputSource = state.getInputSource(tickActions[0].id);
  equal(inputSource.constructor.type, "pointer");
  equal(inputSource.pointer.constructor.type, "mouse");
});

add_task(async function test_processInputSourceActionSequenceKey() {
  const state = new action.State();
  const actionItem = { type: "keyUp", value: "a" };
  const actionSequence = {
    type: "key",
    id: "9",
    actions: [actionItem],
  };
  const chain = await action.Chain.fromJSON(state, [actionSequence], {});
  equal(chain.length, 1);
  const tickActions = chain[0];
  equal(tickActions.length, 1);
  equal(tickActions[0].type, "key");
  equal(tickActions[0].subtype, "keyUp");
  equal(tickActions[0].value, "a");
  equal(tickActions[0].id, "9");
});

add_task(async function test_processInputSourceActionSequenceInputStateMap() {
  const state = new action.State();
  const id = "1";
  const actionItem = { type: "pause", duration: 5000 };
  const actionSequence = {
    type: "key",
    id,
    actions: [actionItem],
  };
  await action.Chain.fromJSON(state, [actionSequence], {});
  equal(state.inputStateMap.size, 1);
  equal(state.inputStateMap.get(id).constructor.type, "key");

  // Construct a different state with the same input id
  const state1 = new action.State();
  const actionItem1 = { type: "pointerDown", button: 0 };
  const actionSequence1 = {
    type: "pointer",
    id,
    actions: [actionItem1],
  };
  await action.Chain.fromJSON(state1, [actionSequence1], {});
  equal(state1.inputStateMap.size, 1);

  // Overwrite the state in the initial map with one of a different type
  state.inputStateMap.set(id, state1.inputStateMap.get(id));
  equal(state.inputStateMap.get(id).constructor.type, "pointer");

  const message = "Wrong state for input id type";
  await Assert.rejects(
    action.Chain.fromJSON(state, [actionSequence]),
    /InvalidArgumentError/,
    message
  );
  await Assert.rejects(
    action.Chain.fromJSON(state, [actionSequence]),
    /Expected input source \[object String\] "1" to be type pointer/,
    message
  );
});

add_task(async function test_extractActionChainValidation() {
  for (let actions of [-1, "a", undefined, null]) {
    const state = new action.State();
    let message = `actions: ${getTypeString(actions)}`;
    await Assert.rejects(
      action.Chain.fromJSON(state, actions),
      /InvalidArgumentError/,
      message
    );
    await Assert.rejects(
      action.Chain.fromJSON(state, actions),
      /Expected "actions" to be an array/,
      message
    );
  }
});

add_task(async function test_extractActionChainEmpty() {
  const state = new action.State();
  deepEqual(await action.Chain.fromJSON(state, [], {}), []);
});

add_task(async function test_extractActionChain_oneTickOneInput() {
  const state = new action.State();
  const actionItem = { type: "pause", duration: 5000 };
  const actionSequence = {
    type: "none",
    id: "some id",
    actions: [actionItem],
  };
  const actionsByTick = await action.Chain.fromJSON(
    state,
    [actionSequence],
    {}
  );
  equal(1, actionsByTick.length);
  equal(1, actionsByTick[0].length);
  equal(actionsByTick[0][0].id, actionSequence.id);
  equal(actionsByTick[0][0].type, "none");
  equal(actionsByTick[0][0].subtype, "pause");
  equal(actionsByTick[0][0].duration, actionItem.duration);
});

add_task(async function test_extractActionChain_twoAndThreeTicks() {
  const state = new action.State();
  const mouseActionItems = [
    {
      type: "pointerDown",
      button: 2,
    },
    {
      type: "pointerUp",
      button: 2,
    },
  ];
  const mouseActionSequence = {
    type: "pointer",
    id: "7",
    actions: mouseActionItems,
    parameters: {
      pointerType: "mouse",
    },
  };
  const keyActionItems = [
    {
      type: "keyDown",
      value: "a",
    },
    {
      type: "pause",
      duration: 4,
    },
    {
      type: "keyUp",
      value: "a",
    },
  ];
  let keyActionSequence = {
    type: "key",
    id: "1",
    actions: keyActionItems,
  };
  let actionsByTick = await action.Chain.fromJSON(
    state,
    [keyActionSequence, mouseActionSequence],
    {}
  );
  // number of ticks is same as longest action sequence
  equal(keyActionItems.length, actionsByTick.length);
  equal(2, actionsByTick[0].length);
  equal(2, actionsByTick[1].length);
  equal(1, actionsByTick[2].length);

  equal(actionsByTick[2][0].id, keyActionSequence.id);
  equal(actionsByTick[2][0].type, "key");
  equal(actionsByTick[2][0].subtype, "keyUp");
});

add_task(async function test_computeTickDuration() {
  const state = new action.State();
  const expected = 8000;
  const inputTickActions = [
    { type: "none", subtype: "pause", duration: 5000 },
    { type: "key", subtype: "pause", duration: 1000 },
    { type: "pointer", subtype: "pointerMove", duration: 6000, x: 0, y: 0 },
    // invalid because keyDown should not have duration, so duration should be ignored.
    { type: "key", subtype: "keyDown", duration: 100000, value: "a" },
    { type: "pointer", subtype: "pause", duration: expected },
    { type: "pointer", subtype: "pointerUp", button: 0 },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    {}
  );
  equal(1, chain.length);
  const tickActions = chain[0];
  equal(expected, tickActions.getDuration());
});

add_task(async function test_computeTickDuration_noDurations() {
  const state = new action.State();
  const inputTickActions = [
    // invalid because keyDown should not have duration, so duration should be ignored.
    { type: "key", subtype: "keyDown", duration: 100000, value: "a" },
    // undefined duration permitted
    { type: "none", subtype: "pause" },
    { type: "pointer", subtype: "pointerMove", button: 0, x: 0, y: 0 },
    { type: "pointer", subtype: "pointerDown", button: 0 },
    { type: "key", subtype: "keyUp", value: "a" },
  ];
  const chain = await action.Chain.fromJSON(
    state,
    chainForTick(inputTickActions),
    {}
  );
  equal(0, chain[0].getDuration());
});

add_task(function test_ClickTracker_setClick() {
  const clickTracker = new ClickTracker();
  const button1 = 1;
  const button2 = 2;

  clickTracker.setClick(button1);
  equal(1, clickTracker.count);

  // Make sure that clicking different mouse buttons doesn't increase the count.
  clickTracker.setClick(button2);
  equal(1, clickTracker.count);

  clickTracker.setClick(button2);
  equal(2, clickTracker.count);

  clickTracker.reset();
  equal(0, clickTracker.count);
});

add_task(function test_ClickTracker_reset_after_timeout() {
  const clickTracker = new ClickTracker();

  clickTracker.setClick(1);
  equal(1, clickTracker.count);

  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  setTimeout(() => equal(0, clickTracker.count), CLICK_INTERVAL + 10);
});

// helpers
function getTypeString(obj) {
  return Object.prototype.toString.call(obj);
}

async function checkFromJSONErrors(
  inputTickActions,
  regex,
  message,
  options = {}
) {
  const { isElementOrigin = () => true, getElementOrigin = elem => elem } =
    options;

  const state = new action.State();
  const actionsOptions = { isElementOrigin, getElementOrigin };

  if (typeof message == "undefined") {
    message = `fromJSON`;
  }

  await Assert.rejects(
    action.Chain.fromJSON(
      state,
      chainForTick(inputTickActions),
      actionsOptions
    ),
    /InvalidArgumentError/,
    message
  );
  await Assert.rejects(
    action.Chain.fromJSON(
      state,
      chainForTick(inputTickActions),
      actionsOptions
    ),
    regex,
    message
  );
}

function chainForTick(tickActions) {
  const actions = [];
  let lastId = 0;
  for (let { type, subtype, parameters, ...props } of tickActions) {
    let id;
    if (!props.hasOwnProperty("id")) {
      id = `${type}_${lastId++}`;
    } else {
      id = props.id;
      delete props.id;
    }
    const inputAction = { type, id, actions: [{ type: subtype, ...props }] };
    if (parameters !== undefined) {
      inputAction.parameters = parameters;
    }
    actions.push(inputAction);
  }
  return actions;
}
