"use strict";

var Promise = require("bluebird");

var logger = require('../logger');
var functions = require('../Functions');

var AliasInfoWindow = require('./window/AliasInfoWindow');
var AliasSurface = require('./surface/AliasSurface');
var GuiConnector = require('../GuiConnector');
var IdentifiedElement = require('./data/IdentifiedElement');
var ObjectWithListeners = require('../ObjectWithListeners');
var PointInfoWindow = require('./window/PointInfoWindow');
var ReactionInfoWindow = require('./window/ReactionInfoWindow');
var ReactionSurface = require('./surface/ReactionSurface');

var MarkerSurfaceCollection = require('./marker/MarkerSurfaceCollection');

/**
 * Default constructor.
 */
function AbstractCustomMap(model, options) {
  // call super constructor
  ObjectWithListeners.call(this);

  if (model === undefined) {
    throw Error("Model must be defined");
  }
  this.registerListenerType("onZoomChanged");

  this.setElement(options.getElement());
  this.setConfiguration(options.getConfiguration());
  this.setProject(options.getProject());

  this.setModel(model);

  // this array contains elements that are presented on a specific layout (set
  // of google map object representing lines/areas that are associated with
  // layout)
  this.selectedLayoutOverlays = [];

  // following fields are used in conversion between x,y coordinates and lat,lng
  // coordinates
  this.pixelOrigin_ = new google.maps.Point(this.getTileSize() / 2, this.getTileSize() / 2);
  this.pixelsPerLonDegree_ = this.getTileSize() / 360;
  this.pixelsPerLonRadian_ = this.getTileSize() / (2 * Math.PI);

  /* jshint bitwise: false */
  this.zoomFactor = this.getPictureSize() / (this.getTileSize() / (1 << this.getMinZoom()));

  // array with info windows for Marker pointing to aliases
  this._aliasInfoWindow = [];

  // array with info windows for Marker pointing to points
  this._pointInfoWindow = [];

  // array with info windows for reactions
  this._reactionInfoWindow = [];

  this._markerSurfaceCollection = new MarkerSurfaceCollection({map: this});

  // this is google.maps.drawing.DrawingManager that will allow user to draw
  // elements in the client
  this._drawingManager = null;

  // this is the polygon that was selected (clicked) last time on the map
  this._selectedArea = null;

  // markers should be optimized by default,
  // however, for testing purpose this function could be turned of by javascript
  // the other possibility is that it should be off in the touch mode
  // (bigButtons=true)
  this._markerOptimization = options.isMarkerOptimization();

  this._bigLogo = options.isBigLogo();
  this._customTouchInterface = options.isCustomTouchInterface();

  this.setDebug(options.isDebug());
}

// define super constructor
AbstractCustomMap.prototype = Object.create(ObjectWithListeners.prototype);
AbstractCustomMap.prototype.constructor = AbstractCustomMap;


AbstractCustomMap.prototype.getMarkerSurfaceCollection = function () {
  return this._markerSurfaceCollection;
};
/**
 * Assigns layouts with images to the google map (which set of images should be
 * used by google maps api for which layout).
 *
 */
AbstractCustomMap.prototype.setupLayouts = function () {
  var overlays = this.getProject().getDataOverlays();
  for (var i = 0; i < overlays.length; i++) {
    var overlay = overlays[i];
    var typeOptions = this.createTypeOptions(overlay);
    var mapType = new google.maps.ImageMapType(typeOptions);
    this.getGoogleMap().mapTypes.set(overlay.getId().toString(), mapType);
  }
  this.getGoogleMap().setMapTypeId(overlays[0].getId().toString());
};

/**
 * Creates general google maps options used in this map.
 *
 */
AbstractCustomMap.prototype.createMapOptions = function () {
  var self = this;
  var model = self.getModel();

  var centerPoint = this.getModel().getCenterLatLng();

  var zoom = ServerConnector.getSessionData(self.getProject()).getZoomLevel(model);
  if (zoom === undefined) {
    zoom = this.getMinZoom();
  }

  // if we have coordinate data stored in session then restore it
  var point = ServerConnector.getSessionData(self.getProject()).getCenter(model);
  if (point !== undefined) {
    centerPoint = self.fromPointToLatLng(point);
    // if we have default coordinates defined for model
  } else if (model.getDefaultCenterX() !== undefined &&
    model.getDefaultCenterY() !== undefined &&
    model.getDefaultZoomLevel() !== undefined &&
    model.getDefaultCenterX() !== null &&
    model.getDefaultCenterY() !== null &&
    model.getDefaultZoomLevel() !== null) {

    centerPoint = self.fromPointToLatLng(new google.maps.Point(model.getDefaultCenterX(), model.getDefaultCenterY()));
    zoom = model.getDefaultZoomLevel();
  }

  return {
    center: centerPoint,
    rotateControl: true,
    panControl: true,
    mapTypeControl: false,
    zoom: zoom,
    streetViewControl: false,
    fullscreenControl: false,

    panControlOptions: {
      position: google.maps.ControlPosition.LEFT_TOP
    },
    zoomControlOptions: {
      style: google.maps.ZoomControlStyle.LARGE,
      position: google.maps.ControlPosition.LEFT_TOP
    }

  };
};

/**
 * Create google maps configuration options object for a specific layout.
 *
 * @param param
 *          object with information about layout
 */
AbstractCustomMap.prototype.createTypeOptions = function (param) {
  var self = this;
  return {
    // this is a function that will retrieve valid url to png images for
    // tiles on different zoom levels
    getTileUrl: function (coord, zoom) {
      // we have 1 tile on self.getConfiguration().MIN_ZOOM and
      // therefore must limit tails according to this
      /* jshint bitwise: false */
      var maxTileRange = 1 << (zoom - self.getMinZoom());
      var maxTileXRange = maxTileRange;
      var maxTileYRange = maxTileRange;

      var width = self.getModel().getWidth();
      var height = self.getModel().getHeight();
      if (width > height) {
        maxTileYRange = height / width * maxTileRange;
      } else if (width < height) {
        maxTileXRange = width / height * maxTileRange;
      }
      if (coord.y < 0 || coord.y >= maxTileYRange || coord.x < 0 || coord.x >= maxTileXRange) {
        return null;
      }
      return "../map_images/" + self.getProject().getDirectory() + "/" + param.getImagesDirectory(self.getId()) + "/" + zoom + "/" + coord.x + "/" + coord.y + ".PNG";
    },
    tileSize: new google.maps.Size(self.getTileSize(), self.getTileSize()),
    maxZoom: self.getMaxZoom(),
    minZoom: self.getMinZoom(),
    radius: 360,
    name: param.name
  };
};

/**
 * Sets maximum zoom level on google map.
 *
 */
AbstractCustomMap.prototype.setMaxZoomLevel = function () {
  this.getGoogleMap().setZoom(this.getMaxZoom());
};

/**
 * Returns mouse coordinate on the map in lat,lng system.
 *
 */
AbstractCustomMap.prototype.getMouseLatLng = function () {
  // this method is tricky, the main problem is how to map mouse coordinate to
  // google map
  // to do that we need a point of reference in both systems (x,y and lat,lng)
  // this will be center of the map that is currently visible
  // next, we will have to find distance from this point in x,y coordinates and
  // transform it to lat,lng

  var self = this;
  // center point visible on the map
  var latLngCoordinates = self.getGoogleMap().getCenter();
  var point = self.fromLatLngToPoint(latLngCoordinates);

  // this is magic :)
  // find offset of the div where google map is located related to top left
  // corner of the browser
  var el = self.getGoogleMap().getDiv();
  for (var lx = 0, ly = 0; el !== null && el !== undefined; lx += el.offsetLeft, ly += el.offsetTop, el = el.offsetParent) {
  }

  var offset = {
    x: lx,
    y: ly
  };

  var center = {
    x: self.getGoogleMap().getDiv().offsetWidth / 2,
    y: self.getGoogleMap().getDiv().offsetHeight / 2
  };

  // and now find how far from center point we are (in pixels)
  var relativeDist = {
    x: (GuiConnector.xPos - offset.x - center.x),
    y: (GuiConnector.yPos - offset.y - center.y)
  };

  // transform pixels into x,y distance
  var pointDist = self.fromPixelsToPoint(relativeDist, self.getGoogleMap().getZoom());

  // now we have offset in x,y and center point on the map in x,y, so we have
  // final position in x,y
  var newCoordinates = new google.maps.Point(point.x + pointDist.x, point.y + pointDist.y);

  // change it to lat,lng
  var latLngResult = self.fromPointToLatLng(newCoordinates);

  return latLngResult;
};

/**
 * Transform distance (coordinates) in pixels into x,y distance on the map.
 *
 * @param pixels
 *          x,y distance in pixels
 * @param zoomLevel
 *          at which zoom level this pixels where measured
 *
 */
AbstractCustomMap.prototype.fromPixelsToPoint = function (pixels, zoomLevel) {
  var zoomScale = this.getPictureSize() / (Math.pow(2, zoomLevel - this.getMinZoom()) * this.getTileSize());
  var pointX = pixels.x * zoomScale;
  var pointY = pixels.y * zoomScale;
  return new google.maps.Point(pointX, pointY);
};

/**
 * Transforms coordinates on the map from google.maps.LatLng to
 * google.maps.Point
 *
 * @param latLng
 *          in lat,lng format
 * @param coordinates in x,y format
 *
 */
AbstractCustomMap.prototype.fromLatLngToPoint = function (latLng) {
  var me = this;
  var point = new google.maps.Point(0, 0);
  var origin = me.pixelOrigin_;

  point.x = origin.x + latLng.lng() * me.pixelsPerLonDegree_;

  // Truncating to 0.9999 effectively limits latitude to 89.189. This is
  // about a third of a tile past the edge of the world tile.
  var siny = functions.bound(Math.sin(functions.degreesToRadians(latLng.lat())), -0.9999, 0.9999);
  point.y = origin.y + 0.5 * Math.log((1 + siny) / (1 - siny)) * -me.pixelsPerLonRadian_;

  // rescale the point (all computations are done assuming that we work on
  // TILE_SIZE square)
  point.x *= me.zoomFactor;
  point.y *= me.zoomFactor;
  return point;
};

/**
 * Transforms coordinates on the map from google.maps.Point to
 * google.maps.LatLng
 *
 * @param point
 *          coordinates in standard x,y format
 * @return coordinates in lat,lng format
 */
AbstractCustomMap.prototype.fromPointToLatLng = function (point) {
  var me = this;

  // rescale the point (all computations are done assuming that we work on
  // TILE_SIZE square)
  var p = new google.maps.Point(point.x / me.zoomFactor, point.y / me.zoomFactor);
  var origin = me.pixelOrigin_;
  var lng = (p.x - origin.x) / me.pixelsPerLonDegree_;
  var latRadians = (p.y - origin.y) / -me.pixelsPerLonRadian_;
  var lat = functions.radiansToDegrees(2 * Math.atan(Math.exp(latRadians)) - Math.PI / 2);
  return new google.maps.LatLng(lat, lng);
};

/**
 * Transforms google.maps.LatLng to tile coordinate (for instance on which tile
 * mouse clicked).
 *
 *
 * @param latLng
 *          coordinates in latlng format
 * @param z
 *          zoom level at which we want to find coordinates of tile
 * @return coordinates of a tile
 */
AbstractCustomMap.prototype.latLngToTile = function (latLng, z) {
  var worldCoordinate = this.fromLatLngToPoint(latLng);
  var pixelCoordinate = new google.maps.Point(worldCoordinate.x * Math.pow(2, z), worldCoordinate.y * Math.pow(2, z));
  var tileCoordinate = new google.maps.Point(Math.floor(pixelCoordinate.x / this.getTileSize()), Math
    .floor(pixelCoordinate.y / this.getTileSize()));
  return tileCoordinate;
};

/**
 * Register events responsible for click events
 */
AbstractCustomMap.prototype.registerMapClickEvents = function () {

  // find top map (CustomMap)
  //
  var customMap = this.getTopMap();

  var self = this;

  // search event
  google.maps.event.addListener(this.getGoogleMap(), 'click', function (mouseEvent) {
    var point = self.fromLatLngToPoint(mouseEvent.latLng);
    var searchDb = customMap.getOverlayByName('search');
    if (searchDb !== undefined) {
      return searchDb.searchByCoordinates({
        modelId: self.getModel().getId(),
        coordinates: point,
        zoom: self.getGoogleMap().getZoom()
      }).then(null, GuiConnector.alert);
    } else {
      logger.warn("Search is impossible because search db is not present");
    }
  });

  // select last clicked map
  google.maps.event.addListener(this.getGoogleMap(), 'click', function (mouseEvent) {
    customMap.setActiveSubmapId(self.getId());
    customMap.setActiveSubmapClickCoordinates(self.fromLatLngToPoint(mouseEvent.latLng));
  });

  // select last clicked map
  google.maps.event.addListener(this.getGoogleMap(), 'rightclick', function (mouseEvent) {
    customMap.setActiveSubmapId(self.getId());
    customMap.setActiveSubmapClickCoordinates(self.fromLatLngToPoint(mouseEvent.latLng));
  });

  // prepare for image export
  google.maps.event.addListener(this.getGoogleMap(), 'rightclick', function () {
    var bounds = self.getGoogleMap().getBounds();
    var polygon = "";

    var ne = bounds.getNorthEast();
    var sw = bounds.getSouthWest();

    var westLng = sw.lng();
    var eastLng = ne.lng();

    if (westLng > 0) {
      westLng = -180;
    }
    if (eastLng - westLng > 90) {
      eastLng = -90;
    } else if (eastLng > -90) {
      eastLng = -90;
    }

    polygon += ne.lat() + "," + westLng + ";";
    polygon += ne.lat() + "," + eastLng + ";";
    polygon += sw.lat() + "," + eastLng + ";";
    polygon += sw.lat() + "," + westLng + ";";
    self.getTopMap().setSelectedPolygon(polygon);
  });

  // context menu event
  google.maps.event.addListener(this.getGoogleMap(), 'rightclick', function () {
    self.getTopMap().getContextMenu().open(GuiConnector.xPos, GuiConnector.yPos, new Date().getTime());
  });
};

/**
 * It toggle drawing manager used on the map: if it's on then it will turn it
 * off, if it's off it will turn it on
 *
 */
AbstractCustomMap.prototype._turnOnOffDrawing = function () {
  if (this.isDrawingOn()) {
    this.turnOffDrawing();
  } else {
    this.turnOnDrawing();
  }
};

/**
 * Checks if the drawing manager for the map is on.
 *
 */
AbstractCustomMap.prototype.isDrawingOn = function () {
  return this._drawingManager !== null;
};

/**
 * Turns on drawing manager on the map.
 */
AbstractCustomMap.prototype.turnOnDrawing = function () {
  if (this.isDrawingOn()) {
    logger.warn("Trying to turn on drawing manager, but it is already available.");
    return;
  }
  var customMap = this.getTopMap();
  var self = this;
  this._drawingManager = new google.maps.drawing.DrawingManager({
    drawingMode: google.maps.drawing.OverlayType.MARKER,
    drawingControl: true,
    drawingControlOptions: {
      position: google.maps.ControlPosition.TOP_CENTER,
      drawingModes: [google.maps.drawing.OverlayType.POLYGON]
    },
    markerOptions: {
      icon: 'images/beachflag.png'
    },
    circleOptions: {
      fillColor: '#ffff00',
      fillOpacity: 1,
      strokeWeight: 5,
      clickable: false,
      editable: true,
      zIndex: 1
    }
  });
  this._drawingManager.setMap(this.getGoogleMap());
  this._drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);

  google.maps.event.addListener(this._drawingManager, 'overlaycomplete', function (e) {
    if (e.type !== google.maps.drawing.OverlayType.MARKER) {
      // Switch back to non-drawing mode after drawing a shape.
      self._drawingManager.setDrawingMode(null);

      // Add an event listener that selects the newly-drawn shape when the
      // user mouses down on it.
      var newShape = e.overlay;
      newShape.type = e.type;
      google.maps.event.addListener(newShape, 'rightclick', function (e) {
        // select map that was clicked
        customMap.setActiveSubmapId(self.getId());

        self.setSelectedArea(newShape);
        newShape.position = e.latLng;

        self.getTopMap().setSelectedPolygon(self.areaToString(newShape));

        self.getTopMap().getSelectionContextMenu().open(GuiConnector.xPos, GuiConnector.yPos, new Date().getTime());
      });
    }
  });

};

/**
 * Sets selectedArea on this map.
 *
 */
AbstractCustomMap.prototype.setSelectedArea = function (area) {
  this._selectedArea = area;
};

/**
 * Returns selectedArea on this map.
 *
 */
AbstractCustomMap.prototype.getSelectedArea = function () {
  return this._selectedArea;
};

/**
 * Transforms google.maps.Polygon into string with coordinates.
 *
 */
AbstractCustomMap.prototype.areaToString = function (area) {
  var len = area.getPath().length;
  var path = area.getPath();
  var res = "";
  for (var i = 0; i < len; i++) {
    var latLng = path.getAt(i);
    res += latLng.lat() + "," + latLng.lng() + ";";
  }
  return res;
};

/**
 * Removes selected area (polygon) from the map.
 */
AbstractCustomMap.prototype._removeSelection = function () {
  if (this._selectedArea) {
    this._selectedArea.setMap(null);
    this._selectedArea = null;
  } else {
    logger.warn("Cannot remove selected area. No area was selected");
  }
};

/**
 * Turns off drawing manager on the map.
 */
AbstractCustomMap.prototype.turnOffDrawing = function () {
  if (this.isDrawingOn()) {
    this._drawingManager.setMap(null);
    this._drawingManager = null;
  } else {
    logger.warn("Trying to turn off drawing manager, but it is not available.");
  }
};

/**
 * Returns top map. TODO implementation of this function should be probably
 * moved to Submap and CustomMap classes and here only abstract function
 * definition
 *
 * @returns {CustomMap}
 */
AbstractCustomMap.prototype.getTopMap = function () {
  logger.fatal("Not implemented");
};

/**
 * Method that should be called when number of layouts to visualize changed to
 * modify boundaries of the elements to visualize. When few layouts are
 * visualized at the same time then index contains information where this new
 * layout is placed in the list (starting from 0) and length contains
 * information how many layouts we visualize in total.
 *
 * @param layoutId
 *          identifier of a layout
 * @param index
 *          when visualizing more than one layout at the same time index
 *          contains information at which position in the list this layout is
 *          placed
 * @param length
 *          number of layouts that are currently visualized
 */
AbstractCustomMap.prototype._resizeSelectedDataOverlay = function (layoutId, index, length) {
  var self = this;
  return new Promise(function (resolve) {
    // if map is not initialized then don't perform this operation
    if (!self.initialized) {
      logger.debug("Model " + self.getId() + " not initialized");
      resolve();
    }
    logger.debug("Resize layout: " + layoutId);
    // start ratio
    var startX = index * (1.0 / length);
    // end ratio
    var endX = (index + 1) * (1.0 / length);

    for (var i = 0; i < self.selectedLayoutOverlays[layoutId].length; i++) {
      self.selectedLayoutOverlays[layoutId][i].setBoundsForAlias(startX, endX);
    }
    resolve();
  });
};

/**
 * Shows all elements from a given layout. When few layouts are visualized at
 * the same time then index contains information where this new layout is placed
 * in the list (starting from 0) and length contains information how many
 * layouts we visualize in total.
 *
 * @param layoutId
 *          identifier of a layout
 * @param index
 *          when visualizing more than one layout at the same time index
 *          contains information at which position in the list this layout is
 *          placed
 * @param length
 *          number of layouts that are currently visualized
 */
AbstractCustomMap.prototype._showSelectedDataOverlay = function (layoutId, index, length) {
  var self = this;
  // if map is not initialized then don't perform this operation
  return new Promise(function (resolve, reject) {
    if (!self.initialized) {
      logger.debug("Model " + self.getId() + " not initialized");
      resolve();
      return;
    } else {
      logger.debug("Showing model " + self.getId());
    }

    self.selectedLayoutOverlays[layoutId] = [];

    // start ratio
    var startX = index * (1.0 / length);
    // end ratio
    var endX = (index + 1) * (1.0 / length);

    var layoutAliases;
    var layoutReactions;

    return self.getProject().getDataOverlayById(layoutId).then(function (layout) {
      layoutAliases = layout.getAliases();
      layoutReactions = layout.getReactions();

      var identifiedElements = [];
      var i;
      for (i = 0; i < layoutAliases.length; i++) {
        if (layoutAliases[i].getModelId() === self.getId()) {
          identifiedElements.push(new IdentifiedElement(layoutAliases[i]));
        }
      }
      var reactionIds = [];
      for (i = 0; i < layoutReactions.length; i++) {
        if (layoutReactions[i].getModelId() === self.getId()) {
          identifiedElements.push(new IdentifiedElement(layoutReactions[i]));
        }
      }
      return self.getModel().getByIdentifiedElements(identifiedElements, false);
    }).then(function () {
      return Promise.each(layoutAliases, function (layoutAlias) {
        if (layoutAlias.getModelId() === self.getId()) {
          var surface;
          var element;
          return self.getModel().getAliasById(layoutAlias.getId()).then(function (aliasData) {
            element = new IdentifiedElement(aliasData);
            return AliasSurface.create({
              overlayAlias: layoutAlias,
              alias: aliasData,
              map: self,
              startX: startX,
              endX: endX,
              onClick: [function () {
                return self.getTopMap().getOverlayByName("search").searchByTarget(element);
              }, function () {
                return self.getTopMap().callListeners("onBioEntityClick", element);
              }]
            });
          }).then(function (result) {
            surface = result;
            self.selectedLayoutOverlays[layoutId].push(surface);
          });
        }
      });
    }).then(function () {
      return Promise.each(layoutReactions, function (layoutReaction) {
        if (layoutReaction.getModelId() === self.getId()) {
          return self.getModel().getReactionById(layoutReaction.getId()).then(function (reactionData) {
            var surface;
            var element = new IdentifiedElement(reactionData);
            return ReactionSurface.create({
              layoutReaction: layoutReaction,
              reaction: reactionData,
              map: self,
              onClick: [function () {
                return self.getTopMap().getOverlayByName("search").searchByTarget(element);
              }, function () {
                return self.getTopMap().callListeners("onBioEntityClick", element);
              }],
              customized: (length === 1)
            }).then(function (result) {
              surface = result;
              self.selectedLayoutOverlays[layoutId].push(surface);
              surface.show();
            });
          });
        }
      });
    }).then(function () {
      resolve();
    }).then(null, reject);
  });
};

/**
 * Hides all elements from layout.
 *
 * @param layoutId
 *          identifier of a layout
 */
AbstractCustomMap.prototype._hideSelectedLayout = function (layoutId) {
  // if map is not initialized then don't perform this operation
  if (!this.initialized) {
    logger.debug("Model " + this.getId() + " not initialized");
    return;
  }
  for (var i = 0; i < this.selectedLayoutOverlays[layoutId].length; i++) {
    this.selectedLayoutOverlays[layoutId][i].setMap(null);
  }
  this.selectedLayoutOverlays[layoutId] = [];
};

/**
 * Opens {@link AliasInfoWindow} for given alias.
 *
 * @param aliasId
 *          identifier of the alias
 */
AbstractCustomMap.prototype._openInfoWindowForAlias = function (alias, googleMarker) {
  var self = this;

  var infoWindow = this.getAliasInfoWindowById(alias.getId());
  if (infoWindow !== null && infoWindow !== undefined) {
    if (!infoWindow.isOpened()) {
      infoWindow.open(googleMarker);
    } else {
      logger.warn("Info window for alias: " + alias.getId() + " is already opened");
    }
    return Promise.resolve(infoWindow);
  } else {
    self._aliasInfoWindow[alias.getId()] = new AliasInfoWindow({
      alias: alias,
      map: self,
      marker: googleMarker,
    });
    return self._aliasInfoWindow[alias.getId()].init();
  }
};

/**
 * Returns promise of a list of {@link LayoutAlias} information for a given
 * {@link Alias} in all currently visualized layouts.
 *
 * @param aliasId
 *          identifier of the {@link Alias}
 * @returns promise of an {Array} with list of {@link LayoutAlias} information
 *          for a given {@link Alias} in all currently visualized layouts
 */
AbstractCustomMap.prototype.getAliasVisibleLayoutsData = function (aliasId) {
  var self = this;
  return self.getTopMap().getVisibleDataOverlays().then(function (visibleDataOverlays) {
    var result = [];
    for (var i = 0; i < visibleDataOverlays.length; i++) {
      var layout = visibleDataOverlays[i];
      result.push(layout.getFullAliasById(aliasId));
    }
    return Promise.all(result);
  });
};

/**
 * Refresh content of all {@link AliasInfoWindow} in this map.
 */
AbstractCustomMap.prototype._refreshInfoWindows = function () {
  var promises = [];
  for (var key in this._pointInfoWindow) {
    if (this._pointInfoWindow.hasOwnProperty(key)) {
      if (this._pointInfoWindow[key].isOpened()) {
        promises.push(this._pointInfoWindow[key].update());
      }
    }
  }
  for (var aliasKey in this._aliasInfoWindow) {
    if (this._aliasInfoWindow.hasOwnProperty(aliasKey)) {
      if (this._aliasInfoWindow[aliasKey].isOpened()) {
        promises.push(this._aliasInfoWindow[aliasKey].update());
      }
    }
  }
  return Promise.all(promises);
};


/**
 * Opens {@link ReactionInfoWindow} for given reaction identifier.
 *
 * @param reactionId
 *          reaction identifier
 */
AbstractCustomMap.prototype._openInfoWindowForReaction = function (reaction, googleMarker) {
  var infoWindow = this.getReactionInfoWindowById(reaction.getId());
  var self = this;
  if (infoWindow !== null && infoWindow !== undefined) {
    if (!infoWindow.isOpened()) {
      infoWindow.open(googleMarker);
    } else {
      logger.warn("Info window for reaction: " + reaction.getId() + " is already opened");
    }
    return Promise.resolve(infoWindow);
  } else {
    return self.getModel().getReactionById(reaction.getId()).then(function (reaction) {
      infoWindow = new ReactionInfoWindow({
        reaction: reaction,
        map: self,
        marker: googleMarker
      });
      self._reactionInfoWindow[reaction.getId()] = infoWindow;
      return infoWindow.init();
    }).then(function () {
      return infoWindow.open();
    }).then(function () {
      return infoWindow;
    });
  }
};

AbstractCustomMap.prototype._openInfoWindowForPoint = function (pointData, googleMarker) {
  var self = this;

  var infoWindow = this.getPointInfoWindowById(pointData.getId());
  if (infoWindow !== null && infoWindow !== undefined) {
    if (!infoWindow.isOpened()) {
      infoWindow.open(googleMarker);
    } else {
      logger.warn("Info window for point: " + pointData.getId() + " is already opened");
    }
  } else {
    infoWindow = new PointInfoWindow({
      point: pointData,
      map: self,
      marker: googleMarker,
    });
    this._pointInfoWindow[pointData.getId()] = infoWindow;
  }
  return Promise.resolve(infoWindow);
};

/**
 * Opens {@link AbstractInfoWindow} for a marker.
 *
 * @param marker
 *          marker for which we are opening window
 */
AbstractCustomMap.prototype.returnInfoWindowForIdentifiedElement = function (element) {
  var markerId = element.getId();
  if (element.getType() === "ALIAS") {
    return this.getAliasInfoWindowById(markerId);
  } else if (element.getType() === "POINT") {
    return this.getPointInfoWindowById(markerId);
  } else if (element.getType() === "REACTION") {
    return this.getReactionInfoWindowById(markerId);
  } else {
    throw new Error("Unknown marker type: ", element);
  }
};

/**
 * Returns identifier.
 *
 * @returns identifier
 */
AbstractCustomMap.prototype.getId = function () {
  return this.getModel().getId();
};

AbstractCustomMap.prototype.getConfiguration = function () {
  return this._configuration;
};

AbstractCustomMap.prototype.setConfiguration = function (configuration) {
  this._configuration = configuration;
};

AbstractCustomMap.prototype._createMapChangedCallbacks = function () {
  var self = this;
  var sessionData = ServerConnector.getSessionData(self.getTopMap().getProject());
  // listener for changing zoom level
  google.maps.event.addListener(this.getGoogleMap(), 'zoom_changed', function () {
    sessionData.setZoomLevel(self.getModel(), self.getGoogleMap().getZoom());
  });

  google.maps.event.addListener(this.getGoogleMap(), 'zoom_changed', function () {
    return self.callListeners("onZoomChanged", self.getGoogleMap().getZoom());
  });

  // listener for changing location of the map (moving left/right/top/bottom
  google.maps.event.addListener(this.getGoogleMap(), 'center_changed', function () {
    var coord = self.getGoogleMap().getCenter();
    var point = self.fromLatLngToPoint(coord);
    sessionData.setCenter(self.getModel(), point);
  });
};

AbstractCustomMap.prototype.addCenterButton = function () {
  var self = this;
  var centerDiv = functions.createElement({
    type: "div",
    style: "padding:5px"
  });
  var centerButton = functions.createElement({
    type: "a",
    content: "<i class='fa fa-crosshairs' style='font-size:24px;color:grey'></i>&nbsp;",
    title: "center map",
    href: "#",
    onclick: function () {
      var bounds = new google.maps.LatLngBounds();
      bounds.extend(self.getTopLeftLatLng());
      bounds.extend(self.getBottomRightLatLng());

      self.getGoogleMap().fitBounds(bounds);
      return false;
    },
    xss: false
  });
  centerDiv.appendChild(centerButton);
  self.getGoogleMap().controls[google.maps.ControlPosition.RIGHT_TOP].push(centerDiv);

};

/**
 * Returns {@link ReactionInfoWindow} for given reaction identifier
 *
 * @param reactionId
 *          reaction identifier
 * @returns {@link ReactionInfoWindow} for given reaction identifier
 */
AbstractCustomMap.prototype.getReactionInfoWindowById = function (reactionId) {
  return this._reactionInfoWindow[reactionId];
};

/**
 * Returns {@link AliasInfoWindow} for given alias identifier
 *
 * @param aliasId
 *          alias identifier
 * @returns {@link AliasInfoWindow} for given alias identifier
 */
AbstractCustomMap.prototype.getAliasInfoWindowById = function (aliasId) {
  return this._aliasInfoWindow[aliasId];
};

/**
 * Returns {@link PointInfoWindow} for given point identifier
 *
 * @param pointId
 *          point identifier
 * @returns {@link PointInfoWindow} for given point identifier
 */
AbstractCustomMap.prototype.getPointInfoWindowById = function (pointId) {
  return this._pointInfoWindow[pointId];
};

AbstractCustomMap.prototype.getModel = function () {
  return this._model;
};

AbstractCustomMap.prototype.setModel = function (model) {
  this._model = model;
};

AbstractCustomMap.prototype.getTileSize = function () {
  return this.getModel().getTileSize();
};

AbstractCustomMap.prototype.getMinZoom = function () {
  return this.getModel().getMinZoom();
};

AbstractCustomMap.prototype.getMaxZoom = function () {
  return this.getModel().getMaxZoom();
};

AbstractCustomMap.prototype.getPictureSize = function () {
  return this.getModel().getPictureSize();
};

/**
 * Returns array containing elements that are presented on a specific layout
 * (set of google map objects representing lines/areas that are associated with
 * layout).
 *
 * @returns {Array} containing elements that are presented on a specific
 *          layout (set of google map objects representing lines/areas that are
 *          associated with layout).
 */
AbstractCustomMap.prototype.getSelectedLayoutOverlays = function () {
  return this.selectedLayoutOverlays;
};

/**
 * Returns google.maps.map object used to representing data.
 *
 * @returns google.maps.map object used to representing data
 */
AbstractCustomMap.prototype.getGoogleMap = function () {
  return this._map;
};

/**
 * Sets google.maps.map object used to representing data.
 *
 */
AbstractCustomMap.prototype.setGoogleMap = function (googleMap) {
  this._map = googleMap;
};

AbstractCustomMap.prototype.isMarkerOptimization = function () {
  return this._markerOptimization;
};

AbstractCustomMap.prototype.isBigLogo = function () {
  return this._bigLogo;
};

AbstractCustomMap.prototype.isCustomTouchInterface = function () {
  return this._customTouchInterface;
};

AbstractCustomMap.prototype.setDebug = function (debug) {
  if (debug !== undefined) {
    if (typeof debug !== "boolean") {
      logger.warn("param must be boolean");
    }
    this._debug = debug;
  }
};

AbstractCustomMap.prototype.isDebug = function () {
  return this._debug === true;
};

AbstractCustomMap.prototype.getTopLeftLatLng = function () {
  return this.getModel().getTopLeftLatLng();
};

AbstractCustomMap.prototype.getBottomRightLatLng = function () {
  return this.getModel().getBottomRightLatLng();
};

AbstractCustomMap.prototype.getElement = function () {
  return this._element;
};
AbstractCustomMap.prototype.setElement = function (element) {
  this._element = element;
};

/**
 * Sets center point for google maps.
 *
 * @param coordinates
 *          new center point on map
 */
AbstractCustomMap.prototype.setCenter = function (coordinates) {
  if (coordinates instanceof google.maps.Point) {
    coordinates = this.fromPointToLatLng(coordinates);
  }
  if (this.initialized) {
    return Promise.resolve(this.getGoogleMap().setCenter(coordinates));
  } else {
    logger.warn("cannot center map that is not opened yet");
    return Promise.resolve();
  }
};

/**
 * Sets zoom level for google maps.
 *
 * @param mapIdentifier
 *          id of the model for which we change zoom level
 * @param zoom
 *          new zoom level on map
 */
AbstractCustomMap.prototype.setZoom = function (zoom) {
  if (this.initialized) {
    return Promise.resolve(this.getGoogleMap().setZoom(zoom));
  } else {
    logger.warn("cannot center map that is not opened yet");
    return Promise.resolve();
  }
};

AbstractCustomMap.prototype.fitBounds = function (markers) {
  var self = this;
  var map = self.getGoogleMap();
  if (map !== undefined) {
    var bounds = new google.maps.LatLngBounds();

    for (var i = 0; i < markers.length; i++) {
      var marker = markers[i];
      if (marker.getModelId() === self.getId()) {
        var markerBounds = marker.getBounds();
        bounds.extend(markerBounds.getNorthEast());
        bounds.extend(markerBounds.getSouthWest());
      }
    }
    if (!bounds.isEmpty()) {
      map.fitBounds(bounds);
    }
  }
};


AbstractCustomMap.prototype.setProject = function (project) {
  this._project = project;
};
AbstractCustomMap.prototype.getProject = function () {
  return this._project;
};

module.exports = AbstractCustomMap;