(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) {

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

        zoom: function(scale) {
            // Find closest number in this._scales, down
            var downScale = this._closestElement(this._scales, scale),
                downZoom = this._scales.indexOf(downScale),
                nextScale,
                nextZoom,
                scaleDiff;
            // Check if scale is downScale => return array index
            if (scale === downScale) {
                return downZoom;
            }
            // 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;
}));
