(function(factory) {
  var L, proj4;
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['leaflet', 'proj4'], factory);
  } else if (typeof module === 'object' && typeof module.exports === 'object') {
    // Node/CommonJS
    L = require('leaflet');
    proj4 = require('proj4');
    module.exports = factory(L, proj4);
  } else {
    // Browser globals
    if (typeof window.L === 'undefined' || typeof window.proj4 === 'undefined') { throw 'Leaflet and proj4 must be loaded first'; }
    factory(window.L, window.proj4);
  }
}(function(L, proj4) {
  if (proj4.__esModule && proj4.default) {
    // If proj4 was bundled as an ES6 module, unwrap it to get
    // to the actual main proj4 object.
    // See discussion in https://github.com/kartena/Proj4Leaflet/pull/147
    proj4 = proj4.default;
  }

  L.Proj = {};

  L.Proj._isProj4Obj = function(a) {
    return (typeof a.inverse !== 'undefined' &&
			typeof a.forward !== 'undefined');
  };

  L.Proj.Projection = L.Class.extend({
    initialize: function(code, def, bounds) {
      var isP4 = L.Proj._isProj4Obj(code);
      this._proj = isP4 ? code : this._projFromCodeDef(code, def);
      this.bounds = isP4 ? def : bounds;
    },

    project: function(latlng) {
      var point = this._proj.forward([latlng.lng, latlng.lat]);
      return new L.Point(point[0], point[1]);
    },

    unproject: function(point, unbounded) {
      var point2 = this._proj.inverse([point.x, point.y]);
      return new L.LatLng(point2[1], point2[0], unbounded);
    },

    _projFromCodeDef: function(code, def) {
      if (def) {
        proj4.defs(code, def);
      } else if (proj4.defs[code] === undefined) {
        var urn = code.split(':');
        if (urn.length > 3) {
          code = urn[urn.length - 3] + ':' + urn[urn.length - 1];
        }
        if (proj4.defs[code] === undefined) {
          throw 'No projection definition for code ' + code;
        }
      }

      return proj4(code);
    }
  });

  L.Proj.CRS = L.Class.extend({
    includes: L.CRS,

    options: {
      transformation: new L.Transformation(1, 0, -1, 0)
    },

    initialize: function(a, b, c) {
      var code,
			    proj,
			    def,
			    options;

      if (L.Proj._isProj4Obj(a)) {
        proj = a;
        code = proj.srsCode;
        options = b || {};

        this.projection = new L.Proj.Projection(proj, options.bounds);
      } else {
        code = a;
        def = b;
        options = c || {};
        this.projection = new L.Proj.Projection(code, def, options.bounds);
      }

      L.Util.setOptions(this, options);
      this.code = code;
      this.transformation = this.options.transformation;

      if (this.options.origin) {
        this.transformation =
					new L.Transformation(1, -this.options.origin[0],
					  -1, this.options.origin[1]);
      }

      if (this.options.scales) {
        this._scales = this.options.scales;
      } else if (this.options.resolutions) {
        this._scales = [];
        for (var i = this.options.resolutions.length - 1; i >= 0; i--) {
          if (this.options.resolutions[i]) {
            this._scales[i] = 1 / this.options.resolutions[i];
          }
        }
      }

      this.infinite = !this.options.bounds;
    },

    scale: function(zoom) {
      var iZoom = Math.floor(zoom);
      var baseScale;
      var nextScale;
      var scaleDiff;
      var zDiff;
      if (zoom === iZoom) {
        return this._scales[zoom];
      } else {
        // Non-integer zoom, interpolate
        baseScale = this._scales[iZoom];
        nextScale = this._scales[iZoom + 1];
        scaleDiff = nextScale - baseScale;
        zDiff = (zoom - iZoom);
        return baseScale + scaleDiff * zDiff;
      }
    },

    zoom: function(scale) {
      // Find closest number in this._scales, down
      var downScale = this._closestElement(this._scales, scale);
      var downZoom = this._scales.indexOf(downScale);
      var nextScale;
      var nextZoom;
      var scaleDiff;
      // Check if scale is downScale => return array index
      if (scale === downScale) {
        return downZoom;
      }
      if (downScale === undefined) {
        return -Infinity;
      }
      // Interpolate
      nextZoom = downZoom + 1;
      nextScale = this._scales[nextZoom];
      if (nextScale === undefined) {
        return Infinity;
      }
      scaleDiff = nextScale - downScale;
      return (scale - downScale) / scaleDiff + downZoom;
    },

    distance: L.CRS.Earth.distance,

    R: L.CRS.Earth.R,

    /* Get the closest lowest element in an array */
    _closestElement: function(array, element) {
      var low;
      for (var i = array.length; i--;) {
        if (array[i] <= element && (low === undefined || low < array[i])) {
          low = array[i];
        }
      }
      return low;
    }
  });

  L.Proj.GeoJSON = L.GeoJSON.extend({
    initialize: function(geojson, options) {
      this._callLevel = 0;
      L.GeoJSON.prototype.initialize.call(this, geojson, options);
    },

    addData: function(geojson) {
      var crs;

      if (geojson) {
        if (geojson.crs && geojson.crs.type === 'name') {
          crs = new L.Proj.CRS(geojson.crs.properties.name);
        } else if (geojson.crs && geojson.crs.type) {
          crs = new L.Proj.CRS(geojson.crs.type + ':' + geojson.crs.properties.code);
        }

        if (crs !== undefined) {
          this.options.coordsToLatLng = function(coords) {
            var point = L.point(coords[0], coords[1]);
            return crs.projection.unproject(point);
          };
        }
      }

      // Base class' addData might call us recursively, but
      // CRS shouldn't be cleared in that case, since CRS applies
      // to the whole GeoJSON, inluding sub-features.
      this._callLevel++;
      try {
        L.GeoJSON.prototype.addData.call(this, geojson);
      } finally {
        this._callLevel--;
        if (this._callLevel === 0) {
          delete this.options.coordsToLatLng;
        }
      }
    }
  });

  L.Proj.geoJson = function(geojson, options) {
    return new L.Proj.GeoJSON(geojson, options);
  };

  L.Proj.ImageOverlay = L.ImageOverlay.extend({
    initialize: function(url, bounds, options) {
      L.ImageOverlay.prototype.initialize.call(this, url, null, options);
      this._projectedBounds = bounds;
    },

    // Danger ahead: Overriding internal methods in Leaflet.
    // Decided to do this rather than making a copy of L.ImageOverlay
    // and doing very tiny modifications to it.
    // Future will tell if this was wise or not.
    _animateZoom: function(event) {
      var scale = this._map.getZoomScale(event.zoom);
      var northWest = L.point(this._projectedBounds.min.x, this._projectedBounds.max.y);
      var offset = this._projectedToNewLayerPoint(northWest, event.zoom, event.center);

      L.DomUtil.setTransform(this._image, offset, scale);
    },

    _reset: function() {
      var zoom = this._map.getZoom();
      var pixelOrigin = this._map.getPixelOrigin();
      var bounds = L.bounds(
        this._transform(this._projectedBounds.min, zoom)._subtract(pixelOrigin),
        this._transform(this._projectedBounds.max, zoom)._subtract(pixelOrigin)
      );
      var size = bounds.getSize();

      L.DomUtil.setPosition(this._image, bounds.min);
      this._image.style.width = size.x + 'px';
      this._image.style.height = size.y + 'px';
    },

    _projectedToNewLayerPoint: function(point, zoom, center) {
      var viewHalf = this._map.getSize()._divideBy(2);
      var newTopLeft = this._map.project(center, zoom)._subtract(viewHalf)._round();
      var topLeft = newTopLeft.add(this._map._getMapPanePos());

      return this._transform(point, zoom)._subtract(topLeft);
    },

    _transform: function(point, zoom) {
      var crs = this._map.options.crs;
      var transformation = crs.transformation;
      var scale = crs.scale(zoom);

      return transformation.transform(point, scale);
    }
  });

  L.Proj.imageOverlay = function(url, bounds, options) {
    return new L.Proj.ImageOverlay(url, bounds, options);
  };

  return L.Proj;
}));