/*
 
* Copyright (c) 2016 Metron, Inc.
 
* All rights reserved.
 
*
 
* Redistribution and use in source and binary forms, with or without
 
* modification, are permitted provided that the following conditions are met:
 
*
     
* Redistributions of source code must retain the above copyright
 
*
       
notice, this list of conditions and the following disclaimer.
 
*
     
* Redistributions in binary form must reproduce the above copyright
 
*
       
notice, this list of conditions and the following disclaimer in the
 
*
       
documentation and/or other materials provided with the distribution.
 
*
     
* Neither the name of Metron, Inc. nor the
 
*
       
names of its contributors may be used to endorse or promote products
 
*
       
derived from this software without specific prior written permission.
 
*
 
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 
* DISCLAIMED. IN NO EVENT SHALL METRON, INC. BE LIABLE FOR ANY
 
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
*/
package com.metsci.glimpse.charts.slippy;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.imageio.ImageIO;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.metsci.glimpse.support.projection.LatLonProjection;
import com.metsci.glimpse.support.projection.Projection;
import com.metsci.glimpse.support.texture.RGBTextureProjected2D;
import com.metsci.glimpse.util.geo.LatLonGeo;
import com.metsci.glimpse.util.geo.projection.GeoProjection;

/**
 
* An in-memory cache that uses soft references.
 
* If a path is given the images are checked for locally before fetching them from the web. Multiple URLs may be given,
 
* but only one thread will pull from each URL at a given time.
 
* @author oren
 
*
 
*/

public class SlippyCache
{

    
private static final Logger logger = Logger.getLogger( SlippyCache.class.getName( ) );

    
/*
     
* suffix pattern for slippy tiles zoom/x/y.png
     
*/

    
private static final String KEY_PATTERN = "%d/%d/%d.png";

    
/*
     
* Queue of URL prefixes for the tile server. You should only fetch from a single server at a time.
     
*/

    
private final BlockingDeque<String> prefixQueue;

    
/*
     
* Where to store tiles on disk. Set to null to disable disk caching.
     
*/

    
private final Path cacheDir;

    
/*
     
* The in memory cache of textures.
     
*/
    
private final LoadingCache<String, RGBTextureProjected2D> cache;

    
/*
     
* The base geo projection of the map display.
     
*/
    
private final GeoProjection geoProj;

    
/*
     
* Slippy projections for various zoom levels
     
*/
    
private final SlippyProjection[] slippyProj = new SlippyProjection[20];

    
public SlippyCache( GeoProjection geoProj, String urlPrefix )
    
{
        
this( geoProj, Collections.singletonList( urlPrefix ) );
    
}

    
public SlippyCache( GeoProjection geoProj, String urlPrefix, Path cacheDir )
    
{
        
this( geoProj, Collections.singletonList( urlPrefix ), cacheDir );
    
}

    
public SlippyCache( GeoProjection geoProj, List<String> urlPrefixes )
    
{
        
this( geoProj, urlPrefixes, null );
    
}

    
public SlippyCache( GeoProjection geoProj, List<String> urlPrefixes, Path cacheDir )
    
{
        
this.geoProj = geoProj;
        
this.prefixQueue = new LinkedBlockingDeque<>( urlPrefixes.size( ) );
        
if ( urlPrefixes.isEmpty( ) )
        
{
            
throw new IllegalArgumentException( "must supply at least one slippy server" );
        
}
        
else
        
{
            
for ( String prefix : urlPrefixes )
            
{
                
try
                
{
                    
new URL( prefix );
                
}
                
catch ( Exception e )
                
{
                    
throw new IllegalArgumentException( prefix + " is not a valid url" );
                
}
                
prefixQueue.add( prefix + ( prefix.endsWith( "/" ) ? "" : "/" ) );
            
}
        
}

        
if ( cacheDir != null )
        
{
            
if ( Files.isDirectory( cacheDir ) )
            
{
                
this.cacheDir = cacheDir;
            
}
            
else if ( !Files.exists( cacheDir ) )
            
{
                
try
                
{
                    
Files.createDirectories( cacheDir );
                
}
                
catch ( Exception e )
                
{
                    
String msg = "Failed to created directory for disk cache: " + cacheDir.toAbsolutePath( ).toString( );
                    
logger.log( Level.WARNING, msg, e );
                
}
                
if ( Files.isDirectory( cacheDir ) )
                
{
                    
this.cacheDir = cacheDir;
                
}
                
else
                
{
                    
this.cacheDir = null;
                
}
            
}
            
else
            
{
                
throw new IllegalArgumentException( "specified cache directory (" + cacheDir.toString( ) + ")is a file" );
            
}
        
}
        
else
        
{
            
this.cacheDir = null;
        
}

        
for ( int zoom = 0; zoom < slippyProj.length; zoom++ )
        
{
            
this.slippyProj[zoom] = new SlippyProjection( zoom );
        
}

        
this.cache = CacheBuilder.newBuilder( )
                
.concurrencyLevel( urlPrefixes.size( ) )
                
.softValues( )
                
.build( new SlippyLoader( ) );
    
}

    
public RGBTextureProjected2D getTexture( int zoom, int x, int y )
    
{
        
String key = String.format( KEY_PATTERN, zoom, x, y );
        
RGBTextureProjected2D tex = null;
        
try
        
{
            
tex = cache.get( key );
        
}
        
catch ( Exception e )
        
{
            
logger.log( Level.WARNING, "Failed to get texture from cache", e );
        
}
        
return tex;
    
}

    
public RGBTextureProjected2D getTextureIfPresent( int zoom, int x, int y )
    
{
        
String key = String.format( KEY_PATTERN, zoom, x, y );
        
RGBTextureProjected2D tex = null;
        
try
        
{
            
tex = cache.getIfPresent( key );
        
}
        
catch ( Exception e )
        
{
            
logger.log( Level.WARNING, "Failed to get texture from cache (if present)", e );
        
}
        
return tex;
    
}

    
private class SlippyLoader extends CacheLoader<String, RGBTextureProjected2D>
    
{
        
@Override
        
public RGBTextureProjected2D load( String key ) throws Exception
        
{
            
BufferedImage img = fetchFromDisk( key );
            
//if we were able to get the image from disk, return it
            
if ( img == null )
            
{
                
img = fetchFromWeb( key );
            
}
            
return makeTex( key, img );
        
}

        
private BufferedImage fetchFromWeb( String key )
        
{
            
BufferedImage img = null;
            
//Now try to pull the image from the web
            
String prefix = null;
            
try
            
{
                
prefix = prefixQueue.take( );
                
String urlStr = prefix + key;
                
try
                
{
                    
img = ImageIO.read( new URL( urlStr ) );
                
}
                
catch ( IOException e )
                
{
                    
logger.log( Level.WARNING, "Exception fetching tile from the web", e );
                
}
                
//If we got an image, try to cache it to disk
                
try
                
{
                    
saveToDisk( key, img );
                
}
                
catch ( IOException e )
                
{
                    
logger.log( Level.WARNING, "Exception saving tile to disk", e );
                
}
            
}
            
catch ( InterruptedException e )
            
{
                
logger.log( Level.WARNING, "Interrupted while getting a URL", e );
            
}
            
finally
            
{
                
if ( prefix != null )
                
{
                    
try
                    
{
                        
prefixQueue.put( prefix );
                    
}
                    
catch ( InterruptedException e )
                    
{
                        
logger.log( Level.WARNING, "Interrupted while putting a URL back on the queue", e );
                    
}
                
}
            
}
            
return img;
        
}

        
private void saveToDisk( String key, BufferedImage img ) throws IOException
        
{
            
if ( img != null && cacheDir != null )
            
{
                
Path imgPath = cacheDir.resolve( key );
                
if ( !Files.exists( imgPath.getParent( ) ) )
                
{
                    
try
                    
{
                        
Files.createDirectories( imgPath.getParent( ) );
                    
}
                    
catch ( Exception e )
                    
{
                        
//the img write should fail anyway
                    
}
                
}
                
ImageIO.write( img, "PNG", imgPath.toFile( ) );
            
}
        
}

        
private BufferedImage fetchFromDisk( String key )
        
{
            
BufferedImage img = null;
            
if ( cacheDir != null )
            
{
                
Path imgPath = cacheDir.resolve( key );
                
if ( Files.exists( imgPath ) )
                
{
                    
try
                    
{
                        
img = ImageIO.read( imgPath.toFile( ) );
                    
}
                    
catch ( Exception e )
                    
{
                        
logger.log( Level.WARNING, "Exception while attempting to read the tile from disk", e );
                    
}
                
}
            
}
            
return img;
        
}
    
}

    
private RGBTextureProjected2D makeTex( String key, BufferedImage img )
    
{
        
if ( img == null )
        
{
            
return null;
        
}
        
RGBTextureProjected2D tex = new RGBTextureProjected2D( img );
        
String[] parts = key.substring( 0, key.length( ) - 4 ).split( "/" );
        
int zoom = Integer.parseInt( parts[0] );
        
int x = Integer.parseInt( parts[1] );
        
int y = Integer.parseInt( parts[2] );
        
tex.setProjection( getProjection( zoom, x, y ) );
        
return tex;
    
}

    
private Projection getProjection( int zoom, int x, int y )
    
{
        
LatLonGeo nw = slippyProj[zoom].unproject( x, y );
        
LatLonGeo se = slippyProj[zoom].unproject( x + 1, y + 1 );
        
double minLat = se.getLatDeg( );
        
double maxLat = nw.getLatDeg( );
        
double minLon = nw.getLonDeg( );
        
double maxLon = se.getLonDeg( );
        
return new LatLonProjection( geoProj, minLat, maxLat, minLon, maxLon, false );
    
}
}