/* Copyright © 2009-2010, Julien Quint <consulting@romulusetrem.us>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *   • Redistributions of source code must retain the above copyright notice,
 *     this list of conditions and the following disclaimer.
 *   • Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *   • Neither the name of consulting.romulusetrem.us nor the names of its
 *     contributors may be used to endorse or promote products derived from
 *     this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE. */

// Bind the function f to the object x. Additional arguments can be provided to
// specialize the bound function.
function bind(f, x)
{
  var args = Array.prototype.slice.call(arguments, 2);
  return function() {
    return f.apply(x, args.concat(Array.prototype.slice.call(arguments)));
  };
}

var $ = bind(document.querySelector, document);
var $$ = bind(document.querySelectorAll, document);

// JSLint doesn't like it but it's the twenty-first century, dammit.
var π = Math.PI;

// For each for NodeList.
NodeList.prototype.for_each = function(f)
{
  for (var i = 0, n = this.length; i < n; ++i) f(this.item(i), i);
};

// Map for NodeList; returns an array, not a NodeList.
NodeList.prototype.map = function(f)
{
  var a = [];
  for (var i = 0, n = this.length; i < n; ++i) a.push(f(this.item(i)));
  return a;
};

// Filter for NodeList; returns an array, not a NodeList.
NodeList.prototype.filter = function(f)
{
  var a = [];
  for (var i = 0, n = this.length; i < n; ++i) {
    if (f(this.item(i))) a.push(this.item(i));
  }
  return a;
};

// Return the first object x in the array such that p(x) is true; null if none
// is found.
Array.prototype.find_first = function(p)
{
  for (var i = 0, n = this.length; i < n; ++i) {
    if (p(this[i])) return this[i];
  }
  return null;
};

// Create a new tag with the given attributes
function create_tag(ns, name, attrs)
{
  var elem = document.createElementNS(ns, name);
  for (var a in attrs) elem.setAttribute(a, attrs[a]);
  return elem;
}

// Create a new XHTML tag.
function create(tag_name, attrs)
{
  return create_tag("http://www.w3.org/1999/xhtml", tag_name, attrs);
}

// Create a new SVG tag.
function create_svg(tag_name, attrs)
{
  return create_tag("http://www.w3.org/2000/svg", tag_name, attrs);
}

// Local storage wrapper -- handles cases where localStorage is not available
// and evaluate JSON if necessary (default is not.) Silently catch exceptions.
// In case of failure (no storage, no key, cannot eval) return null.
function get_from_storage(key, as_json)
{
  if (as_json === undefined) as_json = true;
  var data = null;
  if (window.localStorage) {
    data = localStorage.getItem(key);
    if (data != null && as_json) {
      try { data = eval("({0})".fmt(data)); } catch (e) {}
    }
  }
  return data;
}

// Extend the prototype of a class with that of another; do not overload
// functions with the same name.
function extend_prototype(target, source, proto)
{
  if (proto !== undefined) target.prototype = proto;
  for (var p in source.prototype) {
    if (!(p in target.prototype)) target.prototype[p] = source.prototype[p];
  }
}

// Introduce the localization namespace; to be filled up by applications using
// the global L10N object.
if (L10N === undefined) var L10N = {};
L10N.OPEN_FILE_FAILED_WITH_STATUS = "Open file failed with status {0}.";
L10N.OPEN_FILE_FAILED_EXCEPTION = "Open file failed, exception: {0}";

// Load a file using XMLHttpRequest.
// If mime_type is defined, override MIME type with this type.
// TODO load distant files asynchronously
function load_file(uri, mime_type)
{
  try
  {
    var req = new XMLHttpRequest();
    if (mime_type) req.overrideMimeType(mime_type);
    req.open("GET", uri, false);
    req.send();
    if (req.status == 200) {
      return req.responseXML;
    } else {
      alert(L10N.OPEN_FILE_FAILED_WITH_STATUS.fmt(req.status));
    }
  }
  catch (e)
  {
    alert(L10N.OPEN_FILE_FAILED_EXCEPTION.fmt(e));
  }
  return null;
}

function try_load(uri, mime_type)
{
  var req = new XMLHttpRequest();
  if (mime_type) req.overrideMimeType(mime_type);
  req.open("GET", uri, false);
  req.send();
  return req.status == 200 ? req.responseXML : null;
}

// Make buttons for a hash mapping elements (through document.querySelector)
// and actual functions
function make_buttons(f) { for (var i in f) make_button($(i), f[i]); }

// Turn an element into a button. Add a "button" class (if necessary) and set
// event listeners to execute the given function when clicked. Use the "push"
// and "disabled" classes as well.
// Add the _function field to the element so that it can also be called
// directly. Can be called safely on an undefined element so that buttons can
// be easily commented out.
function make_button(elem, f)
{
  if (elem) {
    elem.add_class("button");
    elem._function = f;
    // Push the button when clicking it (only if not disabled)
    elem.addEventListener("mousedown", function(e) {
        if (!e.target.has_class("disabled")) e.target.add_class("pushed");
        e.preventDefault();
      }, false);
    // Raise the button again when the mouse leaves
    elem.addEventListener("mouseout", function(e) {
        e.target.remove_class("pushed");
        e.preventDefault();
      }, false);
    // Launch the associated function if the button was pressed
    elem.addEventListener("mouseup", function(e) {
        if (e.target.remove_class("pushed")) f();
        e.preventDefault();
      }, false);
  }
}

// Transform an input box into a number box: accept only numbers (between
// bounds given as parameters, optionally) and increase/decrease by dragging
// the mouse ala MAX/PD.
function make_number_box(elem, is_integer, step_size, min, max)
{
  var num_box = null;
  if (elem) {
    elem.add_class("num_box");
    elem._prev_value = parseFloat(elem.value);
    elem.update_value = function(x) {
      elem.value = x;
      elem._prev_value = x;
    }
    // Check that a new value is a number between the bounds
    // and send a custom @num_box_value event
    elem.addEventListener("change", function(e) {
        var v = parseFloat(elem.value);
        if (isNaN(v) || (min !== undefined && v < min) ||
          (max !== undefined && v > max)) v = elem._prev_value;
        if (is_integer) v = Math.round(v);
        elem.value = v;
        elem.blur();
        send_event(elem, "@num_box_value", elem._prev_value);
        elem._prev_value = v;
      }, false);
    elem.addEventListener("mousedown", function(e) {
        num_box = { elem: elem, y: e.pageY, is_integer: is_integer,
          step_size: step_size };
        elem.add_class("dragging");
      }, false);
    // Add a global event listener (if not already) to track down dragging
    // when a number box was clicked
    // TODO handle shift+click for finer dragging
    document.addEventListener("mousemove", function(e) {
        if (num_box) {
          var v = num_box.elem._prev_value +
            (num_box.y - e.pageY) * num_box.step_size;
          if (num_box.is_integer) v = Math.round(v);
          if (isNaN(v)) {
            v = num_box.elem._prev_value;
          } else if (min !== undefined && v < min) {
            v = min;
          } else if (max !== undefined && v > max) {
            v = max;
          }
          num_box.elem.value = v;
          send_event(elem, "@num_box_change");
        }
      }, false);
    document.addEventListener("mouseup", function(e) {
        if (num_box) {
          send_event(elem, "@num_box_value", num_box.elem._prev_value);
          num_box.elem._prev_value = parseFloat(num_box.elem.value);
          num_box.elem.remove_class("dragging");
          num_box = null;
        }
      }, false);
  }
}

// Special keys -- follow the syntax in this list.
var KEYS = { Del: 8 };

// Make shortcuts for the commands of buttons
// TODO merge with make_buttons, something like:
//   #foo_button: [function() { foo(); }, "F"]
// TODO handle shift/ctrl/alt/meta (multiple dispatch arrays)
function make_shortcuts(h)
{
  var shortcuts = { Ctrl: {} };
  for (var key in h) {
    var keys = key.split(/\+/);
    var last_key = keys.pop();
    var code = last_key.length > 1 ? KEYS[last_key] : last_key.charCodeAt(0);
    var b = $(h[key]);
    if (keys[0] === "Ctrl") {
      shortcuts.Ctrl[code] = b;
    } else {
      shortcuts[code] = b;
    }
  }
  // Set shortcut
  document.addEventListener("keydown", function(e) {
      // Ignore input elements or those that are set to contenteditable
      // Note that contentEditable has a string value (check the possible
      // values for completeness)
      var skip = typeof(e.target) === "object" && e.target != null &&
        (e.target.constructor === HTMLInputElement ||
         e.target.contentEditable != "false");
      if (!skip) {
        var h = e.ctrlKey ? shortcuts.Ctrl : shortcuts;
        if (e.keyCode in h) {
          var b = h[e.keyCode];
          if (!b.has_class("disabled")) {
            b._function();
            e.preventDefault();
          }
        }
      }
    }, false);
}

// Pretty print an XML node to a string
function pretty_print(node, indent, cindent)
{
  if (indent === undefined) indent = "  ";
  if (cindent === undefined) cindent = "";
  if (node.nodeType == Node.ELEMENT_NODE) {
    return pretty_print_elem(node, indent, cindent);
  } else if (node.nodeType == Node.TEXT_NODE) {
    return pretty_print_text(node, indent, cindent);
  }
  return "";
}

// Pretty print an XML element to a string
function pretty_print_elem(elem, indent, cindent)
{
  var str = "{0}&lt;<span class=\"pp_tagname\">{1}</span>"
    .fmt(cindent, elem.tagName);
  for (var i = 0, n = elem.attributes.length; i < n; ++i) {
    var attr = elem.attributes[i];
    str += " <span class=\"pp_attrname\">{0}</span>=\"<span class=\"pp_attrvalue\">{1}</span>\""
      .fmt(attr.name, attr.value);
  }
  if (elem.childNodes.length > 0) {
    str += "&gt;\n";
    for (var i = 0, n = elem.childNodes.length; i < n; ++i) {
      str += pretty_print(elem.childNodes[i], indent, indent + cindent);
    }
    str += "{0}&lt;/<span class=\"pp_tagname\">{1}</span>&gt;\n"
      .fmt(cindent, elem.tagName);
  } else {
    str += "/&gt;\n";
  }
  return str;
}

// Pretty print an XML text string to a string
function pretty_print_text(node, _, cindent)
{
  return "{0}{1}\n".fmt(cindent, node.textContent);
}

// Quote a string for JSON output.
function quote_json(str)
{
  return str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
}


// Remove a given element from the array
Array.prototype.remove = function(x)
{
  var index = this.indexOf(x);
  if (index >= 0) this.splice(index, 1);
};


// Simple format function for messages. Use {0}, {1}... as slots for
// parameters. TODO: named parameters/evaled parameters.
String.prototype.fmt = function()
{
  var args = Array.prototype.slice.call(arguments);
  return this.replace(/{(\d+)}/g,
    function(str, p) { return args[p] === undefined ? str : args[p]; });
};


// Class manipulation and other HTMLElement extensions
// TODO should be on DOM nodes actually

// Append a class to an element (if it does not contain it already)
HTMLElement.prototype.add_class = function(c)
{
  var classes = this.className.split(/\s+/);
  if (classes.indexOf(c) < 0) {
    classes.push(c);
    this.className = classes.join(" ");
  }
};

// Append a text node to an element
HTMLElement.prototype.add_text = function(t)
{
  this.appendChild(document.createTextNode(t));
};

// Get position of an element by going up the element tree
HTMLElement.prototype.get_position = function()
{
  var p = this.offsetParent && this.offsetParent.get_position ?
    this.offsetParent.get_position() : { x: 0, y: 0 };
  p.x += this.offsetLeft;
  p.y += this.offsetTop;
  return p;
}

// Test whether the class attribute of an element contains a class.
HTMLElement.prototype.has_class = function(c)
{
  return (new RegExp("\\b{0}\\b".fmt(c))).test(this.className);
};

// Insert the given element at the given index relative to the list of elements
// (i.e. ignoring all other child nodes.)
HTMLElement.prototype.insert_element_at = function(element, index)
{
  var children = this.childNodes;
  var n = children.length;
  var i = 0;
  for (; index > 0 && i < n; ++i) {
    if (children[i].nodeType == Node.ELEMENT_NODE) --index;
  }
  if (i < n) {
    this.insertBefore(element, children[i]);
  } else {
    this.appendChild(element);
  }
};

// Safely remove the class c from the classes of an element; do nothing if it
// was not present in the list. Return the removed class or null if it was not
// present in the first place.
HTMLElement.prototype.remove_class = function(c)
{
  var classes = this.className.split(/\s+/);
  var i = classes.indexOf(c);
  if (i >= 0) {
    classes.splice(i, 1);
    this.className = classes.join(" ");
    if (this.className === "") this.removeAttribute("class");
    return c;
  }
  return null;
};

// Set the class c on an element if p is true, otherwise remove the class c.
HTMLElement.prototype.set_class = function(c, p)
{
  this[p ? "add_class" : "remove_class"](c);
};

// Show or hide an element. If no argument is provided, toggle its visibility.
HTMLElement.prototype.show = function(p)
{
  if (p === undefined) p = this.has_class("hidden");
  this["{0}_class".fmt(p ? "remove" : "add")]("hidden");
};

// Remove the class c if the element has it, and
HTMLElement.prototype.toggle_class = function(c)
{
  this.set_class(c, !this.has_class(c));
};


// Get the pixel value of a CSS property
HTMLElement.prototype.pixel_value = function(prop)
{
  try
  {
    return getComputedStyle(this, "").getPropertyCSSValue(prop)
      .getFloatValue(CSSPrimitiveValue.CSS_PX);
  }
  catch(e)
  {
    return 0;
  }
}

// Simple event handling

// Add an event listener to object x
function add_event_listener(x, event_name, handler)
{
  if (!(event_name in x)) x[event_name] = [];
  x[event_name].push(handler);
}

function clear_event_listeners(x, event_name) { x[event_name] = []; }

function send_event(x, event_name, event_args)
{
  if (event_name in x) {
    x[event_name].forEach(function(y) { y(x, event_args); });
  }
}
