/**
 * AutoQUEST - HTML Monitor
 * 
 * Description: This script records the interactions done by an user on an
 * HTML-Website and sends them to a server. It does not register actions on
 * Flash, Java, or other special inputs. This script is tested on Firefox
 * 15.0.1.
 * 
 * To insert it on your HTML-side, you need to write <script
 * language="JavaScript" type="text/javascript" src="autoquest-htmlmonitor.js"></script> in the
 * head and change the src-attribute to the location, you have chosen for this
 * script.
 * 
 * To change the recorded events, edit the action config array. If you want to change
 * the server where the data is send to, rewrite the destination variable. The
 * records are send to the server, JSON-formatted, if there are 10 inputs or the
 * user changes/closes the site.
 * 
 * Authors: Simon Leidenbach, Simon Reuss, Patrick Harms
 * 
 * Version: 0.1
 */

/**
 * the server to send the recorded data to
 */
var autoquestDestination;

/**
 * the maximum number of recorded events to be put into one package sent to the server
 */
var autoquestPackageSize = 10;

/**
 * this variable defines the tags for which event handling shall be added, as well as the
 * event handling action to be monitored
 */
var autoquestActionConfig = [
    { "tag": "body", "actions": [ "onunload", "onscroll" ] },
    { "tag": "a", "actions": [ "onclick", "ondblclick", "onfocus" ] },
    { "tag": "input", "actions": [ "onclick", "ondblclick", "onfocus" ] }
];

/**
 * a possibility to trace, what is going on
 */
var autoquestDoLog = false;

/*var matchedTags = ["A", "ABBR", "ACRONYM", "ADDRESS", "AREA", "B", "BIG", "BLOCKQUOTE", "BODY",
                   "BUTTON", "CAPTION", "CENTER", "CITE", "CODE", "COL", "COLGROUP", "DD", "DEL",
                   "DFN", "DIR", "DIV", "DL", "DT", "EM", "FIELDSET", "FORM", "H1", "H2", "H3",
                   "H4", "H5", "H6", "HR", "I", "IMG", "INPUT", "INS", "KBD", "LABEL", "LEGEND",
                   "LI", "LINK", "MAP", "MENU", "NOFRAMES", "NOSCRIPT", "OBJECT", "OL",
                   "OPTGROUP", "OPTION", "P", "PRE", "Q", "S", "SAMP", "SELECT", "SMALL", "SPAN",
                   "STRIKE", "STRONG", "SUB", "SUP", "TABLE", "TBODY", "TD", "TEXTAREA", "TFOOT",
                   "TH", "THEAD", "TR", "TT", "U", "UL", "VAR"];*/
/*var actions = ['onclick', 'ondblclick', 'onkeypress', 'onkeydown', 'onkeyup',
'onmouseout' , 'onmousemove' ,'onfocus','onscroll'];  // edit*/

/**
 * stores events, which were recorded but not sent to the server yet
 */
var autoquestRecordedEvents = [];

/**
 * automatically executed to initialize the event handling
 */
(function() {
    initEventHandling();
}());


/**
 * initializes the event handling after the document is loaded completely
 */
function initEventHandling() {
    if (document.body) {
        log("adding event handling attributes");
        determineDestination();
        addEventHandlingAttributes(document.documentElement, "");
        
        if (document.readyState !== "complete") {
            // if the document is not loaded yet, try to add further event handling later
            setTimeout(initEventHandling, 200);
        }
    }
    else {
        setTimeout(initEventHandling, 200);
    }         
}

/**
 * traverses the DOM-structure of the HTML-site and determines the URL of this script. Based on
 * this URL, it calculates the destination to which the traced interactions must be sent
 */
function determineDestination() {
    var scriptElements = document.getElementsByTagName("script");
    var i;
    var index;
    
    for (i = 0; i < scriptElements.length; i++) {
        if ((scriptElements[i].type === "text/javascript") && (scriptElements[i].src)) {
            index = scriptElements[i].src.lastIndexOf("script/autoquest-htmlmonitor.js");
            if (index > -1) {
                autoquestDestination = scriptElements[i].src.substring(0, index - 1);
                log("using destination " + autoquestDestination);
            }
        }
    }
}

/**
 * traverses the DOM-structure of the HTML-site and adds event handling attributes to each
 * relevant node
 * 
 * @param node       the node of the DOM structure that shall be adapted and whose children shall
 *                   be traversed
 * @param parentPath the path to the parent node of the provided node within the DOM-structure of
 *                   the HTML-site
 */
function addEventHandlingAttributes(node, parentPath) {
    var nodePath = getNodePath(node, parentPath);
    var i;
    var k;
    var value;
    
    if (node.nodeType === Node.ELEMENT_NODE) {
        for (i = 0; i < autoquestActionConfig.length; i++) {
            if (node.tagName.toLowerCase() === autoquestActionConfig[i].tag.toLowerCase()) {
                for (k = 0; k < autoquestActionConfig[i].actions.length; k++) {
                    value = "handleEvent('" + autoquestActionConfig[i].actions[k] + "', '" +
                        nodePath + "', event);";

                    if (!node.getAttribute(autoquestActionConfig[i].actions[k])) {
                        node.setAttribute(autoquestActionConfig[i].actions[k], value);
                    }
                    else {
                        var oldValue = node.getAttribute(autoquestActionConfig[i].actions[k]);
                        if (oldValue.indexOf(value) < 0) {
                            node.setAttribute(autoquestActionConfig[i].actions[k],
                                              value + ' ' + oldValue);
                        }
                    }
                }
            }
        }
    }
    
    for (i = 0; i < node.childNodes.length; i++) {
        addEventHandlingAttributes(node.childNodes[i], nodePath);
    }
}

/**
 * generates a path through the DOM-structure of the HTML-site depending on a node and the path
 * to its parent node. The result is the parent path plus a path element for the provided node.
 * If the node has a tag name, this is the first part of the path element generated for the node.
 * If the node has an id, it becomes the second part of the path element. If the node does not have
 * an id, the method calculates the index of the node within all children of the same type within
 * the parent node. This index becomes then the second part of the path element generated for the
 * node. 
 * 
 * @param node       the node of the DOM structure for which the path shall be created
 * @param parentPath the path to the parent node of the provided node
 * 
 * @returns a path in the DOM-structure of the HTML-site including the parent path an a path
 *          element for the provided node
 */
function getNodePath(node, parentPath) {
    var nodePath = parentPath + "/";
    var index = -1;
    var i = 0;
    
    if (node.nodeType === Node.ELEMENT_NODE) {
        nodePath += node.tagName.toLowerCase();
    }
    else {
        nodePath += "undefined";
    }
    
    if ((node.id) && (node.id !== "")) {
        nodePath += "(id=" + node.id + ")";
    }
    else {
        
        if (node.parentNode) {
            for (i = 0; i < node.parentNode.childNodes.length; i++) {
                if (node.parentNode.childNodes[i].tagName === node.tagName) {
                    index++;
                    // if === also returns true if the nodes are not identical but only equal,
                    // this may fail.
                    if (node.parentNode.childNodes[i] === node) {
                        break;
                    }
                }
            }
            
        }
        else {
            index = 0;
        }
        
        nodePath += "[" + index + "]";
    }
    
    return nodePath;
}

/**
 * handles an event that happened on a node. This method is called by the event handling attributes
 * of the nodes. These attributes are generated by the
 * {@link #addEventHandlingAttributes(node,parentPath)} function. It creates a new Event object and
 * add it to the list of <code>autoquestRecordedEvents</code>. If this list achieves the maximum
 * <code>autoquestPackageSize</code> the events in the list are sent to the server asynchronously
 * through calling {@link #sendRequest()}.
 * 
 * @param eventName the name of the event, e.g. onscroll
 * @param nodePath  the path to the node in the HTML DOM on which the event occurred
 * @param event     the HTML event that occured
 */
function handleEvent(eventName, nodePath, event) {
    log("handling event " + eventName);
    
    if (!autoquestDestination) {
        // do nothing if we have no destination to send data to
        return;
    }
    
    var eventType = eventName.toLowerCase();
    
    var eventObj = autoquestRecordedEvents.pop();
    
    // reuse previous on scroll events to prevent too many events
    if ((eventName !== "onscroll") || (!eventObj) || (eventObj.type !== "onscroll")) {
        if (eventObj) {
            autoquestRecordedEvents.push(eventObj);
        }
        eventObj = new Event(eventType, nodePath);
    }
    
    if ((eventType === "onclick") || (eventType === "ondblclick")) {
        eventObj.setClickCoordinates(getEventCoordinates(event));
    }

    if ((eventType === "onkeypress") || (eventType === "onkeydown") || (eventType === "onkeyup")) {
        eventObj.setKey(event.keyCode);
    }
    
    if (eventType === "onscroll") {
        if (window.pageYOffset) {
            eventObj.setScrollPosition(window.pageYOffset);
        }
    }

    log("storing event " + eventName);
    autoquestRecordedEvents.push(eventObj);

    if (autoquestRecordedEvents.length >= autoquestPackageSize) {
        log("initiating sending events");
        setTimeout(sendRequest, 100);
    }
    else if (eventType === "onunload") {
        log("initiating sending events");
        sendRequest();
    }

}

/**
 * sends the collected data to the server, named in the destination-variable. For this it generates
 * a JSON formatted message and uses Ajax and the <code>autoquestDestination</code> to send it to
 * the server
 */
function sendRequest() {
    var eventList;
    var message;
    var clientId;
    var i = 0;
    var request;
    
    if (autoquestRecordedEvents.length > 0) {
        log("creating message");
        eventList = autoquestRecordedEvents;
        autoquestRecordedEvents = [];
        
        message = "{\"message\":{\"clientInfos\":{";
        
        log("reading client id");
        clientId = readClientId();
        if ((clientId) && (clientId !== "")) {
            message += "\"clientId\":\"" + clientId + "\",";
        }
        
        log("adding other infos");
        message += "\"userAgent\":\"" + navigator.userAgent + "\",";
        message += "\"title\":\"" + document.title + "\",";
        message += "\"url\":\"" + document.URL + "\"},";
        
        
        message += "\"events\":[";
        
        for (i = 0; i < eventList.length; i++) {
            if (i > 0) {
                message += ",";
            }
            message += eventList[i].toJSON();
        }
        
        message += "]}}";
        
        request = null;
        
        // Mozilla
        if (window.XMLHttpRequest) {
            request = new XMLHttpRequest();
        }
        // IE
        else if (window.ActiveXObject) {
            request = new ActiveXObject("Microsoft.XMLHTTP");
        }
        
        log("sending message");
        request.open("POST", autoquestDestination, true);

        log("sending " + message);
        request.send(message);
    }
}

/**
 * determines the coordinates of an onclick or ondblclick event. If the coordinates to not come
 * with the provided event, they are determined based on the surrounding object
 * 
 * @param event the event to extract the coordinates of
 * 
 * @returns the coordinates of the event as an array with x and y coordinate
 */
function getEventCoordinates(event) {
    if (event.layerX) {
        return [event.layerX, event.layerY];
    }
    else if (event.offsetX) {
        return [event.offsetX, event.offsetY];
    }

    var obj = event.target || event.srcElement;
    var objOffset = getPageOffset(obj);

    return [(event.clientX - objOffset[0]), (event.clientY - objOffset[1])];
}

/**
 * determines the page offset of an object using the parent objects offset
 */
function getPageOffset(object) {
    var top = 0;
    var left = 0;
    var obj = object;

    while (obj.offsetParent) {
        left += obj.offsetLeft;
        top += obj.offsetTop;
        obj = obj.offsetParent;
    }

    return [left, top];
}

/**
 * reads the id of the client from the cookies.
 * 
 * @returns the client id or null, if none is found in the cookies
 */
function readClientId() {
    var cookie = document.cookie;
    
    var expectedCookieName = getClientIdCookieName();
    
    var cookiename = null;
    var startIndex = 0;
    
    var clientId = null;

    do {
        cookie = cookie.substring(startIndex, cookie.length);
        cookiename = cookie.substr(0, cookie.search('='));
        startIndex = cookie.search(';') + 1;
        
        while (cookie.charAt(startIndex) === ' ') {
            startIndex++;
        }
    }
    while ((startIndex > 0) && (cookiename !== expectedCookieName));
    
    if (cookiename === expectedCookieName) {
        clientId = cookie.substr(cookie.search('=') + 1, cookie.search(';'));
        if (clientId === "") {
            clientId = cookie.substr(cookie.search('=') + 1, cookie.length);
        }
    }
    
    if ((!clientId) || (clientId === "") || (clientId.search(getClientIdPrefix()) !== 0)) {
        clientId = generateClientId();
        storeClientId(clientId);
    }
    
    return clientId;
}


/**
 * stores the provided client id in the cookies
 * 
 * @param clientId the client id to be stored
 */
function storeClientId(clientId) {
    if ((clientId) && (clientId !== "")) {
        var expiry = new Date();
        // 10 years should be sufficient :-)
        expiry = new Date(expiry.getTime() + 1000*60*60*24*365*10);
        document.cookie = getClientIdCookieName() + '=' + clientId +
            '; expires=' + expiry.toGMTString()+';';
    }
}

/**
 * returns the name of the cookie used to store the client id
 * 
 * @returns as described
 */
function getClientIdCookieName() {
    return document.URL + "/quest-htmlmonitor/quest-client-id";
}

/**
 * generates a client id based on the result of {@link #getClientIdPrefix()} and the current time
 * stamp
 * 
 * @returns the client id
 */
function generateClientId() {
    return getClientIdPrefix() + new Date().getTime();
}

/**
 * generates a client id prefix based on the user agent and the navigators platform. The prefix
 * is a simple checksum of the concatenation of both strings
 * 
 * @returns the client id prefix
 */
function getClientIdPrefix() {
    // create something like a more or less unique checksum. It is sufficient, if it differs
    // only often, but not always, because it is concatenated with a time stamp, which differs
    // much more often.
    var prefixStr = navigator.userAgent + "_" + navigator.platform;
    var prefixId = prefixStr.length;
    var i = 0;
    
    for (i = 0; i < prefixStr.length; i++) {
        prefixId += prefixStr.charCodeAt(i);
    }
    
    // ensure, that a string is created and not a long. Otherwise, it can not be checked, if an
    // existing client id starts with the client id prefix and can therefore be reused.
    return prefixId.toString();
}

/**
 * performs a simple logging by adding a specific div to the HTML
 * 
 * @param text the text to be logged
 */
function log(text) {
    if (autoquestDoLog) {
        var loggingInfo = document.getElementById("loggingInfoParagraph");
        
        if (!loggingInfo) {
            var div = document.createElement("div");
            div.setAttribute("style", "z-index:1000000; background-color:#afa; " +
                             "border:1px black solid; position:absolute; " +
                             "top:10px; right:10px; width:350px; height:auto");
            
            loggingInfo = document.createElement("div");
            loggingInfo.id = "loggingInfoParagraph";
            div.appendChild(loggingInfo);
            
            var body = document.getElementsByTagName("body")[0];
            if (!body) {
                alert("could not enable logging");
            }
            else {
                body.appendChild(div);
            }
        }
        
        loggingInfo.appendChild(document.createTextNode(text));
        loggingInfo.appendChild(document.createElement("br"));
    }
}

/**
 * this class represents a single event
 * 
 * @param type     the type of the event
 * @param nodePath the path through the HTML DOM to the event target
 */
function Event(type, nodePath) {
    this.type = type;
    this.nodePath = nodePath;
    this.time = new Date().getTime();
    
    this.setClickCoordinates = function(coordinates) {
          this.clickCoordinates = coordinates;
    };
    
    this.setKey = function(key) {
          this.key = key;
    };
    
    this.setScrollPosition = function(scrollPosition) {
          this.scrollPosition = scrollPosition;
    };
    
    this.toJSON = function() {
        var eventInJSON =
            "{\"path\":\"" + this.nodePath + "\",\"time\":" + this.time + ",\"eventType\":\"" +
            this.type + "\"";

        if ((this.clickCoordinates) && (!isNaN(this.clickCoordinates[0]))) {
            eventInJSON += ",\"coordinates\":[" + this.clickCoordinates + "]";
        }

        if (this.key) {
            eventInJSON += ",\"key\":" + this.key;
        }
        
        if (this.scrollPosition) {
            eventInJSON += ",\"scrollPosition\":" + this.scrollPosition;
        }

        eventInJSON += "}";
        
        return eventInJSON;
    };
} 

