/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* 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";

const {Cc, Ci, Cu} = require("chrome");

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm");

const STACK_THICKNESS = 15;

/**
 * Module containing various helper functions used throughout Tilt.
 */
this.TiltUtils = {};
module.exports = this.TiltUtils;

/**
 * Various console/prompt output functions required by the engine.
 */
TiltUtils.Output = {

  /**
   * Logs a message to the console.
   *
   * @param {String} aMessage
   *                 the message to be logged
   */
  log: function TUO_log(aMessage)
  {
    if (this.suppressLogs) {
      return;
    }
    // get the console service
    let consoleService = Cc["@mozilla.org/consoleservice;1"]
      .getService(Ci.nsIConsoleService);

    // log the message
    consoleService.logStringMessage(aMessage);
  },

  /**
   * Logs an error to the console.
   *
   * @param {String} aMessage
   *                 the message to be logged
   * @param {Object} aProperties
   *                 and object containing script error initialization details
   */
  error: function TUO_error(aMessage, aProperties)
  {
    if (this.suppressErrors) {
      return;
    }
    // make sure the properties parameter is a valid object
    aProperties = aProperties || {};

    // get the console service
    let consoleService = Cc["@mozilla.org/consoleservice;1"]
      .getService(Ci.nsIConsoleService);

    // get the script error service
    let scriptError = Cc["@mozilla.org/scripterror;1"]
      .createInstance(Ci.nsIScriptError);

    // initialize a script error
    scriptError.init(aMessage,
      aProperties.sourceName || "",
      aProperties.sourceLine || "",
      aProperties.lineNumber || 0,
      aProperties.columnNumber || 0,
      aProperties.flags || 0,
      aProperties.category || "");

    // log the error
    consoleService.logMessage(scriptError);
  },

  /**
   * Shows a modal alert message popup.
   *
   * @param {String} aTitle
   *                 the title of the popup
   * @param {String} aMessage
   *                 the message to be logged
   */
  alert: function TUO_alert(aTitle, aMessage)
  {
    if (this.suppressAlerts) {
      return;
    }
    if (!aMessage) {
      aMessage = aTitle;
      aTitle = "";
    }

    // get the prompt service
    let prompt = Cc["@mozilla.org/embedcomp/prompt-service;1"]
      .getService(Ci.nsIPromptService);

    // show the alert message
    prompt.alert(null, aTitle, aMessage);
  }
};

/**
 * Helper functions for managing preferences.
 */
TiltUtils.Preferences = {

  /**
   * Gets a custom Tilt preference.
   * If the preference does not exist, undefined is returned. If it does exist,
   * but the type is not correctly specified, null is returned.
   *
   * @param {String} aPref
   *                 the preference name
   * @param {String} aType
   *                 either "boolean", "string" or "integer"
   *
   * @return {Boolean | String | Number} the requested preference
   */
  get: function TUP_get(aPref, aType)
  {
    if (!aPref || !aType) {
      return;
    }

    try {
      let prefs = this._branch;

      switch(aType) {
        case "boolean":
          return prefs.getBoolPref(aPref);
        case "string":
          return prefs.getCharPref(aPref);
        case "integer":
          return prefs.getIntPref(aPref);
      }
      return null;

    } catch(e) {
      // handle any unexpected exceptions
      TiltUtils.Output.error(e.message);
      return undefined;
    }
  },

  /**
   * Sets a custom Tilt preference.
   * If the preference already exists, it is overwritten.
   *
   * @param {String} aPref
   *                 the preference name
   * @param {String} aType
   *                 either "boolean", "string" or "integer"
   * @param {String} aValue
   *                 a new preference value
   *
   * @return {Boolean} true if the preference was set successfully
   */
  set: function TUP_set(aPref, aType, aValue)
  {
    if (!aPref || !aType || aValue === undefined || aValue === null) {
      return;
    }

    try {
      let prefs = this._branch;

      switch(aType) {
        case "boolean":
          return prefs.setBoolPref(aPref, aValue);
        case "string":
          return prefs.setCharPref(aPref, aValue);
        case "integer":
          return prefs.setIntPref(aPref, aValue);
      }
    } catch(e) {
      // handle any unexpected exceptions
      TiltUtils.Output.error(e.message);
    }
    return false;
  },

  /**
   * Creates a custom Tilt preference.
   * If the preference already exists, it is left unchanged.
   *
   * @param {String} aPref
   *                 the preference name
   * @param {String} aType
   *                 either "boolean", "string" or "integer"
   * @param {String} aValue
   *                 the initial preference value
   *
   * @return {Boolean} true if the preference was initialized successfully
   */
  create: function TUP_create(aPref, aType, aValue)
  {
    if (!aPref || !aType || aValue === undefined || aValue === null) {
      return;
    }

    try {
      let prefs = this._branch;

      if (!prefs.prefHasUserValue(aPref)) {
        switch(aType) {
          case "boolean":
            return prefs.setBoolPref(aPref, aValue);
          case "string":
            return prefs.setCharPref(aPref, aValue);
          case "integer":
            return prefs.setIntPref(aPref, aValue);
        }
      }
    } catch(e) {
      // handle any unexpected exceptions
      TiltUtils.Output.error(e.message);
    }
    return false;
  },

  /**
   * The preferences branch for this extension.
   */
  _branch: (function(aBranch) {
    return Cc["@mozilla.org/preferences-service;1"]
      .getService(Ci.nsIPrefService)
      .getBranch(aBranch);

  }("devtools.tilt."))
};

/**
 * Easy way to access the string bundle.
 */
TiltUtils.L10n = {

  /**
   * The string bundle element.
   */
  stringBundle: null,

  /**
   * Returns a string in the string bundle.
   * If the string bundle is not found, null is returned.
   *
   * @param {String} aName
   *                 the string name in the bundle
   *
   * @return {String} the equivalent string from the bundle
   */
  get: function TUL_get(aName)
  {
    // check to see if the parent string bundle document element is valid
    if (!this.stringBundle || !aName) {
      return null;
    }
    return this.stringBundle.GetStringFromName(aName);
  },

  /**
   * Returns a formatted string using the string bundle.
   * If the string bundle is not found, null is returned.
   *
   * @param {String} aName
   *                 the string name in the bundle
   * @param {Array} aArgs
   *                an array of arguments for the formatted string
   *
   * @return {String} the equivalent formatted string from the bundle
   */
  format: function TUL_format(aName, aArgs)
  {
    // check to see if the parent string bundle document element is valid
    if (!this.stringBundle || !aName || !aArgs) {
      return null;
    }
    return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length);
  }
};

/**
 * Utilities for accessing and manipulating a document.
 */
TiltUtils.DOM = {

  /**
   * Current parent node object used when creating canvas elements.
   */
  parentNode: null,

  /**
   * Helper method, allowing to easily create and manage a canvas element.
   * If the width and height params are falsy, they default to the parent node
   * client width and height.
   *
   * @param {Document} aParentNode
   *                   the parent node used to create the canvas
   *                   if not specified, it will be reused from the cache
   * @param {Object} aProperties
   *                 optional, object containing some of the following props:
   *       {Boolean} focusable
   *                 optional, true to make the canvas focusable
   *       {Boolean} append
   *                 optional, true to append the canvas to the parent node
   *        {Number} width
   *                 optional, specifies the width of the canvas
   *        {Number} height
   *                 optional, specifies the height of the canvas
   *        {String} id
   *                 optional, id for the created canvas element
   *
   * @return {HTMLCanvasElement} the newly created canvas element
   */
  initCanvas: function TUD_initCanvas(aParentNode, aProperties)
  {
    // check to see if the parent node element is valid
    if (!(aParentNode = aParentNode || this.parentNode)) {
      return null;
    }

    // make sure the properties parameter is a valid object
    aProperties = aProperties || {};

    // cache this parent node so that it can be reused
    this.parentNode = aParentNode;

    // create the canvas element
    let canvas = aParentNode.ownerDocument.
      createElementNS("http://www.w3.org/1999/xhtml", "canvas");

    let width = aProperties.width || aParentNode.clientWidth;
    let height = aProperties.height || aParentNode.clientHeight;
    let id = aProperties.id || null;

    canvas.setAttribute("style", "min-width: 1px; min-height: 1px;");
    canvas.setAttribute("width", width);
    canvas.setAttribute("height", height);
    canvas.setAttribute("id", id);

    // the canvas is unfocusable by default, we may require otherwise
    if (aProperties.focusable) {
      canvas.setAttribute("tabindex", "1");
      canvas.style.outline = "none";
    }

    // append the canvas element to the current parent node, if specified
    if (aProperties.append) {
      aParentNode.appendChild(canvas);
    }

    return canvas;
  },

  /**
   * Gets the full webpage dimensions (width and height).
   *
   * @param {Window} aContentWindow
   *                 the content window holding the document
   *
   * @return {Object} an object containing the width and height coords
   */
  getContentWindowDimensions: function TUD_getContentWindowDimensions(
    aContentWindow)
  {
    return {
      width: aContentWindow.innerWidth + aContentWindow.scrollMaxX,
      height: aContentWindow.innerHeight + aContentWindow.scrollMaxY
    };
  },

  /**
   * Calculates the position and depth to display a node, this can be overriden
   * to change the visualization.
   *
   * @param {Window} aContentWindow
   *                 the window content holding the document
   * @param {Node}   aNode
   *                 the node to get the position for
   * @param {Object} aParentPosition
   *                 the position of the parent node, as returned by this
   *                 function
   *
   * @return {Object} an object describing the node's position in 3D space
   *                  containing the following properties:
   *         {Number} top
   *                  distance along the x axis
   *         {Number} left
   *                  distance along the y axis
   *         {Number} depth
   *                  distance along the z axis
   *         {Number} width
   *                  width of the node
   *         {Number} height
   *                  height of the node
   *         {Number} thickness
   *                  thickness of the node
   */
  getNodePosition: function TUD_getNodePosition(aContentWindow, aNode,
                                                aParentPosition) {
    let lh = new LayoutHelpers(aContentWindow);
    // get the x, y, width and height coordinates of the node
    let coord = lh.getRect(aNode, aContentWindow);
    if (!coord) {
      return null;
    }

    coord.depth = aParentPosition ? (aParentPosition.depth + aParentPosition.thickness) : 0;
    coord.thickness = STACK_THICKNESS;

    return coord;
  },

  /**
   * Traverses a document object model & calculates useful info for each node.
   *
   * @param {Window} aContentWindow
   *                 the window content holding the document
   * @param {Object} aProperties
   *                 optional, an object containing the following properties:
   *        {Function} nodeCallback
   *                   a function to call instead of TiltUtils.DOM.getNodePosition
   *                   to get the position and depth to display nodes
   *        {Object} invisibleElements
   *                 elements which should be ignored
   *        {Number} minSize
   *                 the minimum dimensions needed for a node to be traversed
   *        {Number} maxX
   *                 the maximum left position of an element
   *        {Number} maxY
   *                 the maximum top position of an element
   *
   * @return {Array} list containing nodes positions and local names
   */
  traverse: function TUD_traverse(aContentWindow, aProperties)
  {
    // make sure the properties parameter is a valid object
    aProperties = aProperties || {};

    let aInvisibleElements = aProperties.invisibleElements || {};
    let aMinSize = aProperties.minSize || -1;
    let aMaxX = aProperties.maxX || Number.MAX_VALUE;
    let aMaxY = aProperties.maxY || Number.MAX_VALUE;

    let nodeCallback = aProperties.nodeCallback || this.getNodePosition.bind(this);

    let nodes = aContentWindow.document.childNodes;
    let store = { info: [], nodes: [] };
    let depth = 0;

    let queue = [
      { parentPosition: null, nodes: aContentWindow.document.childNodes }
    ]

    while (queue.length) {
      let { nodes, parentPosition } = queue.shift();

      for (let node of nodes) {
        // skip some nodes to avoid visualization meshes that are too bloated
        let name = node.localName;
        if (!name || aInvisibleElements[name]) {
          continue;
        }

        let coord = nodeCallback(aContentWindow, node, parentPosition);
        if (!coord) {
          continue;
        }

        // the maximum size slices the traversal where needed
        if (coord.left > aMaxX || coord.top > aMaxY) {
          continue;
        }

        // use this node only if it actually has visible dimensions
        if (coord.width > aMinSize && coord.height > aMinSize) {

          // save the necessary details into a list to be returned later
          store.info.push({ coord: coord, name: name });
          store.nodes.push(node);
        }

        let childNodes = (name === "iframe" || name === "frame") ? node.contentDocument.childNodes : node.childNodes;
        if (childNodes.length > 0)
          queue.push({ parentPosition: coord, nodes: childNodes });
      }
    }

    return store;
  }
};

/**
 * Binds a new owner object to the child functions.
 * If the new parent is not specified, it will default to the passed scope.
 *
 * @param {Object} aScope
 *                 the object from which all functions will be rebound
 * @param {String} aRegex
 *                 a regular expression to identify certain functions
 * @param {Object} aParent
 *                 the new parent for the object's functions
 */
TiltUtils.bindObjectFunc = function TU_bindObjectFunc(aScope, aRegex, aParent)
{
  if (!aScope) {
    return;
  }

  for (let i in aScope) {
    try {
      if ("function" === typeof aScope[i] && (aRegex ? i.match(aRegex) : 1)) {
        aScope[i] = aScope[i].bind(aParent || aScope);
      }
    } catch(e) {
      TiltUtils.Output.error(e);
    }
  }
};

/**
 * Destroys an object and deletes all members.
 *
 * @param {Object} aScope
 *                 the object from which all children will be destroyed
 */
TiltUtils.destroyObject = function TU_destroyObject(aScope)
{
  if (!aScope) {
    return;
  }

  // objects in Tilt usually use a function to handle internal destruction
  if ("function" === typeof aScope._finalize) {
    aScope._finalize();
  }
  for (let i in aScope) {
    if (aScope.hasOwnProperty(i)) {
      delete aScope[i];
    }
  }
};

/**
 * Retrieve the unique ID of a window object.
 *
 * @param {Window} aWindow
 *                 the window to get the ID from
 *
 * @return {Number} the window ID
 */
TiltUtils.getWindowId = function TU_getWindowId(aWindow)
{
  if (!aWindow) {
    return;
  }

  return aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                .getInterface(Ci.nsIDOMWindowUtils)
                .currentInnerWindowID;
};

/**
 * Sets the markup document viewer zoom for the currently selected browser.
 *
 * @param {Window} aChromeWindow
 *                 the top-level browser window
 *
 * @param {Number} the zoom ammount
 */
TiltUtils.setDocumentZoom = function TU_setDocumentZoom(aChromeWindow, aZoom) {
  aChromeWindow.gBrowser.selectedBrowser.markupDocumentViewer.fullZoom = aZoom;
};

/**
 * Performs a garbage collection.
 *
 * @param {Window} aChromeWindow
 *                 the top-level browser window
 */
TiltUtils.gc = function TU_gc(aChromeWindow)
{
  aChromeWindow.QueryInterface(Ci.nsIInterfaceRequestor)
               .getInterface(Ci.nsIDOMWindowUtils)
               .garbageCollect();
};

/**
 * Clears the cache and sets all the variables to null.
 */
TiltUtils.clearCache = function TU_clearCache()
{
  TiltUtils.DOM.parentNode = null;
};

// bind the owner object to the necessary functions
TiltUtils.bindObjectFunc(TiltUtils.Output);
TiltUtils.bindObjectFunc(TiltUtils.Preferences);
TiltUtils.bindObjectFunc(TiltUtils.L10n);
TiltUtils.bindObjectFunc(TiltUtils.DOM);

// set the necessary string bundle
XPCOMUtils.defineLazyGetter(TiltUtils.L10n, "stringBundle", function() {
  return Services.strings.createBundle(
    "chrome://browser/locale/devtools/tilt.properties");
});
