/*
 
* 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.nio.file.Path;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.metsci.glimpse.axis.Axis2D;
import com.metsci.glimpse.context.GlimpseContext;
import com.metsci.glimpse.gl.texture.DrawableTexture;
import com.metsci.glimpse.painter.base.GlimpsePainterBase;
import com.metsci.glimpse.painter.texture.ShadedTexturePainter;
import com.metsci.glimpse.painter.texture.TextureUnit;
import com.metsci.glimpse.support.projection.LatLonProjection;
import com.metsci.glimpse.support.projection.Projection;
import com.metsci.glimpse.support.shader.triangle.ColorTexture2DProgram;
import com.metsci.glimpse.support.texture.RGBTextureProjected2D;
import com.metsci.glimpse.support.texture.TextureProjected2D;
import com.metsci.glimpse.util.geo.LatLonGeo;
import com.metsci.glimpse.util.geo.projection.GeoProjection;
import com.metsci.glimpse.util.vector.Vector2d;

/**
 
* Paints the slippy tiles. New tiles are fetched dynamically as the user zooms/pans and stale textures are removed.
 
* @author oren
 
*/

public class SlippyMapTilePainter extends ShadedTexturePainter
{
    
private static final Logger logger = Logger.getLogger( SlippyCache.class.getName( ) );
    
private static final double LOG2 = Math.log( 2 );

    
private final GeoProjection geoProj;
    
private final int maxZoom;
    
private final SlippyProjection[] slippyProj;
    
private final ExecutorService exec;
    
private final SlippyCache cache;

    
/**
     
* These are checked during the asynchronous texture fetch to see if we still need to fetch it,
     
* and after we fetch it if we still need to display it.
     
*/

    
private final AtomicReference<double[]> lastBounds = new AtomicReference<>( );
    
private final AtomicInteger lastZoom = new AtomicInteger( );

    
/**
     
* We need to know what zoom level each texture lives at so we can remove stale textures
     
* when the zoom level changes.
     
*/

    
private final ConcurrentHashMap<RGBTextureProjected2D, Integer> texZoomMap = new ConcurrentHashMap<>( );

    
public SlippyMapTilePainter( GeoProjection geoProj, List<String> prefixes, ExecutorService exec, Path cacheDir, int maxZoom )
    
{
        
this.geoProj = geoProj;
        
this.cache = new SlippyCache( geoProj, prefixes, cacheDir );
        
this.maxZoom = maxZoom;
        
this.slippyProj = new SlippyProjection[maxZoom + 1];
        
for ( int zoom = 0; zoom <= maxZoom; zoom++ )
        
{
            
this.slippyProj[zoom] = new SlippyProjection( zoom );
        
}
        
this.exec = exec;
        
this.setProgram( new ColorTexture2DProgram( ) );
    
}
    

    
@Override
    
public void doPaintTo( GlimpseContext context )
    
{
        
updateTiles( GlimpsePainterBase.getAxis2D( context ) );
        
super.doPaintTo( context );
    
}

    
protected void updateTiles( Axis2D axis )
    
{
        
int xPix = axis.getAxisX( ).getSizePixels( );
        
if ( !isVisible( ) || xPix <= 0 )
        
{
            
return;
        
}

        
double minx = axis.getMinX( );
        
double maxx = axis.getMaxX( );
        
double maxy = axis.getMaxY( );
        
double miny = axis.getMinY( );
        
double[] bounds = new double[] { minx, maxx, miny, maxy };
        
if ( Arrays.equals( bounds, lastBounds.get( ) ) )
        
{
            
return;
        
}
        
lastBounds.set( bounds );

        
double xTileDim = xPix / 256.;

        
LatLonGeo ne = geoProj.unproject( maxx, maxy );
        
LatLonGeo sw = geoProj.unproject( minx, miny );

        
double east = ne.getLonDeg( );
        
double west = sw.getLonDeg( );
        
double lonSizeDeg = ( east - west ) / xTileDim;

        
double zoomApprox = Math.log( 360 / lonSizeDeg ) / LOG2;
        
final int zoom = ( int ) Math.min( Math.round( zoomApprox ), maxZoom );
        
lastZoom.set( zoom );

        
Vector2d tileNE = slippyProj[zoom].project( ne );
        
Vector2d tileSW = slippyProj[zoom].project( sw );

        
final int tileYmin = ( int ) Math.floor( tileNE.getY( ) );
        
final int tileYmax = ( int ) Math.ceil( tileSW.getY( ) );
        
final int tileXmin = ( int ) Math.floor( tileSW.getX( ) );
        
final int tileXmax = ( int ) Math.ceil( tileNE.getX( ) );

        
painterLock.lock( );
        
try
        
{
            
for ( int y = tileYmin; y < tileYmax; y++ )
            
{
                
for ( int x = tileXmin; x < tileXmax; x++ )
                
{
                    
RGBTextureProjected2D tex = cache.getTextureIfPresent( zoom, x, y );
                    
if ( tex != null && drawableTextures.containsKey( new TextureUnit<DrawableTexture>( tex ) ) )
                    
{
                        
continue;
                    
}
                    
exec.submit( new FetchTexture( zoom, x, y ) );
                
}
            
}

            
Iterator<TextureUnit<DrawableTexture>> itr = drawableTextures.keySet( ).iterator( );
            
while ( itr.hasNext( ) )
            
{
                
TextureUnit<DrawableTexture> texUnit = itr.next( );
                
TextureProjected2D tex = ( TextureProjected2D ) texUnit.getTexture( );
                
double[] texBounds = getBounds( tex.getProjection( ) );
                
int texZoom = texZoomMap.get( tex );
                
if ( texZoom != zoom || !intersect( bounds, texBounds ) )
                
{
                    
itr.remove( );
                    
texZoomMap.remove( tex );
                
}
            
}
        
}
        
finally
        
{
            
painterLock.unlock( );
        
}
    
}

    
private double[] getBounds( final Projection proj )
    
{
        
float[] min = new float[2];
        
float[] max = new float[2];
        
proj.getVertexXY( 0, 0, min );
        
proj.getVertexXY( 1, 1, max );
        
return new double[] { min[0], max[0], min[1], max[1] };
    
}

    
private double[] getBounds( int zoom, int tileX, int tileY )
    
{
        
return getBounds( getProjection( zoom, tileX, tileY ) );
    
}

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

    
private static boolean intersect( final double[] outerBounds, final double[] bounds )
    
{
        
return contains( outerBounds, bounds[0], bounds[2] )
                
|| contains( outerBounds, bounds[0], bounds[3] )
                
|| contains( outerBounds, bounds[1], bounds[2] )
                
|| contains( outerBounds, bounds[1], bounds[3] )
                
|| contains( bounds, outerBounds[0], outerBounds[2] )
                
|| contains( bounds, outerBounds[0], outerBounds[3] )
                
|| contains( bounds, outerBounds[1], outerBounds[2] )
                
|| contains( bounds, outerBounds[1], outerBounds[3] );
    
}

    
private static boolean contains( final double[] bounds, final double x, final double y )
    
{
        
return ! ( x < bounds[0] || bounds[1] < x || y < bounds[2] || bounds[3] < y );
    
}

    
private final class FetchTexture implements Runnable
    
{
        
private final int zoom;
        
private final int x;
        
private final int y;

        
private FetchTexture( int zoom, int x, int y )
        
{
            
this.zoom = zoom;
            
this.x = x;
            
this.y = y;
        
}

        
@Override
        
public void run( )
        
{
            
final double[] bounds = getBounds( zoom, x, y );
            
try
            
{
                
RGBTextureProjected2D tex = null;
                
int zoomCheck = lastZoom.get( );
                
double[] boundsCheck = lastBounds.get( );
                
if ( zoom == zoomCheck && intersect( boundsCheck, bounds ) )
                
{
                    
tex = cache.getTexture( zoom, x, y );
                
}
                
if ( tex == null )
                
{
                    
return;
                
}
                
zoomCheck = lastZoom.get( );
                
boundsCheck = lastBounds.get( );
                
if ( zoom == zoomCheck && intersect( boundsCheck, bounds ) )
                
{
                    
painterLock.lock( );
                    
try
                    
{
                        
addDrawableTexture( tex );
                        
texZoomMap.put( tex, zoom );
                    
}
                    
finally
                    
{
                        
painterLock.unlock( );
                    
}
                
}
            
}
            
catch ( Exception e )
            
{
                
logger.log( Level.WARNING, "Exception in tile fetching thread", e );
            
}
            
finally
            
{
                
//nothing?
            
}
        
}
    
}
}