/* 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";

loader.lazyRequireGetter(
  this,
  "PICKER_TYPES",
  "resource://devtools/shared/picker-constants.js"
);

var isClickHoldEnabled = Services.prefs.getBoolPref(
  "ui.click_hold_context_menus"
);
var clickHoldDelay = Services.prefs.getIntPref(
  "ui.click_hold_context_menus.delay",
  500
);

const EVENTS_TO_HANDLE = [
  "mousedown",
  "mousemove",
  "mouseup",
  "mouseenter",
  "mouseover",
  "mouseout",
  "mouseleave",
];

const kStateHover = 0x00000004; // ElementState::HOVER

/**
 * Simulate touch events for platforms where they aren't generally available.
 */
class TouchSimulator {
  /**
   * @param {WindowGlobalTargetActor} windowTarget: The window object we'll use
   *                                  to listen for click and touch events to handle.
   */
  constructor(windowTarget) {
    this.windowTarget = windowTarget;
    this.simulatorTarget = windowTarget.chromeEventHandler;
    this._currentPickerMap = new Map();
    this.previousScreenY = 0;
  }

  enabled = false;

  start() {
    if (this.enabled) {
      // Simulator is already started
      return;
    }

    EVENTS_TO_HANDLE.forEach(evt => {
      // Only listen trusted events to prevent messing with
      // event dispatched manually within content documents
      this.simulatorTarget.addEventListener(evt, this, true, false);
    });

    this.enabled = true;
  }

  stop() {
    if (!this.enabled) {
      // Simulator isn't running
      return;
    }
    EVENTS_TO_HANDLE.forEach(evt => {
      this.simulatorTarget.removeEventListener(evt, this, true);
    });
    this.enabled = false;
  }

  _isPicking() {
    const types = Object.values(PICKER_TYPES);
    return types.some(type => this._currentPickerMap.get(type));
  }

  /**
   * Set the state value for one of DevTools pickers (either eyedropper or
   * element picker).
   * If any content picker is currently active, we should not be emulating
   * touch events. Otherwise it is ok to emulate touch events.
   * In theory only one picker can ever be active at a time, but tracking the
   * different pickers independantly avoids race issues in the client code.
   *
   * @param {boolean} state
   *        True if the picker is currently active, false otherwise.
   * @param {string} pickerType
   *        One of PICKER_TYPES.
   */
  setElementPickerState(state, pickerType) {
    if (!Object.values(PICKER_TYPES).includes(pickerType)) {
      throw new Error(
        "Unsupported type in setElementPickerState: " + pickerType
      );
    }
    this._currentPickerMap.set(pickerType, state);
  }

  // eslint-disable-next-line complexity
  handleEvent(evt) {
    // Bail out if devtools is in pick mode in the same tab.
    if (this._isPicking()) {
      return;
    }

    const content = this.getContent(evt.target);
    if (!content) {
      return;
    }

    // Ignore all but real mouse event coming from physical mouse
    // (especially ignore mouse event being dispatched from a touch event)
    if (
      evt.button ||
      evt.inputSource != evt.MOZ_SOURCE_MOUSE ||
      evt.isSynthesized
    ) {
      return;
    }

    const eventTarget = this.target;
    let type = "";
    switch (evt.type) {
      case "mouseenter":
      case "mouseover":
      case "mouseout":
      case "mouseleave":
        // Don't propagate events which are not related to touch events
        evt.stopPropagation();
        evt.preventDefault();

        // We don't want to trigger any visual changes to elements whose content can
        // be modified via hover states. We can avoid this by removing the element's
        // content state.
        InspectorUtils.removeContentState(evt.target, kStateHover);
        break;

      case "mousedown":
        this.target = evt.target;

        // If the click-hold feature is enabled, start a timeout to convert long clicks
        // into contextmenu events.
        // Just don't do it if the event occurred on a scrollbar.
        if (isClickHoldEnabled && !evt.originalTarget.closest("scrollbar")) {
          this._contextMenuTimeout = this.sendContextMenu(evt);
        }

        this.previousScreenY = evt.screenY;

        type = "touchstart";
        break;

      case "mousemove": {
        if (!eventTarget) {
          // Don't propagate mousemove event when touchstart event isn't fired
          evt.stopPropagation();
          return;
        }

        type = "touchmove";
        const deltaY = evt.screenY - this.previousScreenY;
        this.previousScreenY = evt.screenY;
        this.windowTarget.emit("contentScrolled", deltaY);
        break;
      }

      case "mouseup":
        if (!eventTarget) {
          return;
        }
        this.target = null;

        content.clearTimeout(this._contextMenuTimeout);
        type = "touchend";

        // Only register click listener after mouseup to ensure
        // catching only real user click. (Especially ignore click
        // being dispatched on form submit)
        if (evt.detail == 1) {
          this.simulatorTarget.addEventListener("click", this, {
            capture: true,
            once: true,
          });
        }
        break;
    }

    const target = eventTarget || this.target;
    if (target && type) {
      this.sendTouchEvent(content, evt.clientX, evt.clientY, type);
    }

    evt.preventDefault();
    evt.stopImmediatePropagation();
  }

  sendContextMenu({ target, clientX, clientY, screenX, screenY }) {
    const view = target.documentGlobal;
    const evt = new view.PointerEvent("contextmenu", {
      bubbles: true,
      cancelable: true,
      view,
      screenX,
      screenY,
      clientX,
      clientY,
    });
    const content = this.getContent(target);
    const timeout = content.setTimeout(() => {
      target.dispatchEvent(evt);
    }, clickHoldDelay);

    return timeout;
  }

  /**
   * Sends a touch action on a given target element.
   *
   * @param {Window} win
   *        The target window.
   * @param {number} clientX
   *        The `x` screen coordinate relative to the viewport origin.
   * @param {number} clientY
   *        The `y` screen coordinate relative to the viewport origin.
   * @param {string} type
   *        The type of the touch event.
   */
  sendTouchEvent(win, clientX, clientY, type) {
    win.synthesizeTouchEvent(
      type,
      [
        {
          identifier: 0,
          offsetX: clientX,
          offsetY: clientY,
          radiiX: 0,
          radiiY: 0,
        },
      ],
      0,
      { isAsyncEnabled: true }
    );
    return true;
  }

  getContent(target) {
    const win = target?.ownerDocument ? target.documentGlobal : null;
    return win;
  }
}

exports.TouchSimulator = TouchSimulator;
