/** 
 * @requires OpenLayers/Layer/XYZ.js
 */ 

/** 
 * Class: OpenLayers.Layer.ArcGISCache   
 * Layer for accessing cached map tiles from an ArcGIS Server style mapcache. 
 * Tile must already be cached for this layer to access it. This does not require 
 * ArcGIS Server itself.
 * 
 * A few attempts have been made at this kind of layer before. See 
 * http://trac.osgeo.org/openlayers/ticket/1967 
 * and 
 * http://trac.osgeo.org/openlayers/browser/sandbox/tschaub/arcgiscache/lib/OpenLayers/Layer/ArcGISCache.js
 *
 * Typically the problem encountered is that the tiles seem to "jump around".
 * This is due to the fact that the actual max extent for the tiles on AGS layers
 * changes at each zoom level due to the way these caches are constructed.
 * We have attempted to use the resolutions, tile size, and tile origin
 * from the cache meta data to make the appropriate changes to the max extent
 * of the tile to compensate for this behavior.  This must be done as zoom levels change
 * and before tiles are requested, which is why methods from base classes are overridden.
 *
 * For reference, you can access mapcache meta data in two ways. For accessing a 
 * mapcache through ArcGIS Server, you can simply go to the landing page for the
 * layer. (ie. http://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer)
 * For accessing it directly through HTTP, there should always be a conf.xml file
 * in the root directory. 
 * (ie. http://serverx.esri.com/arcgiscache/DG_County_roads_yesA_backgroundDark/Layers/conf.xml)
 *  
 *Inherits from: 
 *  - <OpenLayers.Layer.XYZ>             
 */    
OpenLayers.Layer.ArcGISCache = OpenLayers.Class(OpenLayers.Layer.XYZ, {  

    /**
     * APIProperty: url
     * {String | Array} The base URL for the layer cache.  You can also
     *     provide a list of URL strings for the layer if your cache is
     *     available from multiple origins.  This must be set before the layer
     *     is drawn.
     */
    url: null,
    
   /**
    * APIProperty: tileOrigin
    * {<OpenLayers.LonLat>} The location of the tile origin for the cache.
    *     An ArcGIS cache has it's origin at the upper-left (lowest x value
    *     and highest y value of the coordinate system).  The units for the
    *     tile origin should be the same as the units for the cached data.
    */
    tileOrigin: null, 
   
   /**
    * APIProperty: tileSize
    * {<OpenLayers.Size>} This size of each tile. Defaults to 256 by 256 pixels.
    */
    tileSize: new OpenLayers.Size(256, 256),
    
   /**
    * APIProperty: useAGS
    * {Boolean} Indicates if we are going to be accessing the ArcGIS Server (AGS)
    *     cache via an AGS MapServer or directly through HTTP. When accessing via
    *     AGS the path structure uses a standard z/y/x structure. But AGS actually
    *     stores the tile images on disk using a hex based folder structure that looks
    *     like "http://example.com/mylayer/L00/R00000000/C00000000.png".  Learn more
    *     about this here:
    *     http://blogs.esri.com/Support/blogs/mappingcenter/archive/2010/08/20/Checking-Your-Local-Cache-Folders.aspx
    *     Defaults to true;
    */    
    useArcGISServer: true,

   /**
    * APIProperty: type
    * {String} Image type for the layer.  This becomes the filename extension
    *     in tile requests.  Default is "png" (generating a url like
    *     "http://example.com/mylayer/L00/R00000000/C00000000.png").
    */
    type: 'png',
    
    /**
    * APIProperty: useScales
    * {Boolean} Optional override to indicate that the layer should use 'scale' information
    *     returned from the server capabilities object instead of 'resolution' information.
    *     This can be important if your tile server uses an unusual DPI for the tiles.
    */
    useScales: false,
    
   /**
    * APIProperty: overrideDPI
    * {Boolean} Optional override to change the OpenLayers.DOTS_PER_INCH setting based 
    *     on the tile information in the server capabilities object.  This can be useful 
    *     if your server has a non-standard DPI setting on its tiles, and you're only using 
    *     tiles with that DPI.  This value is used while OpenLayers is calculating resolution
    *     using scales, and is not necessary if you have resolution information. (This is
    *     typically the case)  Regardless, this setting can be useful, but is dangerous
    *     because it will impact other layers while calculating resolution.  Only use this
    *     if you know what you are doing.  (See OpenLayers.Util.getResolutionFromScale)
    */
    overrideDPI: false,
    
   /**
    * Constructor: OpenLayers.Layer.ArcGISCache 
    * Creates a new instance of this class 
    * 
    * Parameters: 
    * name - {String} 
    * url - {String} 
    * options - {Object} extra layer options
    */ 
    initialize: function(name, url, options) { 
        OpenLayers.Layer.XYZ.prototype.initialize.apply(this, arguments);

        if (this.resolutions) {        
            this.serverResolutions = this.resolutions;
            this.maxExtent = this.getMaxExtentForResolution(this.resolutions[0]);
        }

        // this block steps through translating the values from the server layer JSON 
        // capabilities object into values that we can use.  This is also a helpful
        // reference when configuring this layer directly.
        if (this.layerInfo) {
            // alias the object
            var info = this.layerInfo;
            
            // build our extents
            var startingTileExtent = new OpenLayers.Bounds(
                info.fullExtent.xmin, 
                info.fullExtent.ymin, 
                info.fullExtent.xmax, 
                info.fullExtent.ymax  
            );

            // set our projection based on the given spatial reference.
            // esri uses slightly different IDs, so this may not be comprehensive
            this.projection = 'EPSG:' + info.spatialReference.wkid;
            this.sphericalMercator = (info.spatialReference.wkid == 102100);
            
            // convert esri units into openlayers units (basic feet or meters only)
            this.units = (info.units == "esriFeet") ? 'ft' : 'm';

            // optional extended section based on whether or not the server returned
            // specific tile information
            if (!!info.tileInfo) {            
                // either set the tiles based on rows/columns, or specific width/height
                this.tileSize = new OpenLayers.Size(
                    info.tileInfo.width || info.tileInfo.cols, 
                    info.tileInfo.height || info.tileInfo.rows
                );
                
                // this must be set when manually configuring this layer
                this.tileOrigin = new OpenLayers.LonLat(
                    info.tileInfo.origin.x, 
                    info.tileInfo.origin.y
                );

                var upperLeft = new OpenLayers.Geometry.Point(
                    startingTileExtent.left, 
                    startingTileExtent.top
                );
                
                var bottomRight = new OpenLayers.Geometry.Point(
                    startingTileExtent.right, 
                    startingTileExtent.bottom
                );            
                
                if (this.useScales) {
                    this.scales = [];
                } else {
                    this.resolutions = [];
                }
                
                this.lods = [];
                for(var key in info.tileInfo.lods) {
                    if (info.tileInfo.lods.hasOwnProperty(key)) {
                        var lod = info.tileInfo.lods[key];
                        if (this.useScales) {
                            this.scales.push(lod.scale);
                        } else {
                            this.resolutions.push(lod.resolution);
                        }
                    
                        var start = this.getContainingTileCoords(upperLeft, lod.resolution);
                        lod.startTileCol = start.x;
                        lod.startTileRow = start.y;
                    
                        var end = this.getContainingTileCoords(bottomRight, lod.resolution);
                        lod.endTileCol = end.x;
                        lod.endTileRow = end.y;    
                        this.lods.push(lod);
                    }
                }

                this.maxExtent = this.calculateMaxExtentWithLOD(this.lods[0]);
                this.serverResolutions = this.resolutions;
                if (this.overrideDPI && info.tileInfo.dpi) {
                    // see comment above for 'overrideDPI'
                    OpenLayers.DOTS_PER_INCH = info.tileInfo.dpi;
                }
            } 
       }
    }, 

   /** 
    * Method: getContainingTileCoords
    * Calculates the x/y pixel corresponding to the position of the tile
    *     that contains the given point and for the for the given resolution.
    * 
    * Parameters:
    * point - {<OpenLayers.Geometry.Point>} 
    * res - {Float} The resolution for which to compute the extent.
    * 
    * Returns: 
    * {<OpenLayers.Pixel>} The x/y pixel corresponding to the position 
    * of the upper left tile for the given resolution.
    */
    getContainingTileCoords: function(point, res) {
        return new OpenLayers.Pixel(
           Math.max(Math.floor((point.x - this.tileOrigin.lon) / (this.tileSize.w * res)),0),
           Math.max(Math.floor((this.tileOrigin.lat - point.y) / (this.tileSize.h * res)),0)
        );
    },
    
   /** 
    * Method: calculateMaxExtentWithLOD
    * Given a Level of Detail object from the server, this function
    *     calculates the actual max extent
    * 
    * Parameters: 
    * lod - {Object} a Level of Detail Object from the server capabilities object 
            representing a particular zoom level
    * 
    * Returns: 
    * {<OpenLayers.Bounds>} The actual extent of the tiles for the given zoom level
    */
   calculateMaxExtentWithLOD: function(lod) {
        // the max extent we're provided with just overlaps some tiles
        // our real extent is the bounds of all the tiles we touch

        var numTileCols = (lod.endTileCol - lod.startTileCol) + 1;
        var numTileRows = (lod.endTileRow - lod.startTileRow) + 1;        

        var minX = this.tileOrigin.lon + (lod.startTileCol * this.tileSize.w * lod.resolution);
        var maxX = minX + (numTileCols * this.tileSize.w * lod.resolution);

        var maxY = this.tileOrigin.lat - (lod.startTileRow * this.tileSize.h * lod.resolution);
        var minY = maxY - (numTileRows * this.tileSize.h * lod.resolution);
        return new OpenLayers.Bounds(minX, minY, maxX, maxY);
   },
    
   /** 
    * Method: calculateMaxExtentWithExtent
    * Given a 'suggested' max extent from the server, this function uses
    *     information about the actual tile sizes to determine the actual
    *     extent of the layer.
    * 
    * Parameters: 
    * extent - {<OpenLayers.Bounds>} The 'suggested' extent for the layer
    * res - {Float} The resolution for which to compute the extent.
    * 
    * Returns: 
    * {<OpenLayers.Bounds>} The actual extent of the tiles for the given zoom level
    */
   calculateMaxExtentWithExtent: function(extent, res) {
        var upperLeft = new OpenLayers.Geometry.Point(extent.left, extent.top);
        var bottomRight = new OpenLayers.Geometry.Point(extent.right, extent.bottom);
        var start = this.getContainingTileCoords(upperLeft, res);
        var end = this.getContainingTileCoords(bottomRight, res);
        var lod = {
            resolution: res,
            startTileCol: start.x,
            startTileRow: start.y,
            endTileCol: end.x,
            endTileRow: end.y
        };
        return this.calculateMaxExtentWithLOD(lod);
   },
    
    /** 
    * Method: getUpperLeftTileCoord
    * Calculates the x/y pixel corresponding to the position 
    *     of the upper left tile for the given resolution.
    * 
    * Parameters: 
    * res - {Float} The resolution for which to compute the extent.
    * 
    * Returns: 
    * {<OpenLayers.Pixel>} The x/y pixel corresponding to the position 
    * of the upper left tile for the given resolution.
    */
    getUpperLeftTileCoord: function(res) {
        var upperLeft = new OpenLayers.Geometry.Point(
            this.maxExtent.left,
            this.maxExtent.top);
        return this.getContainingTileCoords(upperLeft, res);
    },

    /** 
    * Method: getLowerRightTileCoord
    * Calculates the x/y pixel corresponding to the position 
    *     of the lower right tile for the given resolution.
    *  
    * Parameters: 
    * res - {Float} The resolution for which to compute the extent.
    * 
    * Returns: 
    * {<OpenLayers.Pixel>} The x/y pixel corresponding to the position
    * of the lower right tile for the given resolution.
    */
    getLowerRightTileCoord: function(res) {
        var bottomRight = new OpenLayers.Geometry.Point(
            this.maxExtent.right,
            this.maxExtent.bottom);
        return this.getContainingTileCoords(bottomRight, res);
    },
    
   /** 
    * Method: getMaxExtentForResolution
    * Since the max extent of a set of tiles can change from zoom level
    *     to zoom level, we need to be able to calculate that max extent 
    *     for a given resolution.
    *
    * Parameters: 
    * res - {Float} The resolution for which to compute the extent.
    * 
    * Returns: 
    * {<OpenLayers.Bounds>} The extent for this resolution
    */ 
    getMaxExtentForResolution: function(res) {
        var start = this.getUpperLeftTileCoord(res);
        var end = this.getLowerRightTileCoord(res);

        var numTileCols = (end.x - start.x) + 1;
        var numTileRows = (end.y - start.y) + 1;

        var minX = this.tileOrigin.lon + (start.x * this.tileSize.w * res);
        var maxX = minX + (numTileCols * this.tileSize.w * res);
        
        var maxY = this.tileOrigin.lat - (start.y * this.tileSize.h * res);
        var minY = maxY - (numTileRows * this.tileSize.h * res);
        return new OpenLayers.Bounds(minX, minY, maxX, maxY);
    },
    
   /** 
    * APIMethod: clone 
    * Returns an exact clone of this OpenLayers.Layer.ArcGISCache
    * 
    * Parameters: 
    * [obj] - {Object} optional object to assign the cloned instance to.
    *  
    * Returns: 
    * {<OpenLayers.Layer.ArcGISCache>} clone of this instance 
    */ 
    clone: function (obj) { 
        if (obj == null) { 
            obj = new OpenLayers.Layer.ArcGISCache(this.name, this.url, this.options);
        }
        return OpenLayers.Layer.XYZ.prototype.clone.apply(this, [obj]);
    },

    /**
     * Method: getMaxExtent
     * Get this layer's maximum extent.
     *
     * Returns:
     * {<OpenLayers.Bounds>}
     */
    getMaxExtent: function() {
        var resolution = this.map.getResolution();
        return this.maxExtent = this.getMaxExtentForResolution(resolution);
    },

    /**
     * Method: getTileOrigin
     * Determine the origin for aligning the grid of tiles.  
     *     The origin will be derived from the layer's <maxExtent> property. 
     *
     * Returns:
     * {<OpenLayers.LonLat>} The tile origin.
     */
    getTileOrigin: function() {
        var extent = this.getMaxExtent();
        return new OpenLayers.LonLat(extent.left, extent.bottom);
    },

   /**
    * Method: getURL
    * Determine the URL for a tile given the tile bounds.  This is should support
    *     urls that access tiles through an ArcGIS Server MapServer or directly through
    *     the hex folder structure using HTTP.  Just be sure to set the useArcGISServer
    *     property appropriately!  This is basically the same as 
    *     'OpenLayers.Layer.TMS.getURL',  but with the addition of hex addressing,
    *     and tile rounding.
    *
    * Parameters:
    * bounds - {<OpenLayers.Bounds>}
    *
    * Returns:
    * {String} The URL for a tile based on given bounds.
    */
    getURL: function (bounds) {
        var res = this.getResolution(); 

        // tile center
        var originTileX = (this.tileOrigin.lon + (res * this.tileSize.w/2)); 
        var originTileY = (this.tileOrigin.lat - (res * this.tileSize.h/2));

        var center = bounds.getCenterLonLat();
        var point = { x: center.lon, y: center.lat };
        var x = (Math.round(Math.abs((center.lon - originTileX) / (res * this.tileSize.w)))); 
        var y = (Math.round(Math.abs((originTileY - center.lat) / (res * this.tileSize.h)))); 
        var z = this.map.getZoom();

        // this prevents us from getting pink tiles (non-existant tiles)
        if (this.lods) {        
            var lod = this.lods[this.map.getZoom()];
            if ((x < lod.startTileCol || x > lod.endTileCol) 
                || (y < lod.startTileRow || y > lod.endTileRow)) {
                    return null;
            }
        }
        else {
            var start = this.getUpperLeftTileCoord(res);
            var end = this.getLowerRightTileCoord(res);
            if ((x < start.x || x >= end.x)
                || (y < start.y || y >= end.y)) {
                    return null;
            }        
        }

        // Construct the url string
        var url = this.url;
        var s = '' + x + y + z;

        if (OpenLayers.Util.isArray(url)) {
            url = this.selectUrl(s, url);
        }

        // Accessing tiles through ArcGIS Server uses a different path
        // structure than direct access via the folder structure.
        if (this.useArcGISServer) {
            // AGS MapServers have pretty url access to tiles
            url = url + '/tile/${z}/${y}/${x}';
        } else {
            // The tile images are stored using hex values on disk.
            x = 'C' + this.zeroPad(x, 8, 16);
            y = 'R' + this.zeroPad(y, 8, 16);
            z = 'L' + this.zeroPad(z, 2, 16);
            url = url + '/${z}/${y}/${x}.' + this.type;
        }

        // Write the values into our formatted url
        url = OpenLayers.String.format(url, {'x': x, 'y': y, 'z': z});

        return OpenLayers.Util.urlAppend(
            url, OpenLayers.Util.getParameterString(this.params)
        );
    },

    /**
     * Method: zeroPad
     * Create a zero padded string optionally with a radix for casting numbers.
     *
     * Parameters:
     * num - {Number} The number to be zero padded.
     * len - {Number} The length of the string to be returned.
     * radix - {Number} An integer between 2 and 36 specifying the base to use
     *     for representing numeric values.
     */
    zeroPad: function(num, len, radix) {
        var str = num.toString(radix || 10);
        while (str.length < len) {
            str = "0" + str;
        }
        return str;
    },

    CLASS_NAME: 'OpenLayers.Layer.ArcGISCache' 
}); 
