/*!
 * Backbone.GoogleMaps
 * A Backbone JS layer for the GoogleMaps API
 * Copyright (c)2012 Edan Schwartz
 * Distributed under MIT license
 * https://github.com/eschwartz/backbone.googlemaps
 */
(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD. Register as an anonymous module.
    define(['backbone', 'underscore', 'jquery'], factory);
  }
  else {
    // Browser globals
    factory(root.Backbone, root._, root.jQuery || root.Zepto || root.ender);
  }
}(this, function(Backbone, _, $) {

  'use strict';

  var GoogleMaps = {};

  /**
   * GoogleMaps.Location
   * --------------------
   * Representing a lat/lng location on a map
   */
  GoogleMaps.Location = Backbone.Model.extend({
    constructor: function() {
      _.bindAll(this, 'select', 'deselect', 'toggleSelect', 'getLatLng', 'getLatlng');

      this.defaults = _.extend({}, {
        lat: 0,
        lng: 0,
        selected: false,
        title: ""
      }, this.defaults);

      Backbone.Model.prototype.constructor.apply(this, arguments);

      // Trigger 'selected' and 'deselected' events
      this.on("change:selected", function(model, isSelected) {
        var topic = isSelected ? "selected" : "deselected";
        this.trigger(topic, this);
      }, this);
    },

    select: function() {
      this.set("selected", true);
    },

    deselect: function() {
      this.set("selected", false);
    },

    toggleSelect: function() {
      this.set("selected", !this.get("selected"));
    },

    getLatlng: function() {
      return this.getLatLng();
    },

    getLatLng: function() {
      return new google.maps.LatLng(this.get("lat"), this.get("lng"));
    }
  });

  /**
   * GoogleMaps.LocationCollection
   * ------------------------------
   * A collection of map locations
   */
  GoogleMaps.LocationCollection = Backbone.Collection.extend({
    constructor: function(opt_models, opt_options) {
      var options = _.defaults({}, opt_options, {
        model: GoogleMaps.Location
      });

      // Set default model
      options.model || (options.model = GoogleMaps.Location);

      Backbone.Collection.prototype.constructor.call(this, opt_models, options);

      // Deselect other models on model select
      // ie. Only a single model can be selected in a collection
      this.on("change:selected", function(selectedModel, isSelected) {
        if (isSelected) {
          this.each(function(model) {
            if (selectedModel.cid !== model.cid) {
              model.deselect();
            }
          });
        }
      }, this);
    }
  });


  /**
   * GoogleMaps.MapView
   * ------------------
   * Base maps overlay view from which all other overlay views extend
   */
  GoogleMaps.MapView = Backbone.View.extend({
    constructor: function(options) {
      _.bindAll(this, 'render', 'close');

      // Hash of Google Map events
      // Events will be attached to this.gOverlay (google map or overlay)
      // eg `zoom_changed': 'handleZoomChange'
      this.mapEvents = this.mapEvents || {};

      this.overlayOptions = this.overlayOptions || {};

      Backbone.View.prototype.constructor.apply(this, arguments);

      this.options = options;

      // Ensure map and API loaded
      if (!google || !google.maps) throw new Error("Google maps API is not loaded.");
      if (!this.options.map && !this.map) throw new Error("A map must be specified.");
      this.gOverlay = this.map = this.options.map || this.map;

      // Add overlayOptions from ctor options
      // to this.overlayOptions
      _.extend(this.overlayOptions, this.options.overlayOptions);
    },

    // Attach listeners to the this.gOverlay
    // From the `mapEvents` hash
    bindMapEvents: function(mapEvents, opt_context) {
      var context = opt_context || this;

      mapEvents || (mapEvents = this.mapEvents);

      _.each(mapEvents, function(handlerRef, topic) {
        var handler = this._getHandlerFromReference(handlerRef);
        this._addGoogleMapsListener(topic, handler, context);
      }, this);
    },

    // handlerRef can be a named method of the view (string)
    // or a refernce to any function.
    _getHandlerFromReference: function(handlerRef) {
      var handler = _.isString(handlerRef) ? this[handlerRef] : handlerRef;

      if (!_.isFunction(handler)) {
        throw new Error("Unable to bind map event. " + handlerRef +
          " is not a valid event handler method");
      }

      return handler;
    },

    _addGoogleMapsListener: function(topic, handler, opt_context) {
      if (opt_context) {
        handler = _.bind(handler, opt_context);
      }

      google.maps.event.addListener(this.gOverlay, topic, handler);
    },

    render: function() {
      this.trigger('before:render');
      if (this.beforeRender) {
        this.beforeRender();
      }
      this.bindMapEvents();

      this.trigger('render');
      if (this.onRender) {
        this.onRender();
      }

      return this;
    },

    // Clean up view
    // Remove overlay from map and remove event listeners
    close: function() {
      this.trigger('before:close');
      if (this.beforeClose) {
        this.beforeClose();
      }

      google.maps.event.clearInstanceListeners(this.gOverlay);
      if (this.gOverlay.setMap) {
        this.gOverlay.setMap(null);
      }
      this.gOverlay = null;

      this.trigger('close');
      if (this.onClose) {
        this.onClose();
      }
    }
  });

  /**
   * GoogleMaps.InfoWindow
   * ---------------------
   * View controller for a google.maps.InfoWindow overlay instance
   */
  GoogleMaps.InfoWindow = GoogleMaps.MapView.extend({
    constructor: function() {
      GoogleMaps.MapView.prototype.constructor.apply(this, arguments);

      _.bindAll(this, 'render', 'close');

      // Require a related marker instance
      if (!this.options.marker && !this.marker) throw new Error("A marker must be specified for InfoWindow view.");
      this.marker = this.options.marker || this.marker;

      // Set InfoWindow template
      this.template = this.template || this.options.template;

    },

    // Render
    render: function() {
      this.trigger('before:render');
      if (this.beforeRender) {
        this.beforeRender();
      }

      GoogleMaps.MapView.prototype.render.apply(this, arguments);

      // Render element
      var tmpl = function(model) {
        return _.template('<h2><%=title %></h2>')(model.toJSON());
      }

      if (this.template) {
        if (_.isFunction(this.template)) {
          tmpl = this.template;
        } else if (_.isString(this.template)) {
          tmpl = function(model) {
            _.template($(this.template).html())(model.toJSON());
          }
        }
      }
      this.$el.html(tmpl.call(this,this.model));

      // Create InfoWindow
      this.gOverlay = new google.maps.InfoWindow(_.extend({
        content: this.$el[0]
      }, this.overlayOptions));

      // Display InfoWindow on map
      this.gOverlay.open(this.map, this.marker);

      this.trigger('render');
      if (this.onRender) {
        this.onRender();
      }

      return this;
    },

    // Close and delete window, and clean up view
    close: function() {
      this.trigger('before:close');
      if (this.beforeClose) {
        this.beforeClose();
      }

      GoogleMaps.MapView.prototype.close.apply(this, arguments);

      this.trigger('close');
      if (this.onClose) {
        this.onClose();
      }

      return this;
    }
  });


  /**
   * GoogleMaps.MarkerView
   * ---------------------
   * View controller for a marker overlay
   */
  GoogleMaps.MarkerView = GoogleMaps.MapView.extend({
    constructor: function() {
      // Set associated InfoWindow view
      this.infoWindow = this.infoWindow || GoogleMaps.InfoWindow;

      GoogleMaps.MapView.prototype.constructor.apply(this, arguments);

      _.bindAll(this, 'render', 'close', 'openDetail', 'closeDetail', 'toggleSelect');

      // Ensure model
      if (!this.model) throw new Error("A model must be specified for a MarkerView");

      // Instantiate marker, with user defined properties
      this.gOverlay = new google.maps.Marker(_.extend({
        position: this.model.getLatLng(),
        map: this.map,
        title: this.model.title,
        animation: google.maps.Animation.DROP,
        visible: false										// hide, until render
      }, this.overlayOptions));

      // Add default mapEvents
      _.extend(this.mapEvents, {
        'click': 'toggleSelect'							// Select model on marker click
      });

      // Show detail view on model select
      this.model.on("change:selected", function(model, isSelected) {
        if (isSelected) {
          this.openDetail();
        }
        else {
          this.closeDetail();
        }
      }, this);
      this.model.on("change:lat change:lng", this.refreshOverlay, this);

      // Sync location model lat/lng with marker position
      this.bindMapEvents({
        'position_changed': 'updateModelPosition'
      });
    },

    // update overlay position if lat or lng change
    refreshOverlay: function() {
      // Only update overlay if we're not already in sync
      // Otherwise we end up in an endless loop of
      // update model <--eventhandler--> update overlay
      if (!this.model.getLatLng().equals(this.gOverlay.getPosition())) {
        this.gOverlay.setOptions({
          position: this.model.getLatLng()
        });
      }
    },

    updateModelPosition: function() {
      var newPosition = this.gOverlay.getPosition();

      // Only update model if we're not already in sync
      // Otherwise we end up in an endless loop of
      // update model <--eventhandler--> update overlay
      if (!this.model.getLatLng().equals(newPosition)) {
        this.model.set({
          lat: newPosition.lat(),
          lng: newPosition.lng()
        });
      }
    },

    toggleSelect: function() {
      this.model.toggleSelect();
    },

    // Show the google maps marker overlay
    render: function() {
      this.trigger('before:render');
      if (this.beforeRender) {
        this.beforeRender();
      }

      GoogleMaps.MapView.prototype.render.apply(this, arguments);
      this.gOverlay.setVisible(true);

      this.trigger('render');
      if (this.onRender) {
        this.onRender();
      }

      return this;
    },

    close: function() {
      this.trigger('before:close');
      if (this.beforeClose) {
        this.beforeClose();
      }

      this.closeDetail();
      GoogleMaps.MapView.prototype.close.apply(this, arguments);
      this.model.off();

      this.trigger('close');
      if (this.onClose) {
        this.onClose()
      }

      return this;
    },

    openDetail: function() {
      this.detailView = new this.infoWindow({
        model: this.model,
        map: this.map,
        marker: this.gOverlay
      });
      this.detailView.render();
    },

    closeDetail: function() {
      if (this.detailView) {
        this.detailView.close();
        this.detailView = null;
      }
    }
  });


  /**
   * GoogleMaps.MarkerCollectionView
   * -------------------------------
   * Collection of MarkerViews
   */
  GoogleMaps.MarkerCollectionView = Backbone.View.extend({
    constructor: function(options) {
      this.markerView = this.markerView || GoogleMaps.MarkerView;
      this.markerViewChildren = this.markerViewChildren || {};

      Backbone.View.prototype.constructor.apply(this, arguments);

      this.options = options;

      _.bindAll(this, 'render', 'closeChildren', 'closeChild', 'addChild', 'refresh', 'close');

      // Ensure map property
      if (!this.options.map && !this.map) throw new Error("A map must be specified on MarkerCollectionView instantiation");
      this.map || (this.map = this.options.map);

      // Bind to collection
      this.collection.on("reset", this.refresh, this);
      this.collection.on("add", this.addChild, this);
      this.collection.on("remove", this.closeChild, this);
    },

    // Render MarkerViews for all models in collection
    render: function(collection) {
      var collection = collection || this.collection;

      this.trigger('before:render');
      if (this.beforeRender) {
        this.beforeRender();
      }

      // Create marker views for each model
      collection.each(this.addChild);

      this.trigger('render');
      if (this.onRender) {
        this.onRender();
      }

      return this;
    },

    // Close all child MarkerViews
    closeChildren: function() {
      for (var cid in this.markerViewChildren) {
        this.closeChild(this.markerViewChildren[cid]);
      }
    },

    closeChild: function(child) {
      // Param can be child's model, or child view itself
      var childView = (child instanceof Backbone.Model) ? this.markerViewChildren[child.cid] : child;

      childView.close();
      delete this.markerViewChildren[childView.model.cid];
    },

    // Add a MarkerView and render
    addChild: function(childModel) {
      var markerView = new this.markerView({
        model: childModel,
        map: this.map
      });

      this.markerViewChildren[childModel.cid] = markerView;

      markerView.render();
    },

    refresh: function() {
      this.closeChildren();
      this.render();
    },

    // Close all child MarkerViews
    close: function() {
      this.closeChildren();
      this.collection.off();
    }
  });

  Backbone.GoogleMaps = GoogleMaps;
  return GoogleMaps;
}));