/*
 * noVNC: HTML5 VNC client
 * Copyright (C) 2020 The noVNC Authors
 * Licensed under MPL 2.0 (see LICENSE.txt)
 *
 * See README.md for usage and integration instructions.
 *
 */

const GH_NOGESTURE = 0;
const GH_ONETAP = 1;
const GH_TWOTAP = 2;
const GH_THREETAP = 4;
const GH_DRAG = 8;
const GH_LONGPRESS = 16;
const GH_TWODRAG = 32;
const GH_PINCH = 64;

const GH_INITSTATE = 127;

const GH_MOVE_THRESHOLD = 50;
const GH_ANGLE_THRESHOLD = 90; // Degrees

// Timeout when waiting for gestures (ms)
const GH_MULTITOUCH_TIMEOUT = 250;

// Maximum time between press and release for a tap (ms)
const GH_TAP_TIMEOUT = 1000;

// Timeout when waiting for longpress (ms)
const GH_LONGPRESS_TIMEOUT = 1000;

// Timeout when waiting to decide between PINCH and TWODRAG (ms)
const GH_TWOTOUCH_TIMEOUT = 50;

export default class GestureHandler {
  constructor() {
    this._target = null;

    this._state = GH_INITSTATE;

    this._tracked = [];
    this._ignored = [];

    this._waitingRelease = false;
    this._releaseStart = 0.0;

    this._longpressTimeoutId = null;
    this._twoTouchTimeoutId = null;

    this._boundEventHandler = this._eventHandler.bind(this);
  }

  attach(target) {
    this.detach();

    this._target = target;
    this._target.addEventListener('touchstart', this._boundEventHandler);
    this._target.addEventListener('touchmove', this._boundEventHandler);
    this._target.addEventListener('touchend', this._boundEventHandler);
    this._target.addEventListener('touchcancel', this._boundEventHandler);
  }

  detach() {
    if (!this._target) {
      return;
    }

    this._stopLongpressTimeout();
    this._stopTwoTouchTimeout();

    this._target.removeEventListener('touchstart', this._boundEventHandler);
    this._target.removeEventListener('touchmove', this._boundEventHandler);
    this._target.removeEventListener('touchend', this._boundEventHandler);
    this._target.removeEventListener('touchcancel', this._boundEventHandler);
    this._target = null;
  }

  _eventHandler(e) {
    let fn;

    e.stopPropagation();
    e.preventDefault();

    switch (e.type) {
      case 'touchstart':
        fn = this._touchStart;
        break;
      case 'touchmove':
        fn = this._touchMove;
        break;
      case 'touchend':
      case 'touchcancel':
        fn = this._touchEnd;
        break;
    }

    for (let i = 0; i < e.changedTouches.length; i++) {
      let touch = e.changedTouches[i];
      fn.call(this, touch.identifier, touch.clientX, touch.clientY);
    }
  }

  _touchStart(id, x, y) {
    // Ignore any new touches if there is already an active gesture,
    // or we're in a cleanup state
    if (this._hasDetectedGesture() || this._state === GH_NOGESTURE) {
      this._ignored.push(id);
      return;
    }

    // Did it take too long between touches that we should no longer
    // consider this a single gesture?
    if (this._tracked.length > 0 && Date.now() - this._tracked[0].started > GH_MULTITOUCH_TIMEOUT) {
      this._state = GH_NOGESTURE;
      this._ignored.push(id);
      return;
    }

    // If we're waiting for fingers to release then we should no longer
    // recognize new touches
    if (this._waitingRelease) {
      this._state = GH_NOGESTURE;
      this._ignored.push(id);
      return;
    }

    this._tracked.push({
      id: id,
      started: Date.now(),
      active: true,
      firstX: x,
      firstY: y,
      lastX: x,
      lastY: y,
      angle: 0,
    });

    switch (this._tracked.length) {
      case 1:
        this._startLongpressTimeout();
        break;

      case 2:
        this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
        this._stopLongpressTimeout();
        break;

      case 3:
        this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
        break;

      default:
        this._state = GH_NOGESTURE;
    }
  }

  _touchMove(id, x, y) {
    let touch = this._tracked.find((t) => t.id === id);

    // If this is an update for a touch we're not tracking, ignore it
    if (touch === undefined) {
      return;
    }

    // Update the touches last position with the event coordinates
    touch.lastX = x;
    touch.lastY = y;

    let deltaX = x - touch.firstX;
    let deltaY = y - touch.firstY;

    // Update angle when the touch has moved
    if (touch.firstX !== touch.lastX || touch.firstY !== touch.lastY) {
      touch.angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
    }

    if (!this._hasDetectedGesture()) {
      // Ignore moves smaller than the minimum threshold
      if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
        return;
      }

      // Can't be a tap or long press as we've seen movement
      this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
      this._stopLongpressTimeout();

      if (this._tracked.length !== 1) {
        this._state &= ~GH_DRAG;
      }
      if (this._tracked.length !== 2) {
        this._state &= ~(GH_TWODRAG | GH_PINCH);
      }

      // We need to figure out which of our different two touch gestures
      // this might be
      if (this._tracked.length === 2) {
        // The other touch is the one where the id doesn't match
        let prevTouch = this._tracked.find((t) => t.id !== id);

        // How far the previous touch point has moved since start
        let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX, prevTouch.firstY - prevTouch.lastY);

        // We know that the current touch moved far enough,
        // but unless both touches moved further than their
        // threshold we don't want to disqualify any gestures
        if (prevDeltaMove > GH_MOVE_THRESHOLD) {
          // The angle difference between the direction of the touch points
          let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
          deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);

          // PINCH or TWODRAG can be eliminated depending on the angle
          if (deltaAngle > GH_ANGLE_THRESHOLD) {
            this._state &= ~GH_TWODRAG;
          } else {
            this._state &= ~GH_PINCH;
          }

          if (this._isTwoTouchTimeoutRunning()) {
            this._stopTwoTouchTimeout();
          }
        } else if (!this._isTwoTouchTimeoutRunning()) {
          // We can't determine the gesture right now, let's
          // wait and see if more events are on their way
          this._startTwoTouchTimeout();
        }
      }

      if (!this._hasDetectedGesture()) {
        return;
      }

      this._pushEvent('gesturestart');
    }

    this._pushEvent('gesturemove');
  }

  _touchEnd(id, x, y) {
    // Check if this is an ignored touch
    if (this._ignored.indexOf(id) !== -1) {
      // Remove this touch from ignored
      this._ignored.splice(this._ignored.indexOf(id), 1);

      // And reset the state if there are no more touches
      if (this._ignored.length === 0 && this._tracked.length === 0) {
        this._state = GH_INITSTATE;
        this._waitingRelease = false;
      }
      return;
    }

    // We got a touchend before the timer triggered,
    // this cannot result in a gesture anymore.
    if (!this._hasDetectedGesture() && this._isTwoTouchTimeoutRunning()) {
      this._stopTwoTouchTimeout();
      this._state = GH_NOGESTURE;
    }

    // Some gestures don't trigger until a touch is released
    if (!this._hasDetectedGesture()) {
      // Can't be a gesture that relies on movement
      this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
      // Or something that relies on more time
      this._state &= ~GH_LONGPRESS;
      this._stopLongpressTimeout();

      if (!this._waitingRelease) {
        this._releaseStart = Date.now();
        this._waitingRelease = true;

        // Can't be a tap that requires more touches than we current have
        switch (this._tracked.length) {
          case 1:
            this._state &= ~(GH_TWOTAP | GH_THREETAP);
            break;

          case 2:
            this._state &= ~(GH_ONETAP | GH_THREETAP);
            break;
        }
      }
    }

    // Waiting for all touches to release? (i.e. some tap)
    if (this._waitingRelease) {
      // Were all touches released at roughly the same time?
      if (Date.now() - this._releaseStart > GH_MULTITOUCH_TIMEOUT) {
        this._state = GH_NOGESTURE;
      }

      // Did too long time pass between press and release?
      if (this._tracked.some((t) => Date.now() - t.started > GH_TAP_TIMEOUT)) {
        this._state = GH_NOGESTURE;
      }

      let touch = this._tracked.find((t) => t.id === id);
      touch.active = false;

      // Are we still waiting for more releases?
      if (this._hasDetectedGesture()) {
        this._pushEvent('gesturestart');
      } else {
        // Have we reached a dead end?
        if (this._state !== GH_NOGESTURE) {
          return;
        }
      }
    }

    if (this._hasDetectedGesture()) {
      this._pushEvent('gestureend');
    }

    // Ignore any remaining touches until they are ended
    for (let i = 0; i < this._tracked.length; i++) {
      if (this._tracked[i].active) {
        this._ignored.push(this._tracked[i].id);
      }
    }
    this._tracked = [];

    this._state = GH_NOGESTURE;

    // Remove this touch from ignored if it's in there
    if (this._ignored.indexOf(id) !== -1) {
      this._ignored.splice(this._ignored.indexOf(id), 1);
    }

    // We reset the state if ignored is empty
    if (this._ignored.length === 0) {
      this._state = GH_INITSTATE;
      this._waitingRelease = false;
    }
  }

  _hasDetectedGesture() {
    if (this._state === GH_NOGESTURE) {
      return false;
    }
    // Check to see if the bitmask value is a power of 2
    // (i.e. only one bit set). If it is, we have a state.
    if (this._state & (this._state - 1)) {
      return false;
    }

    // For taps we also need to have all touches released
    // before we've fully detected the gesture
    if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
      if (this._tracked.some((t) => t.active)) {
        return false;
      }
    }

    return true;
  }

  _startLongpressTimeout() {
    this._stopLongpressTimeout();
    this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(), GH_LONGPRESS_TIMEOUT);
  }

  _stopLongpressTimeout() {
    clearTimeout(this._longpressTimeoutId);
    this._longpressTimeoutId = null;
  }

  _longpressTimeout() {
    if (this._hasDetectedGesture()) {
      throw new Error('A longpress gesture failed, conflict with a different gesture');
    }

    this._state = GH_LONGPRESS;
    this._pushEvent('gesturestart');
  }

  _startTwoTouchTimeout() {
    this._stopTwoTouchTimeout();
    this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(), GH_TWOTOUCH_TIMEOUT);
  }

  _stopTwoTouchTimeout() {
    clearTimeout(this._twoTouchTimeoutId);
    this._twoTouchTimeoutId = null;
  }

  _isTwoTouchTimeoutRunning() {
    return this._twoTouchTimeoutId !== null;
  }

  _twoTouchTimeout() {
    if (this._tracked.length === 0) {
      throw new Error('A pinch or two drag gesture failed, no tracked touches');
    }

    // How far each touch point has moved since start
    let avgM = this._getAverageMovement();
    let avgMoveH = Math.abs(avgM.x);
    let avgMoveV = Math.abs(avgM.y);

    // The difference in the distance between where
    // the touch points started and where they are now
    let avgD = this._getAverageDistance();
    let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) - Math.hypot(avgD.last.x, avgD.last.y));

    if (avgMoveV < deltaTouchDistance && avgMoveH < deltaTouchDistance) {
      this._state = GH_PINCH;
    } else {
      this._state = GH_TWODRAG;
    }

    this._pushEvent('gesturestart');
    this._pushEvent('gesturemove');
  }

  _pushEvent(type) {
    let detail = { type: this._stateToGesture(this._state) };

    // For most gesture events the current (average) position is the
    // most useful
    let avg = this._getPosition();
    let pos = avg.last;

    // However we have a slight distance to detect gestures, so for the
    // first gesture event we want to use the first positions we saw
    if (type === 'gesturestart') {
      pos = avg.first;
    }

    // For these gestures, we always want the event coordinates
    // to be where the gesture began, not the current touch location.
    switch (this._state) {
      case GH_TWODRAG:
      case GH_PINCH:
        pos = avg.first;
        break;
    }

    detail['clientX'] = pos.x;
    detail['clientY'] = pos.y;

    // FIXME: other coordinates?

    // Some gestures also have a magnitude
    if (this._state === GH_PINCH) {
      let distance = this._getAverageDistance();
      if (type === 'gesturestart') {
        detail['magnitudeX'] = distance.first.x;
        detail['magnitudeY'] = distance.first.y;
      } else {
        detail['magnitudeX'] = distance.last.x;
        detail['magnitudeY'] = distance.last.y;
      }
    } else if (this._state === GH_TWODRAG) {
      if (type === 'gesturestart') {
        detail['magnitudeX'] = 0.0;
        detail['magnitudeY'] = 0.0;
      } else {
        let movement = this._getAverageMovement();
        detail['magnitudeX'] = movement.x;
        detail['magnitudeY'] = movement.y;
      }
    }

    let gev = new CustomEvent(type, { detail: detail });
    this._target.dispatchEvent(gev);
  }

  _stateToGesture(state) {
    switch (state) {
      case GH_ONETAP:
        return 'onetap';
      case GH_TWOTAP:
        return 'twotap';
      case GH_THREETAP:
        return 'threetap';
      case GH_DRAG:
        return 'drag';
      case GH_LONGPRESS:
        return 'longpress';
      case GH_TWODRAG:
        return 'twodrag';
      case GH_PINCH:
        return 'pinch';
    }

    throw new Error('Unknown gesture state: ' + state);
  }

  _getPosition() {
    if (this._tracked.length === 0) {
      throw new Error('Failed to get gesture position, no tracked touches');
    }

    let size = this._tracked.length;
    let fx = 0,
      fy = 0,
      lx = 0,
      ly = 0;

    for (let i = 0; i < this._tracked.length; i++) {
      fx += this._tracked[i].firstX;
      fy += this._tracked[i].firstY;
      lx += this._tracked[i].lastX;
      ly += this._tracked[i].lastY;
    }

    return { first: { x: fx / size, y: fy / size }, last: { x: lx / size, y: ly / size } };
  }

  _getAverageMovement() {
    if (this._tracked.length === 0) {
      throw new Error('Failed to get gesture movement, no tracked touches');
    }

    let totalH, totalV;
    totalH = totalV = 0;
    let size = this._tracked.length;

    for (let i = 0; i < this._tracked.length; i++) {
      totalH += this._tracked[i].lastX - this._tracked[i].firstX;
      totalV += this._tracked[i].lastY - this._tracked[i].firstY;
    }

    return { x: totalH / size, y: totalV / size };
  }

  _getAverageDistance() {
    if (this._tracked.length === 0) {
      throw new Error('Failed to get gesture distance, no tracked touches');
    }

    // Distance between the first and last tracked touches

    let first = this._tracked[0];
    let last = this._tracked[this._tracked.length - 1];

    let fdx = Math.abs(last.firstX - first.firstX);
    let fdy = Math.abs(last.firstY - first.firstY);

    let ldx = Math.abs(last.lastX - first.lastX);
    let ldy = Math.abs(last.lastY - first.lastY);

    return { first: { x: fdx, y: fdy }, last: { x: ldx, y: ldy } };
  }
}
