// Copyright (c) 2011, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/**
 * Click buster implementation, which is a behavior that prevents native clicks
 * from firing at undesirable times. There are two scenarios where we may want
 * to 'bust' a click.
 *
 * Buttons implemented with touch events usually have click handlers as well.
 * This is because sometimes touch events stop working, and the click handler
 * serves as a fallback. Here we use a click buster to prevent the native click
 * from firing if the touchend event was succesfully handled.
 *
 * When native scrolling behavior is disabled (see Scroller), click events will
 * fire after the touchend event when the drag sequence is complete. The click
 * event also happens to fire at the location of the touchstart event which can
 * lead to some very strange behavior.
 *
 * This class puts a single click handler on the body, and calls preventDefault
 * on the click event if we detect that there was a touchend event that already
 * fired in the same spot recently.
 */
class ClickBuster {
  /**
   * The threshold for how long we allow a click to occur after a touchstart.
   */
  static final _TIME_THRESHOLD = 2500;

  /**
   * The threshold for how close a click has to be to the saved coordinate for
   * us to allow it.
   */
  static final _DISTANCE_THRESHOLD = 25;

  /**
   * The list of coordinates that we use to measure the distance of clicks from.
   * If a click is within the distance threshold of any of these coordinates
   * then we allow the click.
   * TODO(ngeoffray): Should be DoubleLinkedQueue<num>
   */
  static var _coordinates;

  /** The last time preventGhostClick was called. */
  static int _lastPreventedTime;

  /**
   * This handler will prevent the default behavior for any clicks unless the
   * click is within the distance threshold of one of the temporary allowed
   * coordinates.
   */
  static void _onClick(Event e) {
    if (TimeUtil.now() - _lastPreventedTime > _TIME_THRESHOLD) {
      return;
    }
    final coord = new Coordinate.fromClient(e);
    // TODO(rnystrom): On Android, we get spurious click events at (0, 0). We
    // *do* want those clicks to be busted, so commenting this out fixes it.
    // Leaving it commented out instead of just deleting it because I'm not sure
    // what this code was intended to do to begin with.
    /*
    if (coord.x < 1 && coord.y < 1) {
      // TODO(jacobr): implement a configurable logging framework.
      // _logger.warning(
      //     "Not busting click on label elem at(${coord.x}, ${coord.y})");
      return;
    }
    */
    var entry = _coordinates.firstEntry();
    while (entry != null) {
      if (_hitTest(entry.element,
                   entry.nextEntry().element,
                   coord.x,
                   coord.y)) {
        entry.nextEntry().remove();
        entry.remove();
        return;
      } else {
        entry = entry.nextEntry().nextEntry();
      }
    }

    // TODO(jacobr): implement a configurable logging framework.
    // _logger.warning("busting click at ${coord.x}, ${coord.y}");
    e.stopPropagation();
    e.preventDefault();
  }

  /**
   * This handler will temporarily allow a click to occur near the touch event's
   * coordinates.
   */
  static void _onTouchStart(Event e) {
    TouchEvent te = e;
    final coord = new Coordinate.fromClient(te.touches[0]);
    _coordinates.add(coord.x);
    _coordinates.add(coord.y);
    window.setTimeout(() {
      _removeCoordinate(coord.x, coord.y);
    }, _TIME_THRESHOLD);
    _toggleTapHighlights(true);
  }

  /**
   * Hit test for whether a coordinate is within the distance threshold of an
   * event.
   */
  static bool _hitTest(num x, num y, num eventX, num eventY) {
    return (eventX - x).abs() < _DISTANCE_THRESHOLD &&
        (eventY - y).abs() < _DISTANCE_THRESHOLD;
  }

  /**
   * Remove one specified coordinate from the coordinates list.
   */
  static void _removeCoordinate(num x, num y) {
    var entry = _coordinates.firstEntry();
    while (entry != null) {
      if (entry.element == x && entry.nextEntry().element == y) {
        entry.nextEntry().remove();
        entry.remove();
        return;
      } else {
        entry = entry.nextEntry().nextEntry();
      }
    }
  }

  /**
   * Enable or disable tap highlights. They are disabled when preventGhostClick
   * is called so that the flicker on links is not invoked when the ghost click
   * does fire. This is due to a bug: links get highlighted even if the click
   * event has preventDefault called on it.
   */
  static void _toggleTapHighlights(bool enable) {
    document.body.style.setProperty(
        "-webkit-tap-highlight-color", enable ? "" : "rgba(0,0,0,0)", "");
  }

  /**
   * Registers new touches to create temporary "allowable zones" and registers
   * new clicks to be prevented unless they fall in one of the current
   * "allowable zones". Note that if the touchstart and touchend locations are
   * different, it is still possible for a ghost click to be fired if you
   * called preventDefault on all touchmove events. In this case the ghost
   * click will be fired at the location of the touchstart event, so the
   * coordinate you pass in should be the coordinate of the touchstart.
   */
  static void preventGhostClick(num x, num y) {
    // First time this is called the following occurs:
    //   1) Attaches a handler to touchstart events so that each touch will
    //      temporarily create an "allowable zone" for clicks to occur in.
    //   2) Attaches a handler to click events so that each click will be
    //      prevented unless it is in an "allowable zone".
    //
    // Every time this is called (including the first) the following occurs:
    //   1) Removes an allowable zone that contains the specified coordinate.
    //
    // How this enables click busting:
    //   1) User performs first click.
    //     - No attached touchstart handler yet.
    //     - preventGhostClick is called before the click event occurs, it
    //       attaches the touchstart and click handlers.
    //     - The click handler captures the user's click event and prevents it
    //       from propagating since there is no "allowable zone".
    //
    //   2) User performs subsequent, to-be-busted click.
    //     - touchstart event triggers the attached handler and creates a
    //       temporary "allowable zone".
    //     - preventGhostClick is called and removes the "allowable zone".
    //     - The click handler captures the user's click event and prevents it
    //       from propagating since there is no "allowable zone".
    //
    //   3) User performs a should-not-be-busted click.
    //     - touchstart event triggers the attached handler and creates a
    //       temporary "allowable zone".
    //     - The click handler captures the user's click event and allows it to
    //       propagate since the click falls in the "allowable zone".
    if (_coordinates === null) {
      // Listen to clicks on capture phase so they can be busted before anything
      // else gets a chance to handle them.
      document.on.click.add((e) { _onClick(e); }, true);
      document.on.focus.add((e) { _lastPreventedTime = 0; }, true);

      // Listen to touchstart on capture phase since it must be called prior to
      // every click or else we will accidentally prevent the click even if we
      // don't call preventGhostClick.
      Function startFn = (e) { _onTouchStart(e); };
      if (!Device.supportsTouch) {
        startFn = mouseToTouchCallback(startFn);
      }
      EventUtil.observe(document,
          Device.supportsTouch ? document.on.touchStart : document.on.mouseDown,
          startFn, true, true);
      _coordinates = new Queue<num>();
    }

    // Turn tap highlights off until we know the ghost click has fired.
    _toggleTapHighlights(false);

    // Above all other rules, we won't bust any clicks if there wasn't some call
    // to preventGhostClick in the last time threshold.
    _lastPreventedTime = TimeUtil.now();
    var entry = _coordinates.firstEntry();
    while (entry != null) {
      if (_hitTest(entry.element, entry.nextEntry().element, x, y)) {
        entry.nextEntry().remove();
        entry.remove();
        return;
      } else {
        entry = entry.nextEntry().nextEntry();
      }
    }
  }
}
