/*
 * This file implements several helpers for fixing up full-page web apps on touchscreen
 * browsers:
 *
 * AndroidDetectWindowSize
 * EmulateDoubleClick
 * ResponsiveSingleClick
 * PreventDragScrolling
 *
 * Most of these are annoying hacks to work around the fact that WebKit on browsers was
 * designed with displaying scrolling webpages in mind, apparently without consideration
 * for full-screen applications: pages that should fill the screen at all times.  Most
 * of the browser mobile hacks no longer make sense: separate display viewports, touch
 * dragging, double-click zooming and their associated side-effects.
 */


/*
 * AndroidDetectWindowSize
 *
 * Implementing a full-page web app for Android is hard, because if you set the page to
 * "width: 100%; height: 100%;" it'll eat a big chunk of the screen with the address bar
 * which can't be scrolled off in that configuration.  We have to play games to figure out
 * the real size of the window, and set the body size to it explicitly.  This handler does
 * the following:
 *
 * - capture resize events
 * - cancel the resize event; we'll fire it again when we're done
 * - enable a large padding div, to ensure that we can scroll the window downward
 * - window.scrollTo(0, 99999999) to scroll the address bar off screen, which increases the window
 *   size to the maximum.  We use a big value here, because Android has a broken scrollTo, which
 *   animates to the specified position.  If we say (0, 1), then it'll take a while to scroll
 *   there; by giving it a huge value, it'll scroll past the scrollbar in one frame.
 * - wait a little while.  We need to wait for one frame of scrollTo's animation, but we don't
 *   know how long that'll be, so we need to poll with a timer periodically, checking
 *   document.body.scrollTop.
 * - set the body to the size of the window
 * - hide the padding div
 * - synthesize a new resize event to continue other event handlers that we originally cancelled
 *
 * resize will always be fired at least once as a result of constructing this class.
 *
 * This is only used on Android.
 */

function AndroidDetectWindowSize()
{
  $("sizing-body").setStyle({overflow: "hidden"});

  /* This is shown to make sure we can scroll the address bar off.  It goes outside
   * of #sizing-body, so it's not clipped.  By not changing #sizing-body itself, we
   * avoid reflowing the entire document more than once, when we finish. */
  this.padding = document.createElement("DIV");
  this.padding.setStyle({width: "1px", height: "5000px"});
  this.padding.style.visibility = "hidden";
  this.padding.hide();
  document.documentElement.appendChild(this.padding);

  this.window_size = [0, 0];
  this.finish = this.finish.bind(this);
  this.event_onresize = this.event_onresize.bindAsEventListener(this);

  this.finish_timer = null;
  this.last_window_orientation = window.orientation;

  window.addEventListener("resize", this.event_onresize, true);

  this.active = false;

  /* Kick off a detection cycle.  On Android 2.1, we can't do this immediately after onload; for
   * some reason this triggers some very strange browser bug where the screen will jitter up and
   * down, as if our scrollTo is competing against the browser trying to scroll somewhere.  For
   * older browsers, delay before starting.  This is no longer needed on Android 2.2. */
  var delay_seconds = 0;
  var m = navigator.userAgent.match(/Android (\d+\.\d+)/);
  if(m && parseFloat(m[1]) < 2.2)
  {
    debug("Delaying bootstrapping due to Android version " + m[1]);
    delay_seconds = 1;
  }

  /* When this detection cycle completes, a resize event will be fired so listeners can
   * act on the detected window size. */
  this.begin.bind(this).delay(delay_seconds);
}

/* Return true if Android resize handling is needed. */
AndroidDetectWindowSize.required = function()
{
  // XXX: be more specific
  return navigator.userAgent.indexOf("Android") != -1;
}

/* After we set the window size, dispatch a resize event so other listeners will notice
 * it. */
AndroidDetectWindowSize.prototype.dispatch_resize_event = function()
{
  debug("dispatch final resize event");
  var e = document.createEvent("Event");
  e.initEvent("resize", true, true);
  document.documentElement.dispatchEvent(e);
}

AndroidDetectWindowSize.prototype.begin = function()
{
  if(this.active)
    return;

  var initial_window_size = this.current_window_size();
  if(this.window_size && initial_window_size[0] == this.window_size[0] && initial_window_size[1] == this.window_size[1])
  {
    debug("skipped window size detection");
    return;
  }

  debug("begin window size detection, " + initial_window_size[0] + "x" + initial_window_size[1] + " at start (scroll pos " + document.documentElement.scrollHeight + ")");
  this.active = true;
  this.padding.show();

  /* If we set a sizing-body the last time, remove it before running again. */
  $("sizing-body").setStyle({width: "0px", height: "0px"});

  window.scrollTo(0, 99999999);
  this.finish_timer = window.setTimeout(this.finish, 0);
}

AndroidDetectWindowSize.prototype.end = function()
{
  if(!this.active)
    return;
  this.active = false;

  if(this.begin_timer != null)
    window.clearTimeout(this.begin_timer);
  this.begin_timer = null;

  if(this.finish_timer != null)
    window.clearTimeout(this.finish_timer);
  this.finish_timer = null;

  this.padding.hide();
}

AndroidDetectWindowSize.prototype.current_window_size = function()
{
  var size = [window.innerWidth, window.innerHeight];

  // We need to fudge the height up a pixel, or in many cases we'll end up with a white line
  // at the bottom of the screen (or the top in 2.3).  This seems to be sub-pixel rounding
  // error.
  ++size[1];

  return size;
}

AndroidDetectWindowSize.prototype.finish = function()
{
  if(!this.active)
    return;
  debug("window size detection: finish(), at " + document.body.scrollTop);

  /* scrollTo is supposed to be synchronous.  Android's animates.  Worse, the time it'll
   * update the animation is nondeterministic; it might happen as soon as we return from
   * calling scrollTo, or it might take a while.  Check whether we've scrolled down; if
   * we're still at the top, keep waiting. */
  if(document.body.scrollTop == 0)
  {
    console.log("Waiting for scroll...");
    this.finish_timer = window.setTimeout(this.finish, 10);
    return;
  }

  /* The scroll may still be trying to run. */
  window.scrollTo(document.body.scrollLeft, document.body.scrollTop);
  this.end();

  this.window_size = this.current_window_size();

  debug("new window size: " + this.window_size[0] + "x" + this.window_size[1]);
  $("sizing-body").setStyle({width: this.window_size[0] + "px", height: (this.window_size[1]) + "px"});

  this.dispatch_resize_event();
}

AndroidDetectWindowSize.prototype.event_onresize = function(e)
{
  if(this.last_window_orientation != window.orientation)
  {
    e.stop();

    this.last_window_orientation = window.orientation;
    if(this.active)
    {
      /* The orientation changed while we were in the middle of detecting the resolution.
       * Start over. */
      debug("Orientation changed while already detecting window size; restarting");
      this.end();
    }
    else
    {
      debug("Resize received with an orientation change; beginning");
    }

    this.begin();
    return;
  }

  if(this.active)
  {
    /* Suppress resize events while we're active, since many of them will fire.
     * Once we finish, we'll fire a single one. */
    debug("stopping resize event while we're active");
    e.stop();
    return;
  }
}


/*
 * Work around a bug on many touchscreen browsers: even when the page isn't
 * zoomable, dblclick is never fired.  We have to emulate it.
 *
 * This isn't an exact emulation of the event behavior:
 *
 * - It triggers from touchstart rather than mousedown.  The second mousedown
 *   of a double click isn't being fired reliably in Android's WebKit.
 *
 * - preventDefault on the triggering event should prevent a dblclick, but
 *   we can't find out if it's been called; there's nothing like Firefox's
 *   getPreventDefault.  We could mostly emulate this by overriding
 *   Event.preventDefault to set a flag that we can read.
 *
 * - The conditions for a double click won't match the ones of the platform.
 *
 * This is needed on Android and iPhone's WebKit.
 *
 * Note that this triggers a minor bug on Android: after firing a dblclick event,
 * we no longer receive mousemove events until the touch is released, which means
 * PreventDragScrolling can't cancel dragging.
 */

function EmulateDoubleClick()
{
  this.touchstart_event = this.touchstart_event.bindAsEventListener(this);
  this.touchend_event = this.touchend_event.bindAsEventListener(this);
  this.last_click = null;

  window.addEventListener("touchstart", this.touchstart_event, false);
  window.addEventListener("touchend", this.touchend_event, false);
}

EmulateDoubleClick.prototype.touchstart_event = function(event)
{
  var this_touch = event.changedTouches[0];
  var last_click = this.last_click;

  /* Don't store event.changedTouches or any of its contents.  Some browsers modify these
   * objects in-place between events instead of properly returning unique events. */
  var this_click = {
    timeStamp: event.timeStamp,
    target: event.target,
    identifier: this_touch.identifier,
    position: [this_touch.screenX, this_touch.screenY],
    clientPosition: [this_touch.clientX, this_touch.clientY]
  }
  this.last_click = this_click;

  if(last_click == null)
      return;

  /* If the first tap was never released then this is a multitouch double-tap.
   * Clear the original tap and don't fire anything. */
  if(event.touches.length > 1)
    return;

  /* Check that not too much time has passed. */
  var time_since_previous = event.timeStamp - last_click.timeStamp;
  if(time_since_previous > 500)
    return;

  /* Check that the clicks aren't too far apart. */
  var distance = Math.pow(this_touch.screenX - last_click.position[0], 2) + Math.pow(this_touch.screenY - last_click.position[1], 2);
  if(distance > 500)
    return;

  if(event.target != last_click.target)
    return;

  /* Synthesize a dblclick event.  Use the coordinates of the first click as the location
   * and not the second click, since if the position matters the user's first click of
   * a double-click is probably more precise than the second. */
  var e = document.createEvent("MouseEvent");
  e.initMouseEvent("dblclick", true, true, window,
                     2,
                     last_click.position[0], last_click.position[1],
                     last_click.clientPosition[0], last_click.clientPosition[1],
                     false, false,
                     false, false,
                     0, null);

  this.last_click = null;
  event.target.dispatchEvent(e);
}

EmulateDoubleClick.prototype.touchend_event = function(event)
{
  if(this.last_click == null)
    return;

  var last_click_identifier = this.last_click.identifier;
  if(last_click_identifier == null)
    return;

  var last_click_position = this.last_click.position;
  var this_click = event.changedTouches[0];
  if(this_click.identifier == last_click_identifier)
  {
    /* If the touch moved too far when it was removed, don't fire a doubleclick; for
     * example, two quick swipe gestures aren't a double-click. */
    var distance = Math.pow(this_click.screenX - last_click_position[0], 2) + Math.pow(this_click.screenY - last_click_position[1], 2);
    if(distance > 500)
    {
      this.last_click = null;
      return;
    }
  }
}

/*
 * Mobile WebKit has serious problems with the click event: it delays them for the
 * entire double-click timeout, and if a double-click happens it doesn't deliver the
 * click at all.  This makes clicks unresponsive, and it has this behavior even
 * when the page can't be zoomed, which means nothing happens at all.
 *
 * Generate click events from touchend events to bypass this mess.
 */
ResponsiveSingleClick = function()
{
  this.click_event = this.click_event.bindAsEventListener(this);
  this.touchstart_event = this.touchstart_event.bindAsEventListener(this);
  this.touchend_event = this.touchend_event.bindAsEventListener(this);

  this.last_touch = null;

  window.addEventListener("touchstart", this.touchstart_event, false);
  window.addEventListener("touchend", this.touchend_event, false);

  /* This is a capturing listener, so we can intercept clicks before they're
   * delivered to anyone. */
  window.addEventListener("click", this.click_event, true);
}

ResponsiveSingleClick.prototype.touchstart_event = function(event)
{
  /* If we get a touch while we already have a touch, it's multitouch, which is never
   * a click, so cancel the click. */
  if(this.last_touch != null)
  {
    debug("Cancelling click (multitouch)");
    this.last_touch = null;
    return;
  }

  /* Watch out: in older versions of WebKit, the event.touches array and the items inside
   * it are actually modified in-place when the user drags.  That means that we can't just
   * save the entire array for comparing in touchend. */
  var touch = event.changedTouches[0];
  this.last_touch = [touch.screenX, touch.screenY];
}

ResponsiveSingleClick.prototype.touchend_event = function(event)
{
  var last_touch = this.last_touch;
  if(last_touch == null)
    return;
  this.last_touch = null;

  var touch = event.changedTouches[0];
  var this_touch = [touch.screenX, touch.screenY];

  /* Don't trigger a click if the point has moved too far. */
  var distance = distance_squared(this_touch[0], this_touch[1], last_touch[0], last_touch[1]);
  if(distance > 50)
    return;

  var e = document.createEvent("MouseEvent");
  e.initMouseEvent("click", true, true, window,
                     1,
                     touch.screenX, touch.screenY,
                     touch.clientX, touch.clientY,
                     false, false,
                     false, false,
                     0, /* touch clicks are always button 0 - maybe not for multitouch */
                     null);
  e.synthesized_click = true;

  /* If we dispatch the click immediately, EmulateDoubleClick won't receive a
   * touchstart for the next click.  Defer dispatching it until we return. */
  (function() { event.target.dispatchEvent(e); }).defer();
}

/* Capture and cancel all clicks except the ones we generate. */
ResponsiveSingleClick.prototype.click_event = function(event)
{
  if(!event.synthesized_click)
    event.stop();
}

/* Stop all touchmove events on the document, to prevent dragging the window around. */
PreventDragScrolling = function()
{
  Element.observe(document, "touchmove", function(event) {
    event.preventDefault();
  });
}


/*
 * Save the URL hash to local DOM storage when it changes.  When called, restores the
 * previously saved hash.
 *
 * This is used on the iPhone only, and only when operating in web app mode (window.standalone).
 * The iPhone doesn't update the URL hash saved in the web app shortcut, nor does it
 * remember the current URL when using make-believe multitasking, which means every time
 * you switch out and back in you end up back to wherever you were when you first created
 * the web app shortcut.  Saving the URL hash allows switching out and back in without losing
 * your place.
 *
 * This should only be used in environments where it's been tested and makes sense.  If used
 * in a browser, or in a web app environment that properly tracks the URL hash, this will
 * just interfere with normal operation.
 */
var MaintainUrlHash = function()
{
  /* This requires DOM storage. */
  if(LocalStorageDisabled())
    return;

  /* When any part of the URL hash changes, save it. */
  var update_stored_hash = function(changed_hash_keys, old_hash, new_hash)
  {
    var hash = localStorage.current_hash = UrlHash.get_raw_hash();
  }
  UrlHash.observe(null, update_stored_hash);

  /* Restore the previous hash, if any. */
  var hash = localStorage.getItem("current_hash");
  if(hash)
    UrlHash.set_raw_hash(hash);
}

/*
 * In some versions of the browser, iPhones don't send resize events after an
 * orientation change, so we need to fire it ourself.  Try not to do this if not
 * needed, so we don't fire spurious events.
 *
 * This is never needed in web app mode.
 *
 * Needed on user-agents:
 * iPhone OS 4_0_2 ... AppleWebKit/532.9 ... Version/4.0.5
 * iPhone OS 4_1 ... AppleWebKit/532.9 ... Version/4.0.5
 *
 * Not needed on:
 * (iPad, OS 3.2)
 * CPU OS 3_2 ... AppleWebKit/531.1.10 ... Version/4.0.4
 * iPhone OS 4_2 ... AppleWebKit/533.17.9 ... Version/5.0.2
 *
 * This seems to be specific to Version/4.0.5.
 */
var SendMissingResizeEvents = function()
{
  if(window.navigator.standalone)
    return;
  if(navigator.userAgent.indexOf("Version/4.0.5") == -1)
    return;

  var last_seen_orientation = window.orientation;
  window.addEventListener("orientationchange", function(e) {
    if(last_seen_orientation == window.orientation)
      return;
    last_seen_orientation = window.orientation;

    debug("dispatch fake resize event");
    var e = document.createEvent("Event");
    e.initEvent("resize", true, true);
    document.documentElement.dispatchEvent(e);
  }, true);
}

var InitializeFullScreenBrowserHandlers = function()
{
  /* These handlers deal with heavily browser-specific issues.  Only install them
   * on browsers that have been tested to need them. */
  if(navigator.userAgent.indexOf("Android") != -1 && navigator.userAgent.indexOf("WebKit") != -1)
  {
    new ResponsiveSingleClick();
    new EmulateDoubleClick();
  }
  else if((navigator.userAgent.indexOf("iPhone") != -1 || navigator.userAgent.indexOf("iPad") != -1 || navigator.userAgent.indexOf("iPod") != -1)
      && navigator.userAgent.indexOf("WebKit") != -1)
  {
    new ResponsiveSingleClick();
    new EmulateDoubleClick();

    /* In web app mode only: */
    if(window.navigator.standalone)
      MaintainUrlHash();

    SendMissingResizeEvents();
  }

  PreventDragScrolling();
}

SwipeHandler = function(element)
{
  this.element = element;
  this.dragger = new DragElement(element, { ondrag: this.ondrag.bind(this), onstartdrag: this.startdrag.bind(this) });
}

SwipeHandler.prototype.startdrag = function()
{
  this.swiped_horizontal = false;
  this.swiped_vertical = false;
}

SwipeHandler.prototype.ondrag = function(e)
{
  if(!this.swiped_horizontal)
  {
    // XXX: need a guessed DPI
    if(Math.abs(e.aX) > 100)
    {
      this.element.fire("swipe:horizontal", {right: e.aX > 0});
      this.swiped_horizontal = true;
    }
  }

  if(!this.swiped_vertical)
  {
    if(Math.abs(e.aY) > 100)
    {
      this.element.fire("swipe:vertical", {down: e.aY > 0});
      this.swiped_vertical = true;
    }
  }
}

SwipeHandler.prototype.destroy = function()
{
  this.dragger.destroy();
}

