/*
Copyright (C) 2001, 2006 United States Government
as represented by the Administrator of the
National Aeronautics and Space Administration.
All Rights Reserved.
*/
package gov.nasa.worldwind;

import gov.nasa.worldwind.geom.*;

import java.net.*;
import java.nio.*;
import java.io.*;

// Implementation notes, not for API doc:
//
// Implements an elevation model based on a quad tree of elevation tiles. Meant to be subclassed by very specific
// classes, e.g. Earth/SRTM. A Descriptor passed in at construction gives the configuration parameters. Eventually
// Descriptor will be replaced by an XML configuration document.
//
// A "tile" corresponds to one tile of the data set, which has a corresponding unique row/column address in the data
// set. An inner class implements Tile. An inner class also implements TileKey, which is used to address the
// corresponding Tile in the memory cache.

// Clients of this class get elevations from it by first getting an Elevations object for a specific Sector, then
// querying that object for the elevation at individual lat/lon positions. The Elevations object captures information
// that is used to compute elevations. See in-line comments for a description.
//
// When an elevation tile is needed but is not in memory, a task is threaded off to find it. If it's in the file cache
// then it's loaded by the task into the memory cache. If it's not in the file cache then a retrieval is initiated by
// the task. The disk is never accessed during a call to getElevations(sector, resolution) because that method is
// likely being called when a frame is being rendered. The details of all this are in-line below.

/**
 * This class represents a single tile in the data set and contains the information that needs to be cached.
 *
 * @author Tom Gaskins
 * @version $Id: BasicElevationModel.java 1732 2007-05-05 07:47:37Z tgaskins $
 */
public class BasicElevationModel extends WWObjectImpl implements ElevationModel
{
    private boolean isEnabled = true;
    private final LevelSet levels;
    private final double minElevation;
    private final double maxElevation;
    private long numExpectedValues = 0;
    private final Object fileLock = new Object();
    private java.util.concurrent.ConcurrentHashMap<TileKey, Tile> levelZeroTiles =
        new java.util.concurrent.ConcurrentHashMap<TileKey, Tile>();
    private gov.nasa.worldwind.MemoryCache memoryCache = new gov.nasa.worldwind.BasicMemoryCache(8000000, 10000000);

    private static final class Tile extends gov.nasa.worldwind.Tile implements Cacheable
    {
        private java.nio.ShortBuffer elevations; // the elevations themselves

        private Tile(Sector sector, Level level, int row, int col)
        {
            super(sector, level, row, col);
        }
    }

    /**
     * @param levels
     * @param minElevation
     * @param maxElevation
     * @throws IllegalArgumentException if <code>levels</code> is null or invalid
     */
    public BasicElevationModel(LevelSet levels, double minElevation, double maxElevation)
    {
        if (levels == null)
        {
            String message = WorldWind.retrieveErrMsg("nullValue.LevelSetIsNull");
            WorldWind.logger().log(java.util.logging.Level.FINE, message);
            throw new IllegalArgumentException(message);
        }

        this.levels = new LevelSet(levels); // the caller's levelSet may change internally, so we copy it.
        this.minElevation = minElevation;
        this.maxElevation = maxElevation;
    }

    public boolean isEnabled()
    {
        return this.isEnabled;
    }

    public void setEnabled(boolean enabled)
    {
        this.isEnabled = enabled;
    }

    public LevelSet getLevels()
    {
        return this.levels;
    }

    public final double getMaximumElevation()
    {
        return this.maxElevation;
    }

    public final double getMinimumElevation()
    {
        return this.minElevation;
    }

    public long getNumExpectedValuesPerTile()
    {
        return numExpectedValues;
    }

    public void setNumExpectedValuesPerTile(long numExpectedValues)
    {
        this.numExpectedValues = numExpectedValues;
    }

    // Create the tile corresponding to a specified key.
    private Tile createTile(TileKey key)
    {
        Level level = this.levels.getLevel(key.getLevelNumber());

        // Compute the tile's SW lat/lon based on its row/col in the level's data set.
        Angle dLat = level.getTileDelta().getLatitude();
        Angle dLon = level.getTileDelta().getLongitude();

        Angle minLatitude = Tile.computeRowLatitude(key.getRow(), dLat);
        Angle minLongitude = Tile.computeColumnLongitude(key.getColumn(), dLon);

        Sector tileSector = new Sector(minLatitude, minLatitude.add(dLat), minLongitude, minLongitude.add(dLon));

        return new Tile(tileSector, level, key.getRow(), key.getColumn());
    }

    // Thread off a task to determine whether the file is local or remote and then retrieve it either from the file
    // cache or a remote server.
    private void requestTile(TileKey key)
    {
        if (WorldWind.threadedTaskService().isFull())
            return;

        RequestTask request = new RequestTask(key, this);
        WorldWind.threadedTaskService().addTask(request);
    }

    private static class RequestTask implements Runnable
    {
        private final BasicElevationModel elevationModel;
        private final TileKey tileKey;

        private RequestTask(TileKey tileKey, BasicElevationModel elevationModel)
        {
            this.elevationModel = elevationModel;
            this.tileKey = tileKey;
        }

        public final void run()
        {
            // check to ensure load is still needed
            if (this.elevationModel.areElevationsInMemory(this.tileKey))
                return;

            Tile tile = this.elevationModel.createTile(this.tileKey);
            final java.net.URL url = WorldWind.dataFileCache().findFile(tile.getPath(), false);
            if (url != null)
            {
                if (this.elevationModel.loadElevations(tile, url))
                {
                    this.elevationModel.levels.unmarkResourceAbsent(tile);
                    this.elevationModel.firePropertyChange(AVKey.ELEVATION_MODEL, null, this);
                    return;
                }
                else
                {
                    // Assume that something's wrong with the file and delete it.
                    gov.nasa.worldwind.WorldWind.dataFileCache().removeFile(url);
                    this.elevationModel.levels.markResourceAbsent(tile);
                    String message = WorldWind.retrieveErrMsg("generic.DeletedCorruptDataFile") + url;
                    WorldWind.logger().log(java.util.logging.Level.FINE, message);
                }
            }

            this.elevationModel.downloadElevations(tile);
        }

        public final boolean equals(Object o)
        {
            if (this == o)
                return true;
            if (o == null || getClass() != o.getClass())
                return false;

            final RequestTask that = (RequestTask) o;

            //noinspection RedundantIfStatement
            if (this.tileKey != null ? !this.tileKey.equals(that.tileKey) : that.tileKey != null)
                return false;

            return true;
        }

        public final int hashCode()
        {
            return (this.tileKey != null ? this.tileKey.hashCode() : 0);
        }

        public final String toString()
        {
            return this.tileKey.toString();
        }
    }

    // Reads a tile's elevations from the file cache and adds the tile to the memory cache.
    private boolean loadElevations(Tile tile, java.net.URL url)
    {
        java.nio.ShortBuffer elevations = this.readElevations(url);
        if (elevations == null)
            return false;

        if (this.numExpectedValues > 0 && elevations.capacity() != this.numExpectedValues)
            return false; // corrupt file

        tile.elevations = elevations;
        this.addTileToCache(tile, elevations);

        return true;
    }

    private void addTileToCache(Tile tile, java.nio.ShortBuffer elevations)
    {
        // Level 0 tiles are held in the model itself; other levels are placed in the memory cache.
        if (tile.getLevelNumber() == 0)
            this.levelZeroTiles.putIfAbsent(tile.getTileKey(), tile);
        else
            this.memoryCache.add(tile.getTileKey(), tile, elevations.limit() * 2);
    }

    private boolean areElevationsInMemory(TileKey key)
    {
        Tile tile = this.getTileFromMemory(key);
        return (tile != null && tile.elevations != null);
    }

    private Tile getTileFromMemory(TileKey tileKey)
    {
        if (tileKey.getLevelNumber() == 0)
            return this.levelZeroTiles.get(tileKey);
        else
            return (Tile) this.memoryCache.getObject(tileKey);
    }

    // Read elevations from the file cache. Don't be confused by the use of a URL here: it's used so that files can
    // be read using System.getResource(URL), which will draw the data from a jar file in the classpath.
    // TODO: Look into possibly moving the mapping to a URL into WWIO.
    private java.nio.ShortBuffer readElevations(java.net.URL url)
    {
        try
        {
            java.nio.ByteBuffer buffer;
            synchronized (this.fileLock)
            {
                buffer = gov.nasa.worldwind.WWIO.readURLContentToBuffer(url);
            }
            buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN); // TODO: byte order is format dependent
            return buffer.asShortBuffer();
        }
        catch (java.io.IOException e)
        {
            String message = WorldWind.retrieveErrMsg("TiledElevationModel.ExceptionAttemptingToReadTextureFile");
            WorldWind.logger().log(java.util.logging.Level.FINE, message + url);
            return null;
        }
    }

    private void downloadElevations(final Tile tile)
    {
        if (WorldWind.retrievalService().isFull())
            return;

        java.net.URL url = null;
        try
        {
            url = tile.getResourceURL();
        }
        catch (java.net.MalformedURLException e)
        {
            String message = WorldWind.retrieveErrMsg("TiledElevationModel.ExceptionCreatingElevationsUrl");
            WorldWind.logger().log(java.util.logging.Level.FINE, message + url, e);
            return;
        }

        URLRetriever retriever = new HTTPRetriever(url, new DownloadPostProcessor(tile, this));
        if (WorldWind.retrievalService().contains(retriever))
            return;

        WorldWind.retrievalService().runRetriever(retriever, 0d);
    }

    /**
     * @param dc
     * @param sector
     * @param density
     * @return
     * @throws IllegalArgumentException if <code>dc</code> is null, <code>sector</code> is null or <code>density is
     *                                  negative
     */
    public final int getTargetResolution(DrawContext dc, Sector sector, int density)
    {
        if (!this.isEnabled)
            return 0;

        if (dc == null)
        {
            String msg = WorldWind.retrieveErrMsg("nullValue.DrawContextIsNull");
            WorldWind.logger().log(java.util.logging.Level.FINE, msg);
            throw new IllegalArgumentException(msg);
        }
        if (sector == null)
        {
            String msg = WorldWind.retrieveErrMsg("nullValue.SectorIsNull");
            WorldWind.logger().log(java.util.logging.Level.FINE, msg);
            throw new IllegalArgumentException(msg);
        }
        if (density < 0)
        {
            String msg = WorldWind.retrieveErrMsg("BasicElevationModel.DensityBelowZero");
            WorldWind.logger().log(java.util.logging.Level.FINEST, msg);
        }

        LatLon c = this.levels.getSector().getCentroid();
        double radius = dc.getGlobe().getRadiusAt(c.getLatitude(), c.getLongitude());
        double sectorWidth = sector.getDeltaLatRadians() * radius;
        double targetSize = 0.8 * sectorWidth / (density); // TODO: make scale of density configurable

        for (Level level : this.levels.getLevels())
        {
            if (level.getTexelSize(radius) < targetSize)
            {
                return level.getLevelNumber();
            }
        }

        return this.levels.getNumLevels(); // finest resolution available
    }

    private static class DownloadPostProcessor implements RetrievalPostProcessor
    {
        private Tile tile;
        private BasicElevationModel elevationModel;

        public DownloadPostProcessor(Tile tile, BasicElevationModel elevationModel)
        {
            // don't validate - constructor is only available to classes with private access.
            this.tile = tile;
            this.elevationModel = elevationModel;
        }

        /**
         * @param retriever
         * @return
         * @throws IllegalArgumentException if <code>retriever</code> is null
         */
        public ByteBuffer run(Retriever retriever)
        {
            if (retriever == null)
            {
                String msg = WorldWind.retrieveErrMsg("nullValue.RetrieverIsNull");
                WorldWind.logger().log(java.util.logging.Level.FINE, msg);
                throw new IllegalArgumentException(msg);
            }

            try
            {
                if (!retriever.getState().equals(Retriever.RETRIEVER_STATE_SUCCESSFUL))
                    return null;

                if (retriever instanceof HTTPRetriever)
                {
                    HTTPRetriever htr = (HTTPRetriever) retriever;
                    if (htr.getResponseCode() != HttpURLConnection.HTTP_OK)
                    {
                        // Mark tile as missing so avoid excessive attempts
                        this.elevationModel.levels.markResourceAbsent(this.tile);
                        return null;
                    }
                }

                URLRetriever r = (URLRetriever) retriever;
                ByteBuffer buffer = r.getBuffer();

                final File outFile = WorldWind.dataFileCache().newFile(tile.getPath());
                if (outFile == null)
                {
                    String msg = WorldWind.retrieveErrMsg("generic.CantCreateCacheFile")
                        + this.tile.getPath();
                    WorldWind.logger().log(java.util.logging.Level.FINE, msg);
                    return null;
                }

                if (outFile.exists())
                    return buffer;

                if (buffer != null)
                {
                    synchronized (elevationModel.fileLock)
                    {
                        WWIO.saveBuffer(buffer, outFile);
                    }
                    return buffer;
                }
            }
            catch (java.io.IOException e)
            {
                String message = WorldWind.retrieveErrMsg("TiledElevationModel.ExceptionSavingRetrievedElevationFile");
                WorldWind.logger().log(java.util.logging.Level.FINE, message + tile.getPath(), e);
            }
            finally
            {
                this.elevationModel.firePropertyChange(AVKey.ELEVATION_MODEL, null, this);
            }
            return null;
        }
    }

    private static class BasicElevations implements ElevationModel.Elevations
    {
        private final int resolution;
        private final Sector sector;
        private final BasicElevationModel elevationModel;
        private java.util.Set<Tile> tiles;

        private BasicElevations(Sector sector, int resolution, BasicElevationModel elevationModel)
        {
            this.sector = sector;
            this.resolution = resolution;
            this.elevationModel = elevationModel;
        }

        public int getResolution()
        {
            return this.resolution;
        }

        public Sector getSector()
        {
            return this.sector;
        }

        public boolean hasElevations()
        {
            return this.tiles != null && this.tiles.size() > 0;
        }

        public double getElevation(double latRadians, double lonRadians)
        {
            if (this.tiles == null)
                return 0;

            try
            {
                // TODO: Tiles are sorted by level/row/column. Use that to find containing sector faster.
                for (BasicElevationModel.Tile tile : this.tiles)
                {
                    if (tile.getSector().containsRadians(latRadians, lonRadians))
                        return this.elevationModel.lookupElevation(latRadians, lonRadians, tile);
                }

                return 0;
            }
            catch (Exception e)
            {
                // Throwing an exception within what's likely to be the caller's geometry creation loop
                // would be hard to recover from, and a reasonable response to the exception can be done here.
                String message = WorldWind.retrieveErrMsg("BasicElevationModel.ExceptionComputingElevation");
                message += "(" + latRadians + ", " + lonRadians + ")";
                WorldWind.logger().log(java.util.logging.Level.FINE, message, e);

                return 0;
            }
        }
    }

    /**
     * @param latitude
     * @param longitude
     * @return
     * @throws IllegalArgumentException if <code>latitude</code> or <code>longitude</code> is null
     */
    public final double getElevation(Angle latitude, Angle longitude)
    {
        if (!this.isEnabled())
            return 0;

        if (latitude == null || longitude == null)
        {
            String msg = WorldWind.retrieveErrMsg("nullValue.AngleIsNull");
            WorldWind.logger().log(java.util.logging.Level.FINE, msg);
            throw new IllegalArgumentException(msg);
        }

        // TODO: Make level to draw elevations from configurable
        final TileKey tileKey = new TileKey(latitude, longitude, this.levels.getLastLevel());
        Tile tile = this.getTileFromMemory(tileKey);

        if (tile == null)
        {
            int fallbackRow = tileKey.getRow();
            int fallbackCol = tileKey.getColumn();
            for (int fallbackLevelNum = tileKey.getLevelNumber() - 1; fallbackLevelNum >= 0; fallbackLevelNum--)
            {
                fallbackRow /= 2;
                fallbackCol /= 2;
                TileKey fallbackKey = new TileKey(fallbackLevelNum, fallbackRow, fallbackCol,
                    this.levels.getLevel(fallbackLevelNum).getCacheName());
                tile = this.getTileFromMemory(fallbackKey);
                if (tile != null)
                    break;
            }
        }

        if (tile == null)
        {
            final TileKey zeroKey = new TileKey(latitude, longitude, this.levels.getFirstLevel());
            this.requestTile(zeroKey);
            
            return 0;
        }

        return this.lookupElevation(latitude.radians, longitude.radians, tile);
    }

    /**
     * @param sector
     * @param resolution
     * @return
     * @throws IllegalArgumentException if <code>sector</code> is null
     */
    public final Elevations getElevations(Sector sector, int resolution)
    {
        if (sector == null)
        {
            String msg = WorldWind.retrieveErrMsg("nullValue.SectorIsNull");
            WorldWind.logger().log(java.util.logging.Level.FINE, msg);
            throw new IllegalArgumentException(msg);
        }

        if (!this.isEnabled())
            return new BasicElevations(sector, Integer.MIN_VALUE, this);

        // Collect all the elevation tiles intersecting the input sector. If a desired tile is not curently
        // available, choose its next lowest resolution parent that is available.
        final Level targetLevel = this.levels.getLevel(resolution);

        final TileKey keyNW = new TileKey(sector.getMaxLatitude(), sector.getMinLongitude(), this.levels.getLevel(
            resolution));
        final TileKey keySE = new TileKey(sector.getMinLatitude(), sector.getMaxLongitude(), this.levels.getLevel(
            resolution));

        java.util.TreeSet<Tile> tiles = new java.util.TreeSet<Tile>();
        java.util.ArrayList<TileKey> requested = new java.util.ArrayList<TileKey>();

        boolean missingTargetTiles = false;
        boolean missingLevelZeroTiles = false;
        for (int row = keySE.getRow(); row <= keyNW.getRow(); row++)
        {
            for (int col = keyNW.getColumn(); col <= keySE.getColumn(); col++)
            {
                TileKey key = new TileKey(resolution, row, col, targetLevel.getCacheName());
                Tile tile = this.getTileFromMemory(key);
                if (tile != null)
                {
                    tiles.add(tile);
                    continue;
                }

                missingTargetTiles = true;
                this.requestTile(key);

                // Determine the fallback to use. Simultaneously determine a fallback to request that is
                // the next resolution higher than the fallback chosen, if any. This will progressively
                // refine the display until the desired resolution tile arrives.
                TileKey fallbackToRequest = null;
                TileKey fallbackKey = null;

                int fallbackRow = row;
                int fallbackCol = col;
                for (int fallbackLevelNum = key.getLevelNumber() - 1; fallbackLevelNum >= 0; fallbackLevelNum--)
                {
                    fallbackRow /= 2;
                    fallbackCol /= 2;
                    fallbackKey = new TileKey(fallbackLevelNum, fallbackRow, fallbackCol, this.levels.getLevel(
                        fallbackLevelNum).getCacheName());
                    tile = this.getTileFromMemory(fallbackKey);
                    if (tile != null)
                    {
                        if (!tiles.contains(tile))
                            tiles.add(tile);
                        break;
                    }
                    else
                    {
                        if (fallbackLevelNum == 0)
                            missingLevelZeroTiles = true;
                        fallbackToRequest = fallbackKey; // keep track of lowest level to request
                    }
                }

                if (fallbackToRequest != null)
                {
                    if (!requested.contains(fallbackKey))
                    {
                        this.requestTile(fallbackKey);
                        requested.add(fallbackKey); // keep track to avoid overhead of duplicte requests
                    }
                }
            }
        }

        BasicElevations elevations;

        if (missingLevelZeroTiles || tiles.isEmpty())
        {
            // Integer.MIN_VALUE is a signal for no in-memory tile for a given region of the sector.
            elevations = new BasicElevations(sector, Integer.MIN_VALUE, this);
        }
        else if (missingTargetTiles)
        {
            // Use the level of the the lowest resolution found as the resolution for this elevation set.
            // The list of tiles is sorted first by level, so use the level of the list's first entry.
            elevations = new BasicElevations(sector, tiles.first().getLevelNumber(), this);
        }
        else
        {
            elevations = new BasicElevations(sector, resolution, this);
        }

        elevations.tiles = tiles;

        return elevations;
    }

    private double lookupElevation(final double latRadians, final double lonRadians, final Tile tile)
    {
        Sector sector = tile.getSector();
        final int tileHeight = tile.getLevel().getTileHeight();
        final int tileWidth = tile.getLevel().getTileWidth();
        final double sectorDeltaLat = sector.getDeltaLat().radians;
        final double sectorDeltaLon = sector.getDeltaLon().radians;
        final double dLat = sector.getMaxLatitude().radians - latRadians;
        final double dLon = lonRadians - sector.getMinLongitude().radians;
        final double sLat = dLat / sectorDeltaLat;
        final double sLon = dLon / sectorDeltaLon;

        int j = (int) ((tileHeight - 1) * sLat);
        int i = (int) ((tileWidth - 1) * sLon);
        int k = j * tileWidth + i;

        double eLeft = tile.elevations.get(k);
        double eRight = i < (tileWidth - 1) ? tile.elevations.get(k + 1) : eLeft;

        double dw = sectorDeltaLon / (tileWidth - 1);
        double dh = sectorDeltaLat / (tileHeight - 1);
        double ssLon = (dLon - i * dw) / dw;
        double ssLat = (dLat - j * dh) / dh;

        double eTop = eLeft + ssLon * (eRight - eLeft);

        if (j < tileHeight - 1 && i < tileWidth - 1)
        {
            eLeft = tile.elevations.get(k + tileWidth);
            eRight = tile.elevations.get(k + tileWidth + 1);
        }

        double eBot = eLeft + ssLon * (eRight - eLeft);
        return eTop + ssLat * (eBot - eTop);
    }
}
