/**
 * @license
 * Copyright 2021 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Browser event handling.
 */
'use strict';

/**
 * Browser event handling.
 * @namespace Blockly.browserEvents
 */
goog.module('Blockly.browserEvents');

const Touch = goog.require('Blockly.Touch');
const internalConstants = goog.require('Blockly.internalConstants');
const userAgent = goog.require('Blockly.utils.userAgent');
const {globalThis} = goog.require('Blockly.utils.global');


/**
 * Blockly opaque event data used to unbind events when using
 * `bind` and `conditionalBind`.
 * @typedef {!Array<!Array>}
 * @alias Blockly.browserEvents.Data
 */
let Data;
exports.Data = Data;

/**
 * Bind an event handler that can be ignored if it is not part of the active
 * touch stream.
 * Use this for events that either start or continue a multi-part gesture (e.g.
 * mousedown or mousemove, which may be part of a drag or click).
 * @param {!EventTarget} node Node upon which to listen.
 * @param {string} name Event name to listen to (e.g. 'mousedown').
 * @param {?Object} thisObject The value of 'this' in the function.
 * @param {!Function} func Function to call when event is triggered.
 * @param {boolean=} opt_noCaptureIdentifier True if triggering on this event
 *     should not block execution of other event handlers on this touch or
 *     other simultaneous touches.  False by default.
 * @param {boolean=} opt_noPreventDefault True if triggering on this event
 *     should prevent the default handler.  False by default.  If
 *     opt_noPreventDefault is provided, opt_noCaptureIdentifier must also be
 *     provided.
 * @return {!Data} Opaque data that can be passed to
 *     unbindEvent_.
 * @alias Blockly.browserEvents.conditionalBind
 */
const conditionalBind = function(
    node, name, thisObject, func, opt_noCaptureIdentifier,
    opt_noPreventDefault) {
  let handled = false;
  const wrapFunc = function(e) {
    const captureIdentifier = !opt_noCaptureIdentifier;
    // Handle each touch point separately.  If the event was a mouse event, this
    // will hand back an array with one element, which we're fine handling.
    const events = Touch.splitEventByTouches(e);
    for (let i = 0; i < events.length; i++) {
      const event = events[i];
      if (captureIdentifier && !Touch.shouldHandleEvent(event)) {
        continue;
      }
      Touch.setClientFromTouch(event);
      if (thisObject) {
        func.call(thisObject, event);
      } else {
        func(event);
      }
      handled = true;
    }
  };

  const bindData = [];
  if (globalThis['PointerEvent'] && (name in Touch.TOUCH_MAP)) {
    for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {
      const type = Touch.TOUCH_MAP[name][i];
      node.addEventListener(type, wrapFunc, false);
      bindData.push([node, type, wrapFunc]);
    }
  } else {
    node.addEventListener(name, wrapFunc, false);
    bindData.push([node, name, wrapFunc]);

    // Add equivalent touch event.
    if (name in Touch.TOUCH_MAP) {
      const touchWrapFunc = function(e) {
        wrapFunc(e);
        // Calling preventDefault stops the browser from scrolling/zooming the
        // page.
        const preventDef = !opt_noPreventDefault;
        if (handled && preventDef) {
          e.preventDefault();
        }
      };
      for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {
        const type = Touch.TOUCH_MAP[name][i];
        node.addEventListener(type, touchWrapFunc, false);
        bindData.push([node, type, touchWrapFunc]);
      }
    }
  }
  return bindData;
};
exports.conditionalBind = conditionalBind;


/**
 * Bind an event handler that should be called regardless of whether it is part
 * of the active touch stream.
 * Use this for events that are not part of a multi-part gesture (e.g.
 * mouseover for tooltips).
 * @param {!EventTarget} node Node upon which to listen.
 * @param {string} name Event name to listen to (e.g. 'mousedown').
 * @param {?Object} thisObject The value of 'this' in the function.
 * @param {!Function} func Function to call when event is triggered.
 * @return {!Data} Opaque data that can be passed to
 *     unbindEvent_.
 * @alias Blockly.browserEvents.bind
 */
const bind = function(node, name, thisObject, func) {
  const wrapFunc = function(e) {
    if (thisObject) {
      func.call(thisObject, e);
    } else {
      func(e);
    }
  };

  const bindData = [];
  if (globalThis['PointerEvent'] && (name in Touch.TOUCH_MAP)) {
    for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {
      const type = Touch.TOUCH_MAP[name][i];
      node.addEventListener(type, wrapFunc, false);
      bindData.push([node, type, wrapFunc]);
    }
  } else {
    node.addEventListener(name, wrapFunc, false);
    bindData.push([node, name, wrapFunc]);

    // Add equivalent touch event.
    if (name in Touch.TOUCH_MAP) {
      const touchWrapFunc = function(e) {
        // Punt on multitouch events.
        if (e.changedTouches && e.changedTouches.length === 1) {
          // Map the touch event's properties to the event.
          const touchPoint = e.changedTouches[0];
          e.clientX = touchPoint.clientX;
          e.clientY = touchPoint.clientY;
        }
        wrapFunc(e);

        // Stop the browser from scrolling/zooming the page.
        e.preventDefault();
      };
      for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {
        const type = Touch.TOUCH_MAP[name][i];
        node.addEventListener(type, touchWrapFunc, false);
        bindData.push([node, type, touchWrapFunc]);
      }
    }
  }
  return bindData;
};
exports.bind = bind;

/**
 * Unbind one or more events event from a function call.
 * @param {!Data} bindData Opaque data from bindEvent_.
 *     This list is emptied during the course of calling this function.
 * @return {!Function} The function call.
 * @alias Blockly.browserEvents.unbind
 */
const unbind = function(bindData) {
  let func;
  while (bindData.length) {
    const bindDatum = bindData.pop();
    const node = bindDatum[0];
    const name = bindDatum[1];
    func = bindDatum[2];
    node.removeEventListener(name, func, false);
  }
  return func;
};
exports.unbind = unbind;

/**
 * Returns true if this event is targeting a text input widget?
 * @param {!Event} e An event.
 * @return {boolean} True if text input.
 * @alias Blockly.browserEvents.isTargetInput
 */
const isTargetInput = function(e) {
  return e.target.type === 'textarea' || e.target.type === 'text' ||
      e.target.type === 'number' || e.target.type === 'email' ||
      e.target.type === 'password' || e.target.type === 'search' ||
      e.target.type === 'tel' || e.target.type === 'url' ||
      e.target.isContentEditable ||
      (e.target.dataset && e.target.dataset.isTextInput === 'true');
};
exports.isTargetInput = isTargetInput;

/**
 * Returns true this event is a right-click.
 * @param {!Event} e Mouse event.
 * @return {boolean} True if right-click.
 * @alias Blockly.browserEvents.isRightButton
 */
const isRightButton = function(e) {
  if (e.ctrlKey && userAgent.MAC) {
    // Control-clicking on Mac OS X is treated as a right-click.
    // WebKit on Mac OS X fails to change button to 2 (but Gecko does).
    return true;
  }
  return e.button === 2;
};
exports.isRightButton = isRightButton;

/**
 * Returns the converted coordinates of the given mouse event.
 * The origin (0,0) is the top-left corner of the Blockly SVG.
 * @param {!Event} e Mouse event.
 * @param {!Element} svg SVG element.
 * @param {?SVGMatrix} matrix Inverted screen CTM to use.
 * @return {!SVGPoint} Object with .x and .y properties.
 * @alias Blockly.browserEvents.mouseToSvg
 */
const mouseToSvg = function(e, svg, matrix) {
  const svgPoint = svg.createSVGPoint();
  svgPoint.x = e.clientX;
  svgPoint.y = e.clientY;

  if (!matrix) {
    matrix = svg.getScreenCTM().inverse();
  }
  return svgPoint.matrixTransform(matrix);
};
exports.mouseToSvg = mouseToSvg;

/**
 * Returns the scroll delta of a mouse event in pixel units.
 * @param {!Event} e Mouse event.
 * @return {{x: number, y: number}} Scroll delta object with .x and .y
 *    properties.
 * @alias Blockly.browserEvents.getScrollDeltaPixels
 */
const getScrollDeltaPixels = function(e) {
  switch (e.deltaMode) {
    case 0x00:  // Pixel mode.
    default:
      return {x: e.deltaX, y: e.deltaY};
    case 0x01:  // Line mode.
      return {
        x: e.deltaX * internalConstants.LINE_MODE_MULTIPLIER,
        y: e.deltaY * internalConstants.LINE_MODE_MULTIPLIER,
      };
    case 0x02:  // Page mode.
      return {
        x: e.deltaX * internalConstants.PAGE_MODE_MULTIPLIER,
        y: e.deltaY * internalConstants.PAGE_MODE_MULTIPLIER,
      };
  }
};
exports.getScrollDeltaPixels = getScrollDeltaPixels;
