// Module pattern (function (global) {

"use strict";
// We define these two variables as null to cause an error when the
// global version of these variables is accessed. This is used to enforce
// the use of the jQueryObject parameter of the ballonizer constructor.
var jQuery = null;
var $ = null;

var Ballonizer = (function () {

    var htmlEscape = function (str) {
        return (str)
            .replace(/&/g, "&")
            .replace(/"/g, """)
            .replace(/'/g, "'")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;");
    };

    var objectIsEmpty = function (obj) {
        for (var p in obj) {
            if (obj.hasOwnProperty(p)) {
                return false;
            }
        }
        return true;
    };

    // Classes used by the module
    var Ballonizer;
    var BallonizedImageContainer;
    var InterfaceBallon;

     /**
        * @constructor Construct a Ballonizer object. This includes
        *  the BallonizedImageContainer objects for any images who match
        *  the ballonizerContainerSelector in the context. Add form for the
        *  submit of the ballonized image changes.
        * @param actionFormURL A string with the value of the action
        *  attribute of the ballonizer form.
        * @param ballonizerContainerSelector A css selector string who
        *  match the container wrapped around each image to be ballonized.
        * @param context A node or JQuery object. The Ballonizer will
        *  affect and see only inside of this node.
        * @param jQueryObject The '$' or 'jQuery' variables defined by jQuery
        *  library. The object will use this object instead the global one.
        *  The main utility of this is allow the use of a different jQuery
        *  version that the used in the rest of the page scripts.
        * @param config A hash with the config to be used in some
        *   situations:
        *   ballonInitialText: ballon text when the ballon is created (do
        *       not use a empty string or only spaces). Defaults
        *       to "double click to edit ballon text";
        *   submitButtonValue: text of the button who submits the changes
        *       in the ballons. Defaults to "Submit ballon changes";
        *   jQueryObject: a jQuery object to use instead of the global one,
        *       the main utility of this configuration is use multiple jQuery
        *       versions in the page.
        * @return Ballonizer object
        */
    Ballonizer = function (actionFormURL,
                            ballonizerContainerSelector,
                            context,
                            jQueryObject,
                            config) {

        // Implicit constructor pattern
        if (!(this instanceof Ballonizer)) {
            return new Ballonizer(actionFormURL,
                                    ballonizerContainerSelector,
                                    context,
                                    jQueryObject,
                                    config);
        }

        var defaultConfig = {
            ballonInitialText: "double click to edit ballon text",
            submitButtonValue: "Submit ballon changes"
        };

        if (jQueryObject) {
            this.jQuery = jQueryObject;
        } else {
            throw new Ballonizer.Error(
                "the parameter jQueryObject is null or undefined"
            );
        }
        var $ = this.jQuery;

        if (null == config) {
            this.config = defaultConfig;
        } else {
            this.config = $.extend({}, defaultConfig, config);
        }

        this.actionFormURL = actionFormURL;
        this.context = $(context);

        this.ballonizedImages = {};

        var imageContainers = $(ballonizerContainerSelector, this.context);
        imageContainers.each($.proxy(function (ix, element) {
            var container = $(element);
            var ballonizedContainer = new BallonizedImageContainer(
                this, container, jQueryObject
            );
            var imgSrc = $("img", container).prop("src");

            this.ballonizedImages[imgSrc] = ballonizedContainer;

        }, this));

        this.formNode = null;
        if (!objectIsEmpty(this.ballonizedImages)) {
            this.formNode = this.generateBallonizerFormNode();
            this.context.children().last().after(this.formNode);
        }

        return this;
    };

    Ballonizer.Error = function (message) {
        this.message = message;
    };

    Ballonizer.Error.prototype = new Error();

    // Instance methods
    Ballonizer.prototype.getBallonInitialText = function () {
        return this.config.ballonInitialText;
    };

    Ballonizer.prototype.getContext = function () {
        return this.context;
    };

    Ballonizer.prototype.getForm = function () {
        return this.formNode;
    };

    Ballonizer.prototype.getBallonizedImageContainers = function () {
        return this.ballonizedImages;
    };

    Ballonizer.prototype.generateBallonizerFormNode = function () {
        var $ = this.jQuery;
        var jQuery = this.jQuery;
        var form = $("<form class='ballonizer_page_form' method='post' >" +
                        "<div><input name='ballonizer_data' type='hidden'>" +
                        "</input><input name='ballonizer_submit'" +
                        " type='submit' value='" +
                        this.config.submitButtonValue +
                        "'></input></div></form>");

        form.attr("action", this.actionFormURL);

        // We find the greater z-index of a body direct child and
        // increment this value by one (this way the form will not 
        // be hiiden behind another element)
        var zIndexes = [];
        $("body > *").each(function (ix, el) {
            var zIndexCSS = $(el).css('z-index');
            // the inherit is made numeric by the jQuery, we are
            // filtering here only the "auto" string value
            if (jQuery.isNumeric(zIndexCSS)) {
                zIndexes.push(parseInt(zIndexCSS, 10));
            }
        });
        var zIndex = null;
        if (zIndexes.length > 0) {
            zIndex = Math.max.apply(null, zIndexes) + 1;
        } else {
            zIndex = 1;
        }
        $("input[name='ballonizer_submit']", form).css("z-index", zIndex);

        return form;
    };

    Ballonizer.prototype.notifyBallonChange = function () {
        var $ = this.jQuery;
        var jQuery = this.jQuery;
        var submitButton = $("input[type='submit']", this.formNode);
        var dataInput = $("input[type='hidden']", this.formNode);

        if (!submitButton.hasClass("ballonizer_ballons_have_changes")) {
            submitButton.addClass("ballonizer_ballons_have_changes");
        }

        dataInput.val(jQuery.toJSON(this.serialize()));

        return true;
    };

    Ballonizer.prototype.serialize = function () {
        var jQuery = this.jQuery;
        var serialization = {};

        jQuery.each(this.ballonizedImages, function (ix, el) {
            // makeArray remove non-numerical keys as img_src
            serialization[ix] = jQuery.makeArray(el.serialize());
        });

        return serialization;
    };

    BallonizedImageContainer = (function () {
        var BallonizedImageContainer = function (ballonizerInstance,
                                                    containerNode,
                                                    jQueryObject) {

            // Implicit constructor pattern
            if (!(this instanceof BallonizedImageContainer)) {
                return new BallonizedImageContainer(ballonizerInstance,
                                                        containerNode,
                                                        jQueryObject);
            }

            this.jQuery = jQueryObject;
            var $ = this.jQuery;

            // See notifyBallonChange for this flag explanation
            this.userAlreadyInteracted = false;

            this.ballonizerInstance = ballonizerInstance;
            this.containerNode = containerNode;
            this.ballons = [];

            // Insert the form for the ballons in edit mode
            this.form = $(
                "<form class='ballonizer_image_form' action='#'></form>"
            );
            this.formInnerContainer = $(
                "<div></div>"
            );
            this.form.prepend(this.formInnerContainer);
            // The form is prepended in the beggining of the ballonizer
            // context and not inside the ballonizer_image_container
            // because not every element that can wrap a image (as an
            // anchor for example) can wrap a form (or a block element).
            // See more in: http://www.w3.org/TR/REC-html40/struct/global.html#block-inline, http://skypoetsworld.blogspot.com.br/2008/10/dont-ever-put-block-inside-inline.html, http://stackoverflow.com/questions/1091739/html-div-in-link-problem, stackoverflow.com/questions/1827965/is-putting-a-div-inside-an-anchor-ever-correcta
            this.ballonizerInstance.getContext().prepend(this.form);

            var ballons = $(".ballonizer_ballon", this.containerNode);

            ballons.each($.proxy(function (ix, element) {
                this.ballons.push(new InterfaceBallon(
                    this, $(element), this.jQuery
                ));
            }, this));

            var img = $("img", this.containerNode);
            img.click($.proxy(function (event) {
                this.click(event);
            }, this));
            img.dblclick($.proxy(function (event) {
                this.dblclick(event);
            }, this));
        };

        BallonizedImageContainer.prototype.getFormInnerContainer = function () {
            return this.formInnerContainer;
        };

        BallonizedImageContainer.prototype.getContainerNode = function () {
            return this.containerNode;
        };

        BallonizedImageContainer.prototype.getBallonizerInstance = function () {
            return this.ballonizerInstance;
        };

        // Avoid to call this method in methods that update the ballons
        // data, call this method only in callbacks of events, after calling
        // the methods that update the ballons data. This is intended to
        // preserve the semantics of the ballon change be always from a user
        // interaction.
        BallonizedImageContainer.prototype.notifyBallonChange = function () {
            var jQuery = this.jQuery;
            // This flag is necessary to work around the load of the image.
            // The ready callback don't wait for images to load and the
            // load callback has several caveats with using images (see
            // http://api.jquery.com/ready/ and http://api.jquery.com/load-event/).
            // All the ballons are defined in percentages and probably won't
            // be visible until the image is loaded (or will be over the alt
            // text but is hard to think of an user edit a ballon in this
            // case). So when the user interact with a ballon it will
            // calculate and update the sizes in pixels of all ballons
            // (the attributes used for the serialize), assuming that the
            // user is already editing because the image is loaded.
            // This fix a bug where all the ballons except the modified
            // (who the bounds are recalculated) are serialized and
            // submitted with the incorrect values (but are displayed
            // corretly to the user before the submission).
            if (!this.userAlreadyInteracted) {
                jQuery.each(this.ballons, function (ix, ballon) {
                    ballon.updatePositionAndSize();
                });
                this.userAlreadyInteracted = true;
            }
            return this.ballonizerInstance.notifyBallonChange();
        };

        BallonizedImageContainer.prototype.removeBallonFromList = function (ballon) {
            var ix = this.jQuery.inArray(ballon, this.ballons);
            this.ballons.splice(ix, 1);

            this.notifyBallonChange();

            return this;
        };

        BallonizedImageContainer.prototype.getBallons = function () {
            return this.ballons;
        };

        BallonizedImageContainer.prototype.click = function (event) {
            // The container don't have an action to do when it's clicked,
            // but it have when are double-clicked, and a double-click
            // trigger a click event too. To avoid problems with webcomic
            // pages who are links to the next page we disable the default
            // efects of the click.
            event.preventDefault();
        };

        BallonizedImageContainer.prototype.dblclick = function (event) {
            event.preventDefault();
            event.stopImmediatePropagation();
            var offset = this.containerNode.offset();
            var ballonX = event.pageX - offset.left;
            var ballonY = event.pageY - offset.top;

            // Width and height are magic number choosen for no good reason
            var ballonWidth = 129, ballonHeight = 41;
            var ballon = new InterfaceBallon(this, ballonX, ballonY,
                    ballonWidth, ballonHeight,
                    this.ballonizerInstance.getBallonInitialText(),
                    this.jQuery);

            this.ballons.push(ballon);

            this.notifyBallonChange();
        };

        BallonizedImageContainer.prototype.serialize = function () {
            var $ = this.jQuery;
            var jQuery = this.jQuery;
            /* jshint camelcase: false */
            var serialization = {
                // The img_src is out of style (not in camel case) because
                // it will be submitted to the ruby, and i'm giving
                // preference to the ruby convention when in conflict
                img_src: $("img", this.containerNode).prop("src"),
                length: this.ballons.length
            };

            jQuery.each(this.ballons, function (ix, el) {
                serialization[ix] = el.serialize();
            });

            return serialization;
        };

        return BallonizedImageContainer;
    })();

    InterfaceBallon = (function () {
        var InterfaceBallon = function (imgContainer, xOrNode, yOrJQueryObject,
                width, height, initialText, jQueryObject) {

            // Implicit constructor pattern
            if (!(this instanceof InterfaceBallon)) {
                return new InterfaceBallon(imgContainer, xOrNode, yOrJQueryObject, width,
                        height, initialText, jQueryObject);
            }

            this.imgContainer = imgContainer;
            this.state = "initial";

            if (3 === arguments.length) {
                this.node = xOrNode;
                this.jQuery = yOrJQueryObject;
                this.updatePositionAndSize();
                this.fontSize = parseInt(this.node.css('font-size'), 10);
                this.text = this.node.text();
            } else {
                this.left = xOrNode;
                this.top = yOrJQueryObject;
                this.width = width;
                this.height = height;
                this.jQuery = jQueryObject;
                var containerNode = this.imgContainer.getContainerNode();

                // The ifs purpose are avoid the ballon of being created
                // partially outside of the image (after the creation the
                // jQueryUI handles this for us).
                if (this.left + this.width > containerNode.width()) {
                    if (containerNode.width() < this.width) {
                        this.left = 0;
                        this.width = containerNode.width();
                    } else {
                        this.left = containerNode.width() - this.width;
                    }
                }
                if (this.top + this.height > containerNode.height()) {
                    if (containerNode.height() < this.height) {
                        this.top = 0;
                        this.height = containerNode.height();
                    } else {
                        this.top = containerNode.height() - this.height;
                    }
                }

                this.text = initialText;
                this.node = this.generateBallonNode();
                this.imgContainer.getContainerNode().prepend(this.node);
                this.changeFontSizeToBestFitBallon();
            }

            var $ = this.jQuery;

            this.node.draggable({
                containment: "parent",
                opacity: 0.5,
                distance: 5,
                stop: $.proxy(function (event, ui) {
                    /* jshint unused: false */
                    this.updatePositionAndSize();
                    this.imgContainer.notifyBallonChange();
                }, this)
            });
            this.node.resizable({
                containment: "parent",
                opacity: 0.5,
                distance: 5,
                // Hides the handle when not hovering
                autoHide: true,
                stop: $.proxy(function (event, ui) {
                    /* jshint unused: false */
                    this.updatePositionAndSize();
                    this.changeFontSizeToBestFitBallon();
                    this.imgContainer.notifyBallonChange();
                }, this)
            });

            var imageForm = this.imgContainer.getFormInnerContainer();
            var editionBallon = $(
                // cols and rows are obrigatory attributes, the choosen
                // values are magic numbers without a good reason
                "<textarea cols='100' rows='40' " +
                "class='ballonizer_edition_ballon'></textarea>"
            ).val(this.text);

            this.editionNode = editionBallon;
            imageForm.prepend(this.editionNode);

            // Some webcomics allows the reader to navigate using the
            // arrow keys. This can be very annoying when editing a
            // balloon ("edition mode"/textarea) because you normally
            // use the arrows to edit the ballon you are writing. The
            // result is you losing all your work every time you make
            // the mistake of pressing an arrow key. This guarantee that
            // any other scripts on the page that trigger by key events
            // will not trigger while the user is typing a balloon text.
            this.editionNode.keydown($.proxy(function (event) {
                event.stopPropagation();
            }, this));
            this.editionNode.keyup($.proxy(function (event) {
                event.stopPropagation();
            }, this));
            this.editionNode.keypress($.proxy(function (event) {
                event.stopPropagation();
            }, this));

            this.node.click($.proxy(function (event) {
                this.click(event);
            }, this));
            this.editionNode.blur($.proxy(function (event) {
                this.blur(event);
            }, this));
            this.node.dblclick($.proxy(function (event) {
                this.dblclick(event);
            }, this));
        };

        InterfaceBallon.prototype.getBallonizedImageContainer = function () {
            return this.imgContainer;
        };

        InterfaceBallon.prototype.generateBallonNode = function () {
            var $ = this.jQuery;
            var nodeStyle = ["left: ", this.left, "px; ", "top: ",
                this.top, "px; ", "width: ", this.width, "px; ",
                "height: ", this.height, "px;"].join("");
            var node = $("<span class='ballonizer_ballon' ></span>");

            // The use of ".text" will escape '<', '>', and others
            node.text(this.text).attr("style", nodeStyle);

            return node;
        };

        InterfaceBallon.prototype.getText = function () {
            return this.text;
        };
        InterfaceBallon.prototype.getPositionAndSize = function () {
            return {
                left: this.left,
                top: this.top,
                width: this.width,
                height: this.height
            };
        };
        InterfaceBallon.prototype.changeFontSizeToBestFitBallon = function () {
            var oldHeight = this.node.css('height');
            var desiredCalcHeight = this.node.height();
            this.node.css('height', 'auto');
            var actualFontSize = 1;
            this.node.css('font-size', actualFontSize);
            while (this.node.height() < desiredCalcHeight) {
                this.node.css('font-size', ++actualFontSize + 'px');
            }
            this.node.css('font-size', --actualFontSize + 'px');
            this.node.css('height', oldHeight);
            this.fontSize = actualFontSize;
        };
        InterfaceBallon.prototype.updatePositionAndSize = function () {
            var newX = this.node.position().left;
            var newY = this.node.position().top;
            var newWidth = this.node.width();
            var newHeight = this.node.height();

            if (newX !== this.left || newY !== this.top ||
                    newWidth !== this.width || newHeight !== this.height) {

                this.left = newX;
                this.top = newY;
                this.width = newWidth;
                this.height = newHeight;
            }
        };
        InterfaceBallon.prototype.getState = function () {
            return this.state;
        };
        InterfaceBallon.prototype.getNode = function () {
            if (this.state.match(/edit/)) {
                return this.editionNode;
            } else {
                return this.node;
            }
        };
        InterfaceBallon.prototype.getNormalNode = function () {
            return this.node;
        };
        InterfaceBallon.prototype.getEditionNode = function () {
            return this.editionNode;
        };
        InterfaceBallon.prototype.blur = function () {
            /* jshint loopfunc: true */
            if ("edit" !== this.state) {
                throw new Ballonizer.Error(
                    "losing focus when not in the edit state"
                );
            }
            var oldText = this.text;
            this.text = this.editionNode.val();
            if ((/^\s*$/).test(this.text)) {
                this.node.remove();
                this.editionNode.remove();
                // implies notifyBallonChange
                this.imgContainer.removeBallonFromList(this);
                // throw a exception if any method is called
                for (var method in InterfaceBallon.prototype)
                {
                    if (InterfaceBallon.prototype.hasOwnProperty(method)) {
                        this[method] = function () {
                            throw new Ballonizer.Error(
                                "this ballon already have been destroyed" +
                                ", do not call any methods over it ('" +
                                method + "' was called)");
                        };
                    }
                }
            } else {
                this.state = "initial";
                // only change the text in the node, and not what
                // the resizable insert inside the element (the handles)
                this.node.contents().filter(function () {
                    return this.nodeType === 3;
                }).replaceWith(htmlEscape(this.text));
                this.node.removeClass("ballonizer_ballon_hidden_for_edition");
                this.editionNode.removeClass("ballonizer_ballon_in_edition");
                if (this.text !== oldText) {
                    this.changeFontSizeToBestFitBallon();
                    this.imgContainer.notifyBallonChange();
                }
            }
        };

        InterfaceBallon.prototype.dblclick = function () {
            var $ = this.jQuery;
            var jQuery = this.jQuery;
            this.state = this.state.replace("initial", "edit");

            var context = this.imgContainer.getBallonizerInstance().getContext();

            // The add not only add this node to the set but sort the set
            // (that is a array, not a set) in the order that the elements
            // appears in the document. This way the most outer parent is
            // the first element.
            // This behaviour is documented in: http://api.jquery.com/add/
            var els = this.node.parentsUntil(context).add(this.node);
            var zIndexes = [];
            els.each(function (ix, el) {
                var zIndexCSS = $(el).css('z-index');
                // the inherit is made numeric by the jQuery, we are
                // filtering here only the "auto" string value
                if (jQuery.isNumeric(zIndexCSS)) {
                    zIndexes.push(parseInt(zIndexCSS, 10));
                }
            });
            var zIndex = null;
            if (zIndexes.length > 0) {
                // The zIndex only need to be one greater than the
                // zIndex of the most outer parent with a defined zIndex.
                zIndex = zIndexes[0] + 1;
            } else {
                zIndex = 1;
            }

            // we subtract the offset of the imageform from the offset
            // of the normal ballon because the left and top of the edition
            // ballon is relative to this form (its the closest parent with
            // position: relative defined)
            var offset = this.node.offset();
            var imageFormOffset = this.imgContainer.getFormInnerContainer().offset();

            this.editionNode.css('top', offset.top - imageFormOffset.top);
            this.editionNode.css('left', offset.left - imageFormOffset.left);
            this.editionNode.css('width', this.node.css('width'));
            this.editionNode.css('height', this.node.css('height'));
            this.editionNode.css('z-index', zIndex);

            // only make the ballon hidden after getting the offset
            this.node.addClass("ballonizer_ballon_hidden_for_edition");
            this.editionNode.addClass("ballonizer_ballon_in_edition");
            // focus after is visible, otherwise will crash in IE
            // http://api.jquery.com/focus/#focus
            this.editionNode.focus();
        };

        // avoid following anchor when click over a ballon
        InterfaceBallon.prototype.click = function (event) {
            event.preventDefault();
        };

        InterfaceBallon.prototype.serialize = function () {
            var containerWidth = this.imgContainer.getContainerNode().width();
            var containerHeight = this.imgContainer.getContainerNode().height();

            return {
                left: this.left / containerWidth,
                top: this.top / containerHeight,
                width: this.width / containerWidth,
                height: this.height / containerHeight,
                text: this.text,
                /* jshint camelcase: false */
                // The fontSize is out of style (not in camel case) because
                // it will be submitted to the ruby, and i'm giving
                // preference to the ruby convention when in conflict
                font_size: this.fontSize
            };
        };

        return InterfaceBallon;
    })();

    return Ballonizer;
})();

global.Ballonizer = Ballonizer;

})(this);