(function (factory) {
	var L, proj4;
	if (typeof define === 'function' && define.amd) {
		// AMD
		define(['leaflet', 'proj4'], factory);
	} else if (typeof module !== 'undefined') {
		// 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) {

	L.Proj = {};

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

	L.Proj.ScaleDependantTransformation = function(scaleTransforms) {
		this.scaleTransforms = scaleTransforms;
	};

	L.Proj.ScaleDependantTransformation.prototype.transform = function(point, scale) {
		return this.scaleTransforms[scale].transform(point, scale);
	};

	L.Proj.ScaleDependantTransformation.prototype.untransform = function(point, scale) {
		return this.scaleTransforms[scale].untransform(point, scale);
	};

	L.Proj.Projection = L.Class.extend({
		initialize: function(a, def) {
			if (L.Proj._isProj4Obj(a)) {
				this._proj = a;
			} else {
				var code = a;
				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;
					}
				}
				this._proj = proj4(code);
			}
		},

		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);
		}
	});

	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);
			} else {
				code = a;
				def = b;
				options = c || {};
				this.projection = new L.Proj.Projection(code, def);
			}

			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];
					}
				}
			}
		},

		scale: function(zoom) {
			var iZoom = Math.floor(zoom),
				baseScale,
				nextScale,
				scaleDiff,
				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;
			}
		},

		getSize: function(zoom) {
			var b = this.options.bounds,
			    s,
			    min,
			    max;

			if (b) {
				s = this.scale(zoom);
				min = this.transformation.transform(b.min, s);
				max = this.transformation.transform(b.max, s);
				return L.point(Math.abs(max.x - min.x), Math.abs(max.y - min.y));
			} else {
				// Backwards compatibility with Leaflet < 0.7
				s = 256 * Math.pow(2, zoom);
				return L.point(s, s);
			}
		}
	});

	L.Proj.CRS.TMS = L.Proj.CRS.extend({
		options: {
			tileSize: 256
		},

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

			if (L.Proj._isProj4Obj(a)) {
				proj = a;
				projectedBounds = b;
				options = c || {};
				options.origin = [projectedBounds[0], projectedBounds[3]];
				L.Proj.CRS.prototype.initialize.call(this, proj, options);
			} else {
				code = a;
				def = b;
				projectedBounds = c;
				options = d || {};
				options.origin = [projectedBounds[0], projectedBounds[3]];
				L.Proj.CRS.prototype.initialize.call(this, code, def, options);
			}

			this.projectedBounds = projectedBounds;

			this._sizes = this._calculateSizes();
		},

		_calculateSizes: function() {
			var sizes = [],
			    crsBounds = this.projectedBounds,
			    projectedTileSize,
			    i,
			    x,
			    y;
			for (i = this._scales.length - 1; i >= 0; i--) {
				if (this._scales[i]) {
					projectedTileSize = this.options.tileSize / this._scales[i];
					// to prevent very small rounding errors from causing us to round up,
					// cut any decimals after 3rd before rounding up.
					x = Math.ceil(parseFloat((crsBounds[2] - crsBounds[0]) / projectedTileSize).toPrecision(3)) *
					    projectedTileSize * this._scales[i];
					y = Math.ceil(parseFloat((crsBounds[3] - crsBounds[1]) / projectedTileSize).toPrecision(3)) *
					    projectedTileSize * this._scales[i];
					sizes[i] = L.point(x, y);
				}
			}

			return sizes;
		},

		getSize: function(zoom) {
			return this._sizes[zoom];
		}
	});

	L.Proj.TileLayer = {};

	// Note: deprecated and not necessary since 0.7, will be removed
	L.Proj.TileLayer.TMS = L.TileLayer.extend({
		options: {
			continuousWorld: true
		},

		initialize: function(urlTemplate, crs, options) {
			var boundsMatchesGrid = true,
				scaleTransforms,
				upperY,
				crsBounds,
				i;

			if (!(crs instanceof L.Proj.CRS.TMS)) {
				throw 'CRS is not L.Proj.CRS.TMS.';
			}

			L.TileLayer.prototype.initialize.call(this, urlTemplate, options);
			// Enabling tms will cause Leaflet to also try to do TMS, which will
			// break (at least prior to 0.7.0). Actively disable it, to prevent
			// well-meaning users from shooting themselves in the foot.
			this.options.tms = false;
			this.crs = crs;
			crsBounds = this.crs.projectedBounds;

			// Verify grid alignment
			for (i = this.options.minZoom; i < this.options.maxZoom && boundsMatchesGrid; i++) {
				var gridHeight = (crsBounds[3] - crsBounds[1]) /
					this._projectedTileSize(i);
				boundsMatchesGrid = Math.abs(gridHeight - Math.round(gridHeight)) > 1e-3;
			}

			if (!boundsMatchesGrid) {
				scaleTransforms = {};
				for (i = this.options.minZoom; i < this.options.maxZoom; i++) {
					upperY = crsBounds[1] + Math.ceil((crsBounds[3] - crsBounds[1]) /
						this._projectedTileSize(i)) * this._projectedTileSize(i);
					scaleTransforms[this.crs.scale(i)] = new L.Transformation(1, -crsBounds[0], -1, upperY);
				}

				this.crs = new L.Proj.CRS.TMS(this.crs.projection._proj, crsBounds, this.crs.options);
				this.crs.transformation = new L.Proj.ScaleDependantTransformation(scaleTransforms);
			}
		},

		getTileUrl: function(tilePoint) {
			var zoom = this._map.getZoom(),
				gridHeight = Math.ceil(
				(this.crs.projectedBounds[3] - this.crs.projectedBounds[1]) /
				this._projectedTileSize(zoom));

			return L.Util.template(this._url, L.Util.extend({
				s: this._getSubdomain(tilePoint),
				z: this._getZoomForUrl(),
				x: tilePoint.x,
				y: gridHeight - tilePoint.y - 1
			}, this.options));
		},

		_projectedTileSize: function(zoom) {
			return (this.options.tileSize / this.crs.scale(zoom));
		}
	});

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

		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._projBounds = bounds;
		},

		/* Danger ahead: overriding internal methods in Leaflet.
		   I've decided to do this rather than making a copy of L.ImageOverlay
		   and making very tiny modifications to it. Future will tell if this
		   was wise or not. */
		_animateZoom: function (e) {
			var northwest = L.point(this._projBounds.min.x, this._projBounds.max.y),
				southeast =  L.point(this._projBounds.max.x, this._projBounds.min.y),
				topLeft = this._projectedToNewLayerPoint(northwest, e.zoom, e.center),
			    size = this._projectedToNewLayerPoint(southeast, e.zoom, e.center).subtract(topLeft),
			    origin = topLeft.add(size._multiplyBy((1 - 1 / e.scale) / 2));

			this._image.style[L.DomUtil.TRANSFORM] =
		        L.DomUtil.getTranslateString(origin) + ' scale(' + this._map.getZoomScale(e.zoom) + ') ';
		},

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

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

		_projectedToNewLayerPoint: function (point, newZoom, newCenter) {
			var topLeft = this._map._getNewTopLeftPoint(newCenter, newZoom).add(this._map._getMapPanePos());
			return this._transform(point, newZoom)._subtract(topLeft);
		},

		_transform: function(p, zoom) {
			var crs = this._map.options.crs,
				transformation = crs.transformation,
				scale = crs.scale(zoom);
			return transformation.transform(p, scale);
		}
	});

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

	if (typeof L.CRS !== 'undefined') {
		// This is left here for backwards compatibility
		L.CRS.proj4js = (function () {
			return function (code, def, transformation, options) {
				options = options || {};
				if (transformation) {
					options.transformation = transformation;
				}

				return new L.Proj.CRS(code, def, options);
			};
		}());
	}

	return L.Proj;
}));
