package nxm.sys.libg;

import java.awt.Color;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.awt.image.ImageObserver;
import java.awt.image.PixelGrabber;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
import java.util.WeakHashMap;

import javax.imageio.ImageIO;

import nxm.sys.inc.InternalUseOnly;
import nxm.sys.inc.PlotFile;
import nxm.sys.inc.ProvisionalUseOnly;
import nxm.sys.lib.Args;
import nxm.sys.lib.Cache;
import nxm.sys.lib.Midas;
import nxm.sys.lib.MidasException;
import nxm.sys.lib.Parser;
import nxm.sys.lib.Shell;
import nxm.sys.lib.Table;
import nxm.sys.lib.Time;

/** A class used to plot Maps. This is an abstract class that handles the basic map requests and
    rendering but is extended by classes that handle obtaining the data. This allows various map
    services to share rendering facilities although they have unique sources of the map data.

    @author  J. Czechowski IV
    @author  Neon Ngo 2010-01-18: Added map server connection and read timeout, fixed Xlint warnings, and
                                  added Time.sleep(pause) in downloadImage() to not overload server
                      2010-07-29: In setURL(..), call setNeedsRescale(true) so that a LayerNMS by
                                  itself will get the proper range (see BUG 1349)
    @since   NeXtMidas 2.1.0
*/
public abstract class LayerMap extends Layer implements ImageObserver {
  /** Null image */
  private static final BufferedImage NULL_IMAGE = new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB);
  /** Transparent color */
  private static final Color TRANSPARENT = new Color(0x00000000, true);
  /** Used by getConfig() */
  private static final String CLASS_PREFIX = "nxm.sys.libg.Layer";
  /** Used by getDefClassName() */
  private static final String LIBG_PREFIX = "nxm.sys.libg.";
  /** Used by getInstance() */
  private static long instanceCount = 0;
  /** Default tileMaxComp */
  private static final double MAX_COMP_DEF  = 1.000;
  /** Default tileMinComp */
  private static final double MIN_COMP_DEF  = 0.001;
  /** Default tileGeoComp */
  private static final int GEO_LEVEL_DEF = 3;
  // Tiles should be formed in accordance with the GeoWebCache EPSG:4326 standard
  // as reaching cached tiles required tiles aligned with existing grid bounds
  /** GeoWebCache WORLD4326 Bounding Box X Min*/
  private static final int GWC_4326_X_MIN = MPlot.LONGITUDE_DEFAULT_MIN;
  /** GeoWebCache WORLD4326 Bounding Box X Max*/
  static final int GWC_4326_X_MAX = MPlot.LONGITUDE_DEFAULT_MAX;
  /** GeoWebCache WORLD4326 Bounding Box Y Min*/
  private static final int GWC_4326_Y_MIN = MPlot.LATITUDE_DEFAULT_MIN;
  private static final int TILED_CACHE_SIZE_DEF = 1024;
  private static final int OLD_TILED_CACHE_SIZE_DEF = 16;
  private static final int CACHE_SIZE_DEF = 4;
  private int  currentCacheSizeDefault = CACHE_SIZE_DEF;
  private long jvmMaxMemoryGB = ((Runtime.getRuntime().maxMemory()/1024)/1024)/1024;
  /** limit new tiled cache size default only take up about one forth the JVM memory available
   *  estimate assumes 1024 tile images take about 1GB */
  private int smartFractionOfTileCacheDefault = (int)Math.min(1, jvmMaxMemoryGB/4);
  /** Shared image cache */
  protected static final WeakHashMap<String,Cache.TimeStampedValue> SHARED_VCACHE = new WeakHashMap<>();

  /** URL of the current map server */
  protected String mapServer = null;
  /** map server URLs
      @since NeXtMidas 3.3.2
   */
  protected ArrayList<String> mapServerList = new ArrayList<>();
  /** Brightness factor (0..100) */
  protected int brightness = 50;
  /** Contrast factor (0..100) */
  protected int contrast = 50;
  /** Convert to greyscale? */
  protected boolean greyscale = false;
  /** Progress Indicator (0..100=Busy,-1=NotBusy) */
  protected int progress = -1;
  /** Indicates if the images being used are in mercator view */
  protected boolean mercator =  getDefMercator();

  /** The image cache to use */
  protected final Cache imgCache = getDefImgCache();
  /** Size of image cache (-1=UseDefault) */
  private int imgCacheSize = -1;
  /** Instance count (for numbering threads) */
  private final long instance = getInstance();
  /** Images needing to be downloaded */
  private final Stack<DownloadRequest> toDownload = new Stack<>();
  /** The active download threads */
  private DownloadThread[] downloadThreads = null;
  /** Last blank image used */
  private BufferedImage lastBlankImage = null;
  /** Text of last blank image */
  private String lastBlankText = null;

  /** Default value for tileEnable */
  protected final boolean tileDefault = getDefUseTile();
  /** Draw tiles rather than single image? */
  protected boolean tileEnable = getDefUseTile();
  /** Tile height */
  protected final int tileHeight = getDefTileWidth();
  /** Tile width */
  protected final int tileWidth = getDefTileHeight();
  /** Maximum tile compression factor */
  protected double tileMaxComp = MAX_COMP_DEF;
  /** Minimum tile compression factor */
  protected double tileMinComp = MIN_COMP_DEF;
  /** Maximum tile number of levels (get this from server) */
  protected final int tileMaxLevel = getDefTileLevels();
  /** Tile resolution level for geodetic view (1..5, 0=None) */
  protected int tileGeoLevel = GEO_LEVEL_DEF;
  /** GeoWebCache Gridset Level used for current display zoom level */
  protected int gwcLevel = 0;

  /** HTTP URL connect timeout (seconds), 0 for infinite */
  protected double connectTimeout    =   15.0;
  /** HTTP read timeout (seconds), 0 for infinite */
  protected double readTimeout       =  300.0;
  /** Reconnect interval to Map Sever (seconds) - NOT IMPLEMENTED */
  protected double reconnectInterval =   30.0;
  /** pause time between download threads to not overload server (seconds) */
  private   double pause             = 0.0125;

  /** The name of the class (used for messages) */
  protected final String  className = getDefClassName();

  /** The Midas context to use  */ protected Midas   midas = null;
  /** Turn on debugging output? */ protected boolean debug = false;

  /** number of retries to get image if only get background color */
  int numRetriesIfBGColor = 0; // note: this will force retries to 1 greater than this number

  /** Use old cache size default for tiles */
  private static  boolean useOldCacheSizeDefault = false;
//  private static boolean  LIST_CONNECTION_INFO   = false;  // for DEBUG only
//  private static boolean  LIST_TILE_REQUEST_INFO = false;  // for debug only

  private int lastNumRetries = -1; // for testing purposes - how many retries did it take for the last image download
  private boolean imageAdjustOnPan = true;  // 4.0.1 for NXM-3747 - always perform full image adjustment even when panning
  private boolean legacyImageAdjust = false; // 4.0.1 for NXM-3747 - use legacy code rather than quicker code for BRIGHTNESS only adjustments
  /* Force settings compatible with obtaining cached tiles from a GeoWebCache*/
  private boolean geoWebCacheConfig = false;

  /** List of debug readout options. <br>
    Possible values = <code>{@value}</code>
  */
  public static final String debugList = "Connection,TileRequest,TilesBBox";
  /** Debug Option: List Capabilities Response. */
  public static final int D_CONNECTION   =    0x0001;
  /** Debug Option: List Tile Request Specs */
  public static final int D_TILEREQUEST  =    0x0002;
  /** Debug Option: Calculate and display bounding box for group of Tile requests. */
  public static final int D_TILESBBOX    =    0x0004;


  private int debugOptions = 0;

  /** Creates a new instance.
      @param name The name of the layer.
   */
  protected LayerMap (String name) {
    super();
    super.name = name;
    this.midas = Shell.getSharedMidasContext();
    if (midas!=null) pause = midas.pause;
    setDownloadThreads(0);
    setCacheSize(-1);
  }

  /** Gets the lock object to use. */
  final Object getLockObj() { return toDownload; } // package-private, only used by download threads

  /** Gets the default class name. */
  private String getDefClassName () {
    String _className = getClass().getName();
    if (_className.startsWith(LIBG_PREFIX)) {
      return _className.substring(LIBG_PREFIX.length());
    }
    return _className;
  }

  /** Gets the default tile width. */
  protected int getDefTileLevels () { return 32; }

  /** Gets the default tile width. */
  protected int getDefTileWidth () { return 256; }

  /** Gets the default tile width. */
  protected int getDefTileHeight () { return 256; }

  /** Are the images from the server in mercator projection? */
  protected boolean getDefMercator () { return false; }

  /** Gets the appropriate image cache to use for the layer. */
  private Cache getDefImgCache () {
    return new Cache(Cache.SLRU, 4, 3600.0, SHARED_VCACHE);
  }

  /** Default UseTiles value. */
  protected boolean getDefUseTile () { return false; }

  /** Only used in during initialization of 'instance'. */
  private static synchronized long getInstance () { return instanceCount++; }

  /** Sets the size of the local image cache.
      @param size The cache size or -1 if the size should be set automatically.
   */
  public void setCacheSize (int size) {
    setDefaultCacheSize();
    setCacheSize(size,0,currentCacheSizeDefault);
  }

  /** set default tile cache size
   *  <pre>
   *    tiles enabled:
   *       useOldCacheSizeDefault: OLD_TILED_CACHE_SIZE_DEF
   *       otherwise             : TILED_CACHE_SIZE_DEF unless limited by memory size
   *
   *    tiles not enabled: CACHE_SIZE_DEF
   *  </pre>
   */
  private void setDefaultCacheSize () {
    // smart default - limit new default if short on memory, but have it be no smaller than old default
    int smartDefault = Math.max(TILED_CACHE_SIZE_DEF * smartFractionOfTileCacheDefault,OLD_TILED_CACHE_SIZE_DEF);
    currentCacheSizeDefault = (tileEnable)? ((!useOldCacheSizeDefault) ? smartDefault : OLD_TILED_CACHE_SIZE_DEF) : CACHE_SIZE_DEF;
  }

  /** Sets the size of the local image cache.
      @param size The cache size or -1 if the size should be set automatically.
      @param min  The minimum allowable size.
      @param def  The default size to use when <code>size=-1</code> (ignored if <code>def&lt;min</code>).
   */
  protected final void setCacheSize (int size, int min, int def) {
    if (size == -1) {
      imgCache.setMaxSize(Math.max(def, min));
      imgCacheSize = -1;
    } else {
      if (size < min) {
        warn("Current image cache (CacheSize="+imgCacheSize+") is too small given the current "
            +"configuration and current size of the PLOT window, increasing size to "+size+".");
        size = min;
      }
      imgCache.setMaxSize(size);
      imgCacheSize = imgCache.getMaxSize(); // cache may internally restrict size.
    }
  }

  /** Gets the size of the local image cache.
      @return The cache size or -1 if the size is set automatically.
      @see #getCurrentCacheSize()
   */
  public final int getCacheSize () {
    return imgCacheSize;
  }

  /** Gets the current size of the local image cache.
      @return The cache size currently in use (if the cache size is set automatically, this method
              will return the current size of the cache).
      @see #getCacheSize()
   */
  public final int getCurrentCacheSize () {
    return imgCache.getMaxSize();
  }

  /** Enables or disables use of tiles rather than a single image. Not all layers allow this to be
      changed (i.e. some servers mandate that tiles be used), those layers will issue a warning
      whenever this method is called with an incompatible setting.
      @param tile true = draw using tiles, false = draw as single image.
   */
  public void setUseTiles (boolean tile) {
    if (tile != tileEnable) {
      tileEnable = tile;
      setCacheSize(-1); // need to update to use tiles cache size
      refresh();
    }
  }

  /** Indicates if this layer is drawing using tiles.
   *  @return true if this layer is drawing using tiles */
  public boolean getUseTiles () { return tileEnable; }

  /** Sets the minimum compression factor for tiles.
      @see #setMaxComp(double)
      @see #setUseTiles(boolean)
      @param val the minimum compression factor for tiles
   */
  public void setMinComp (double val) {
    if ((tileMinComp != val) && (val > 0.0)) {
      tileMinComp = val;
      refresh();
    }
  }

  /** Gets the minimum compression factor for tiles.
      @see #getMaxComp()
      @see #setUseTiles(boolean)
      @return the minimum compression factor for tiles
   */
  public double getMinComp () {
    return tileMinComp;
  }

  /** Sets the maximum compression factor for tiles.
      @see #setMinComp(double)
      @see #setUseTiles(boolean)
      @param val the maximum compression factor for tiles
   */
  public void setMaxComp (double val) {
    if ((tileMaxComp != val) && (val > 0.0)) {
      tileMaxComp = val;
      refresh();
    }
  }

  /** Gets the maximum compression factor for tiles.
      @see #getMinComp()
      @see #setUseTiles(boolean)
      @return the maximum compression factor for tiles
   */
  public double getMaxComp () {
    return tileMaxComp;
  }

  /** Sets the resolution level for geodetic view (0=None, 1=Lowest, 5=Highest).
   *  @param level the resolution level for geodetic view */
  public void setGeoResLevel (int level) {
    level = Math.max(0, Math.min(level, 5));

    if (level == 1) { level = 2; } // JC4: Level=1 does not work with LayerNMS right now.

    if (level != tileGeoLevel) {
      tileGeoLevel = level;
      refresh();
    }
  }

  /** Gets the resolution level for geodetic view.
   *  @return the resolution level for geodetic view */
  public int getGeoResLevel () {
    return tileGeoLevel;
  }

  /** Use defaults that will allow tiles to be cached and retrieved in GeoWebCache
   *  @param useGeoWebCacheConfig true to 'force' GeoWebCache comptible settings
   *                              no BGCOLOR specified in URL
   *                              EPSG:4326 CRS
   *                              zoom level 0 tile set -180,-90, 0, 90 and 0,-90, 180, 90
   */
  public void setGeoWebCache (boolean useGeoWebCacheConfig) {
    this.geoWebCacheConfig = useGeoWebCacheConfig;
    setUseTiles(true); // always needed to take advantage of GeoWebCache cache
  }

  /** Get if settings should be GeoWebCache compatible
   *  @return true if settings should support caching and retrival of tiles from GeoWebCache
   */
  public boolean getGeoWebCache () {
    return geoWebCacheConfig;
  }

  /** Gets the configuration of this layer. This is typically the argument that would be provided
      to the <code>PLOT</code> command to bring up the layer in the current state.
      @return The layer config (mods to this table do not affect the active plot in any way).
   */
  public Table getConfig() {
    Table  tbl   = new Table();
    String clazz = getClass().getName();

    if (clazz.startsWith(CLASS_PREFIX)) {
      clazz = clazz.substring(CLASS_PREFIX.length());
    }

    if (true                            ) { tbl.put("LT",              clazz               ); }
    if (debug                           ) { tbl.put("DEBUG",           "TRUE"              ); }
    if (true                            ) { tbl.put("URL",             getURL()            ); }
    if (brightness      != 50           ) { tbl.put("BRIGHTNESS",      brightness          ); }
    if (contrast        != 50           ) { tbl.put("CONTRAST",        contrast            ); }
    if (greyscale       != false        ) { tbl.put("GREYSCALE",       greyscale           ); }
    if (downloadThreads != null         ) { tbl.put("DOWNLOADTHREADS", getDownloadThreads()); }
    if (tileEnable      != tileDefault  ) { tbl.put("USETILES",        getUseTiles()       ); }
    if (tileMaxComp     != MAX_COMP_DEF ) { tbl.put("MAXCOMP",         getMaxComp()        ); }
    if (tileMinComp     != MIN_COMP_DEF ) { tbl.put("MINCOMP",         getMinComp()        ); }
    if (tileGeoLevel    != GEO_LEVEL_DEF) { tbl.put("GEOLEVEL",        getGeoResLevel()    ); }
    if (imgCacheSize    != -1           ) { tbl.put("CACHESIZE",       getCacheSize()      ); }

    return tbl;
  }

  /** This does nothing, use {@link #setURL(String)} to set the URL of the server. */
  @Override
  public boolean setFile (PlotFile pf, Table tab, Args MA) {
    return true;
  }

  /** Sets the URL of the applicable map server.
   *  @param url the URL of the map server */
  public void setURL (String url) {
    if ((url != null) && !isReachable(url,true)) url = null;

    if ((url == mapServer) || ((url != null) && url.equals(mapServer))) {
      // do nothing
    } else {
      mapServer = url;
      setNeedsRescale(true); // this is needed for so that findRange is called for BUG 1349
      refresh();
    }
    updateMapServerList(mapServer);
    if (debug) Shell.info("LayerMap url="+url+ " mapServer="+mapServer+" mapListSize="+mapServerList.size());
  }

  /** Gets the URL of applicable map server.
   *  @return URL of the applkicable map server */
  public String getURL() {
    return mapServer;
  }

  /** Update the list of Map Server URLs.
      @param url Map Server URLs
      @return first non-null url
      @since neXtMidas 3.3.2
   */
  public String updateMapServerList (String url) {
    if (url==null || url.isEmpty()) return null;
    int comma = url.indexOf(',');
    if (comma>=0) {
      String first = null;
      String[] urls = url.split(",");
      for (String u : urls) {
        if (first==null) first = u;
        if (u!=null && !mapServerList.contains(u)) mapServerList.add(u);
      }
      return first;
    }
    else {   // single entry
      if (url!=null && !mapServerList.contains(url)) mapServerList.add(url);
      return url;
    }

  }

  /** disconnect from map server */
  public void disconnect () {
    mapServer = null;
    setNeedsRescale(true); // this is needed for so that findRange is called for BUG 1349
    refresh();
  }

  /** Checks to see if a given host/port is reachable. This does the same checks as
      {@link #checkReachable(String)} except that it returns a boolean rather than
      throwing an exception.
      @param href The URL to check.
      @param warn Print warning if unreachable?
      @return true if valid, false if invalid.
      @see #checkReachable(String)
   */
  boolean isReachable (String href, boolean warn) {
    boolean valid;

    try {
      checkReachable(href);
      valid = true;
    } catch (Exception e) {
      if (warn) warn(e.getMessage());
      valid = false;
    }
    return valid;
  }

  /** Checks to see if a given host/port is reachable. This checks to see that:
      <pre>
        (1) The URL is not null.
        (2) The URL matches valid URL syntax (i.e. not a junk string).
        (3) A socket can be opened to the given host/port combination.
      </pre>
      @param href The URL to check.
      @throws MidasException if the specified host/port is not reachable.
      @see #isReachable(String,boolean)
   */
  void checkReachable (String href) {
    // The InetAddress.isReachable(..) method in Java 5 is useless. It only works well if
    // the user has 'root' access, due to the need to create an ICMP connection. In most
    // cases it needs to default to a simple ECHO request using port 7, which is often
    // blocked for security reasons.
    //
    // Here we just try opening a socket on the given port. If it works, the server must
    // be accessible; if not, assume it is not accessible (this is usually accompanied by
    // a NoRouteToHostException or the like).
    URL url = null;

    try {
             url  = (new URI(href)).toURL();
      String host = url.getHost();
      int    port = getPort(url.getProtocol(), url.getPort());

      Socket socket = new Socket();
      socket.connect(new InetSocketAddress(host, port), (int)(connectTimeout*1000));
      socket.close();
    }
    catch (Exception e) {
      if (url == null) {
        throw new MidasException("Unable to access '"+href+"' invalid URL: "+e.getMessage(), e);
      } else {
        throw new MidasException("Unable to access '"+url.getHost()
                               + "' using port "+getPort(url.getProtocol(), url.getPort())
                               + ": "+e.getMessage(), e);
      }
    }
  }

  private int getPort (String protocol, int urlPort) {
    if (urlPort != -1) return urlPort;
    if (protocol.equals("http")) return 80;
    if (protocol.equals("https")) return 443; // Support for HTTPS was added in 3.7.1
    return -1;
  }

  /** Gets the URL of applicable map server. */
  protected String getHostPort() {
    String hostPort;
    try {
      URL url = (new URI(getURL())).toURL();

      hostPort = url.getHost();
      if (url.getPort() > 0) {
        hostPort = hostPort + ":" + url.getPort();
      }
    } catch (MalformedURLException | URISyntaxException e) {
      hostPort = "<invalid url>";
    }
    return hostPort;
  }

  /** Indicates if the server has been connected.
   *  @return true if the server has been connected */
  public abstract boolean isConnected();

  /** Turns on/off debugging output.
   *  @param dbg true if debugging output should be on */
  public void setDebug(boolean dbg) {
    info("Turning debug "+((dbg)? "on": "off")+" for "+getName());
    debug = dbg;
  }

  /** indicates if debugging output is on/off
   *  @return true if debugging output is on */
  public boolean getDebug() {
    return debug;
  }

  /** Sets the range appropriate for a map of the world. */
  @Override
  public void findRange() {
    x1 = -180.0; x2 = 180.0;
    y1 =  -90.0; y2 =  90.0;
    z1 =    0.0; z2 =   0.0;
    // Set Minimum and Maximum actual auto-scale value (since 3.7.0 for NXM-2984)
    a1 = y1;
    a2 = y2;
  }

  @Override
  public void findFullRange() { findFullRange(pf); }

  /** Process a draw request. Note that minLon>maxLon and minLat>maxLat are valid, this will be the
      case when the plot is mirrored.
      @param minLon Minimum longitude.
      @param maxLon Maximum longitude.
      @param minLat Minimum latitude.
      @param maxLat Maximum latitude.
      @param height The height of the image.
      @param width  The width of the image.
      @param hiRes  Force a high-resolution request due to map projection (e.g. Mercator has high
                    elongation near the poles, this would be set to true if in Mercator view and
                    near one of the poles).
      @param spec   The tile specifier (null if tiling is off).
      @return 0
   */
  protected abstract int drawArea (double minLon, double maxLon, double minLat, double maxLat,
                                   int width, int height, boolean hiRes, String spec);

  @Override
  public void process () {
    double minLon;
    double maxLon;
    minLon = Math.max(-180.0, MP.rx1); // true for all views
    if(MP.view== MPlot.V_LATLONCONTINUOUS) {
      // minLon should be still be limited and
      // maxLon limited to 360 above min or 540 cap NXM-3720
      // (but there are still rendering issues with LATLONCONTINUOUS and TILED=FALSE, which is the case triggering the ServiceException)
      double maxAsideFromViewArea = Math.min(minLon + MPlot.LONGITUDE_RANGE, MPlot.LONGITUDE_CONTINUOUS_MAX);
      maxLon = Math.min(MP.rx2,maxAsideFromViewArea);
    }
    else {
      maxLon = Math.min( 180.0, MP.rx2);
    }

    double  minLat = Math.max( -90.0, MP.ry1);
    double  maxLat = Math.min(  90.0, MP.ry2);
    int     xMin   = MP.getix(minLon);
    int     xMax   = MP.getix(maxLon);
    int     yMin   = MP.getiy(minLat);
    int     yMax   = MP.getiy(maxLat);
    int     width  = xMax - xMin;
    int     height = yMin - yMax; // Note min and max for latitude are reversed for a map
    boolean hiRes  = false;

    if (MP.view == MPlot.V_MERCATOR) {
      // JC4: In Mercator view there is significant elongation near the poles,
      //      use hiRes to get a better image for this case.
      hiRes  = (minLat > 60) || (minLat < -60) || (maxLat > 60) || (maxLat < -60);
    } else if (MP.view == MPlot.V_GEODETIC) {
      minLon = -180;   maxLon = 180;
      minLat =  -90;   maxLat =  90;

      // Make sure that the 1:2 ratio for w:h is maintained using the largest extents... otherwise
      // when the image is "squashed" to fit Lat:Lon=1:2 it will have extra borders on the top/btm
      // or the left/right.
      width  = Math.max(MP.panel.getWidth(),MP.panel.getHeight());
      height = width/2;
      hiRes  = true;
    }
    if (tileEnable) {
      drawTiles(minLon, maxLon, minLat, maxLat, width, height, hiRes);
    } else {
      drawArea(minLon, maxLon, minLat, maxLat, width, height, hiRes, null);
    }
  }

  /** Draws tiles. */
  protected void drawTiles (double minLon, double maxLon, double minLat, double maxLat,
                            int width, int height, boolean hiRes) {
    HashMap<String,double[]> tiles = new HashMap<>();
    int[] levelAndDiv = calcTileLevel(minLon, maxLon, minLat, maxLat, width, height, hiRes);
    int level = levelAndDiv[0];
    int div   = levelAndDiv[1];

    // Get the tile definitions.
    //   To avoid any gaps, step by div/2, this results in a lot of duplicates, but that
    //   is no problem since the HashMap discards the duplicates.
    double dlon = 360.0 / div / 2;
    double dlat = 360.0 / div / 2;
    for (double lon = minLon; lon < maxLon+dlon; lon+=dlon) {
      for (double lat = minLat; lat < maxLat+dlat; lat+=dlat) {
        StringBuilder pos = new StringBuilder("t");
        double dx  = 180, dy = 180;
        // Tiles are often used with GeoWebCaches, which require tiles that grid aligned
        // Use standard world / GeoWebCache longitude range, rather than layer bounds than can be slightly off from -180, 180
        double x1  = GWC_4326_X_MIN, y1 = (level > 2) ? -180 : GWC_4326_Y_MIN; // NXM-3744 make it so lower set is good
        double x2  = GWC_4326_X_MAX, y2 = (level > 2) ? 180 : 270;
        for (int i=2; i<=level; i++, dx=dx/2, dy=dy/2) {
          boolean left = (lon < x1+dx); // Go left (or right)
          boolean down = (lat < y1+dy); // Go down (or up)

          if (left) {
            x2 -= dx;
            if (down) { pos.append('t'); y2 -= dy; }
            else      { pos.append('q'); y1 += dy; }
          } else {
            x1 += dx;
            if (down) { pos.append('s'); y2 -= dy; }
            else      { pos.append('r'); y1 += dy; }
          }
        }

        String posStr = pos.toString();
        if (isValidPos(posStr)) {
          tiles.put(posStr, new double[] { x1, x2, y1, y2 });
        }
      }
    }
    setCacheSize(imgCacheSize,Math.max(1,tiles.size()),currentCacheSizeDefault); // 4.0.0 min Size of 1
    this.gwcLevel = level - 2; // Z level (used by GWC) starts at level 0 and 2 tiles
// 2010-01-28 NTN: old iteration code
//    Iterator toDraw = tiles.entrySet().iterator();
//    while (toDraw.hasNext()) {
//      Map.Entry tile = (Map.Entry)toDraw.next();
//      String    pos  = tile.getKey().toString();
//      double[]  box  = (double[])tile.getValue();
//      drawArea(box[0], box[1], box[2], box[3], tileWidth, tileHeight, hiRes, pos);
//    }
    // Java 5 way to iterate over HashMap<K,V>
    boolean connectionDebug = (debugOptions & D_CONNECTION) != 0;
    double draw1 = (connectionDebug ) ? Time.current() : 0; // only for DEBUG use
    double overallMaxLon = -180;
    double overallMaxLat = -90;
    double overallMinLon = 180;
    double overallMinLat = 90;
    for (Map.Entry<String,double[]> mapentry : tiles.entrySet()) {
      String   pos = mapentry.getKey();
      double[] box = mapentry.getValue();
      if (connectionDebug) midas.info("[DEBUG CONNECTION] LayerMap drawing:"+java.util.Arrays.toString(box)+" tileWidth:"+tileWidth+" tileHeight:"+tileHeight+" pos:"+pos);
      drawArea(box[0], box[1], box[2], box[3], tileWidth, tileHeight, hiRes, pos);
      if (box[1] > overallMaxLon) overallMaxLon = box[1];
      if (box[3] > overallMaxLat) overallMaxLat = box[3];
      if (box[0] < overallMinLon) overallMinLon = box[0];
      if (box[2] < overallMinLat) overallMinLat = box[3];
    }

    double draw2 = (connectionDebug ) ? Time.current() : 0;
    if (connectionDebug && (downloadThreads == null))
      midas.info("[DEBUG CONNECTION] total connect+read+draw time: " + (draw2-draw1));
    if ((debugOptions & D_TILESBBOX) != 0) midas.info("[DEBUG TILESBBOX] z_level:"+(level-2)+ " nxm_level:"+level
        + " overallMinLon:" + overallMinLon + " overallMinLat:" + overallMinLat
        + " overallMaxLon:" + overallMaxLon + " overallMaxLat:" + overallMaxLat);
  }

  /** Checks to see that a given position indicator is valid. */
  private boolean isValidPos (String pos) {
    boolean valid = pos.startsWith("t");

    //   -180     lon    180
    //    -----------------  180
    //    |   |   |   |   |
    //    -----------------
    //    |tqt|tqs|trt|trs|
    //    -----------------  lat
    //    |ttq|ttr|tst|tsr|
    //    -----------------
    //    |   |   |   |   |
    //    ----------------- -180
    if ((pos.length() > 2) && !pos.startsWith("trs") && !pos.startsWith("trt")
                           && !pos.startsWith("tsq") && !pos.startsWith("tsr")
                           && !pos.startsWith("ttq") && !pos.startsWith("ttr")
                           && !pos.startsWith("tqs") && !pos.startsWith("tqt")) {
      valid = false; // Image is off of the world (>90N or <90S)
    }
    //  -180  lon  180
    //    -----------  270
    //    | tq | tr |
    //    -----------  lat
    //    | tt | ts |
    //    ----------- -90
    else if ((pos.length() == 2) && (pos.equals("tr") || pos.equals("tq"))) {
      valid = false;
    }

    return valid;
  }

  int[] calcTileLevel (double minLon, double maxLon, double minLat, double maxLat, int width, int height, boolean hiRes) {
    double  compLimit = (hiRes)? tileMaxComp/2 : tileMaxComp;
    int     maxLevel  = (MP.view == MPlot.V_GEODETIC)? tileGeoLevel : tileMaxLevel;
    int     level     = 1;
    int     div       = 1;
    double  ppd       = width / (maxLon - minLon);    // max pixels per degree

    boolean tileRequestInfo = (debugOptions & D_TILEREQUEST) != 0;
    if (tileRequestInfo) midas.info("[DEBUG TILEREQUEST] LayerMap.drawTiles lon:"+minLon+","+maxLon+" lat:"+minLat+","+maxLat+" width:"+width+" height:"+height+" hiRes:"+hiRes+" ppd:"+ppd);
    for (; level<maxLevel; level++,div*=2) {
      if (MP.view != MPlot.V_GEODETIC) {
        double dpc = (360.0/div) / tileWidth; // degrees per cell
        double ppc = ppd * dpc; // pixels per cell
        if (tileRequestInfo) midas.info("[DEBUG TILEREQUEST] z_level:"+(level-2)+ "nxm_level:"+level+" dpc:"+dpc+" ppc:"+ppc+" div:"+div);
        if (ppc < compLimit) break;
      }
    }

    if (level == 1) {
      // need two squares to avoid latitude being out of bounds on request NXM-3744
      level = 2;
      div = 2;
    }
    return new int[] {level,div};
  }

  @Override
  public void draw (int flag) {
      super.draw(flag);
//jph 03MAR08 -- DR 1230: Commented this out to prevent constant flashing of cursor
//      setProgress(0);

      process();

      setProgress(progress());
  }

  /** Draw an image on the plot. This method handles any special image adjustments for brightness
      and contrast and also handles any projection issues. This uses the full bounds of the image.
      @param img    The image to plot.
      @param minLon Longitude matching xMin.
      @param maxLon Longitude matching xMax.
      @param minLat Latitude  matching yMin.
      @param maxLat Latitude  matching yMax.
   */
  protected final void drawImage (BufferedImage img, double minLon, double maxLon, double minLat, double maxLat) {
    drawImage(img, 0, img.getWidth(), 0, img.getHeight(), minLon, maxLon, minLat, maxLat);
  }

  /** Draw an image on the plot. This method handles any special image adjustments for brightness
      and contrast and also handles any projection issues.
      @param img    The image to plot.
      @param xMin   Min X value in the image to plot.
      @param xMax   Max X value in the image to plot.
      @param yMin   Min Y value in the image to plot.
      @param yMax   Max Y value in the image to plot.
      @param minLon Longitude matching xMin.
      @param maxLon Longitude matching xMax.
      @param minLat Latitude  matching yMin.
      @param maxLat Latitude  matching yMax.
   */
  protected void drawImage(BufferedImage img, int xMin, int xMax, int yMin, int yMax,
                           double minLon, double maxLon, double minLat, double maxLat) {
    if (img == null) {
      // Do nothing
    } else if (!isViewSupported(MP.view)) {
      warn("VIEW="+MP.getView()+" not supported at this time.");
    } else {
      int    xSize   = xMax - xMin;
      int    ySize   = yMax - yMin;
      double lonSize = maxLon - minLon;
      double latSize = maxLat - minLat;

      double xstart  = minLon;
      double xdelta  = lonSize / xSize;
      int    nx      = xSize;
      double ystart  = maxLat;
      double ydelta  = -(latSize / ySize);
      int    ny      = ySize;

      if (xMin != 0) { warn("Can not plot xMin!=0 at this time."); }
      if (yMin != 0) { warn("Can not plot yMin!=0 at this time."); }

      if (imageAdjustOnPan || MP.dragging == 0) {
        // If we are using default behavior of adjusting all images or we are not panning, image needs to be adjusted
        if (!legacyImageAdjust) {
          // use brightness adjustment only speed improvements - NXM-3747 4.0.1
          img = MColor.adjustImageFast(img, xMin, yMin, xMax, yMax, contrast, brightness, -1, greyscale);
        }
        else {
          img = MColor.adjustImage(img, xMin, yMin, xMax, yMax, contrast, brightness, -1, greyscale);
        }
      }

      if(MP.view==MPlot.V_LATLONCONTINUOUS) {
        MP.drawImage(img, ystart, ystart-latSize, xstart+lonSize, xstart);
      }
      else {
        MP.drawImage(img,null,mercator,xstart,xdelta,nx,ystart,ydelta,ny);
      }
    }
  }

  @Override
  public boolean imageUpdate (Image img, int infoflags, int x, int y, int width, int height) {
    return true; // See ImageObserver interface
  }

  /** Sets the image contrast as a percentage (0-100).
   *  @param percent the image contrast as a percentage (0-100) */
  public void setContrast (int percent) {
    int oldval = contrast;
    contrast = Math.max(0,Math.min(100,percent));
    if (contrast!=oldval) refresh();
  }

  /** Gets the image contrast as a percentage (0-100).
   *  @return  the image contrast as a percentage (0-100) */
  public int getContrast () { return contrast; }

  /** Sets the image brightness as a percentage (0-100).
   *  @param percent the image brightness as a percentage (0-100) */
  public void setBrightness (int percent) {
    int oldval = brightness;
    brightness = Math.max(0,Math.min(100,percent));
    if (brightness!=oldval) refresh();
  }

  /** Gets the image brightness as a percentage (0-100).
   *  @return the image brightness as a percentage (0-100) */
  public int getBrightness () { return brightness; }

  /** Sets the greyscale mode of the image. If turned on (true) the maps and images will be
      converted to greyscale.
      @param gs true if the image should be in greyscale
   */
  public void setGreyscale (boolean gs) {
    boolean oldval = greyscale;
    greyscale = gs;
    if (gs!=oldval) refresh();
  }

  /** Is the image in greyscale mode?
   *  @return true if the image is in greyscale mode */
  public boolean getGreyscale () { return greyscale; }

  /** This is the same as {@link #getProgress()} and is provided for compatibility with
      <code>LayerMToc</code> in the MAP option tree.
      @return the value of the progress indicator
   */
  public final int getBusy () { return getProgress(); }

  /** Get the value of the progress indicator (0 to 100).
   *  @return the value of the progress indicator */
  public int getProgress() { return progress; }

  /** Set the value of the progress indicator (0..100=Busy, -1=NotBusy)
   *  @param val the value of the progress indicator */
  protected void setProgress(int val) {
    progress = val;
    String userCursor = null;   // keep track of user changes  (Bug 1230)
    if (val==0) userCursor = MP.getUserCursor();  // original value before any changes made by this method
    String defCursor = (userCursor!=null)? userCursor : "DEFAULT";
    final String cursor = (progress >= 0)? "WAIT" : defCursor;
    if (MP != null) MP.setCursor(cursor);
  }

  @Override
  public boolean isViewSupported (int view) {
    return (view == MPlot.V_LATLON) || (view == MPlot.V_LATLONCONTINUOUS) || (view == MPlot.V_MERCATOR) || (view == MPlot.V_GEODETIC);
  }

  /** Get the connection timeout (seconds) to the Map Server.
      @return the connect timeout (seconds) to the Map Server
      @since NeXtMidas 2.7.4
   */
  public double getConnectTimeout () { return connectTimeout; }

  /** Set the connection timeout (seconds) to the Map Server.
      @param val the connectTimeout to set (seconds). set to 0 for infinite.
      @since NeXtMidas 2.7.4
   */
  public void setConnectTimeout (double val) { connectTimeout = val; }

  /** Get the read timeout (seconds) to the Map Server.
      @return the read timeout (seconds) to the Map Server
      @since NeXtMidas 2.7.4
   */
  public double getReadTimeout () { return readTimeout; }

  /** Set the read timeout (seconds) to the Map Server.
      @param val the read timeout to set (seconds). set to 0 for infinite.
      @since NeXtMidas 2.7.4
   */
  public void setReadTimeout (double val) { readTimeout = val; }

  /** Set if images contrast, brightness, and alpha are adjusted during panning
   *  (or whether unadjusted images are used for speed until the panning has stopped)
   *
   *  @param adjust true if should always adjust
   */
  @InternalUseOnly
  public void setImageAdjustOnPan (boolean adjust) {
    imageAdjustOnPan = adjust;
  }

  /** Use legacy Image Adjustment Code that does not take short-cut when just BRIGHTNESS is adjusted
   *
   *   @param legacyCode use legacy code
   */
  @InternalUseOnly
  public void setLegacyImageAdjust (boolean legacyCode) {
    legacyImageAdjust = legacyCode;
  }

// TODO enabled when implementing reconnectInterval
//  /** Get interval (seconds) to try to reconnect to the Map Server.
//      @return the reconnect interval (seconds) to the Map Server
//   */
//  public double getReconnectInterval() { return reconnectInterval; }
//
//  /** Set interval (seconds) to try to reconnect to the Map Server.
//      @param reconnectInterval the reconnectInterval to set in seconds.
//   */
//  public void setReconnectInterval(double val) {
//    reconnectInterval = val;
//  }

  /** Opens the connection for requestImage, allows subclasses to override if needed.
      Subclasses should call super.openConnection(..) first or manually set the
      connect and read timeouts.
      @param url HTTP URL to open connection to
      @throws IOException
      @throws MapServerException
   */
  protected HttpURLConnection openConnection (String url) throws IOException, MapServerException {
    URLConnection conn = null;
    try {
      URL urlObj = (new URI(url)).toURL();
      conn = urlObj.openConnection();
      if ((debugOptions & D_CONNECTION) != 0) {
        midas.info("[DEBUG CONNECTION]       - - -  Connection info - - -");
        midas.info("[DEBUG CONNECTION] url:"+conn.getURL());
        midas.info("[DEBUG CONNECTION] url Host:"+urlObj.getHost());
        midas.info("[DEBUG CONNECTION] useCaches:"+conn.getUseCaches());
        midas.info("[DEBUG CONNECTION] header fields: "+conn.getHeaderFields());
        midas.info("[DEBUG CONNECTION]       - - - - - - - ");
      }
      if (connectTimeout > 0) conn.setConnectTimeout((int)(connectTimeout*1000));
      if (readTimeout    > 0) conn.setReadTimeout((int)(readTimeout*1000));
    }
    catch (URISyntaxException e) {
      throw new MalformedURLException("Forming URL via URI failed:"+e.getMessage()+" "+e.getStackTrace());
    }
    return (HttpURLConnection)conn;
  }

  /** Gets a specified image. If the image has been locally cached then return the cached image.
      @param url         The URL of the image to get.
      @param trys        The number of tries.
      @param checkLength Check for valid content length (true=Error when len&lt;0, false=Skip check).
      @param width       The expected width of the image.
      @param height      The expected height of the image.
      @return The image or null if none could be loaded.
   */
  protected BufferedImage requestImage (String url, int trys, boolean checkLength, int width, int height) {
    BufferedImage img = (BufferedImage)imgCache.get(url);

    if ((img == null) && (url != null) && (url.length() > 0)) {
      DownloadRequest req = new DownloadRequest(url,trys,checkLength,width,height);

      if (downloadThreads == null) {
        if (debug) info("Downloading URL="+req);
        img = downloadImage(req);
        if (img != null) imgCache.put(url,img);
      } else {
        addDownloadRequest(req);
        img = getBlankImage("Downloading...", req, Color.darkGray);
      }
    }
    return (img == NULL_IMAGE)? null : img;
  }

  /** Downloads an image.
      @param request The download request.
      @return The image or null if none could be loaded.
   */
  private BufferedImage downloadImage (DownloadRequest request) {
    boolean connectionDebug = (debugOptions & D_CONNECTION) != 0;
    if (connectionDebug) midas.info("[DEBUG CONNECTION] ________ LayerMap Downloading Image __________________");
    BufferedImage img = null;
    InputStream is = null;
    BufferedInputStream bis = null;
    boolean done  = false;
    boolean error = false;
    boolean keepTile = true; // keep the tile received (only set to false if tile is bgcolor only & we are doing retries)
    int code = 0; // http response code
    for (int i = 1; !done && !error && (i <= request.trys) && !MP.cancel; i++) {
      HttpURLConnection conn = null;
      try {
        try {
          double connect1 = (connectionDebug) ? nxm.sys.lib.Time.current() : 0;
          conn = openConnection(request.url);
          double connect2 = (connectionDebug) ? nxm.sys.lib.Time.current() : 0;
          if (connectionDebug) midas.info("[DEBUG CONNECTION] connect time:"+(connect2-connect1));
        } catch (MapServerException e) {
          warn("downLoadImage exception trying to recover image ", e);
          if (connectionDebug) midas.info("[DEBUG CONNECTION] MapServerException :"+e);
          if (e.isInImage() && !MP.cancel) {
            img = ImageIO.read(new BufferedInputStream(conn.getInputStream()));
          } else {
            img = getBlankImage("Error Accessing Image", request, Color.red);
          }
          error = true;
          throw e;
        }
        code = conn.getResponseCode();
        if (connectionDebug) midas.info("[DEBUG CONNECTION] Response message :"+conn.getResponseMessage());
        if (code < 0) {
          // JC4: This is common for HTTP_NO_CONTENT connections that don't have the content
          //      length set to 0. Since the status is HTTP_NO_CONTENT no content is allowed,
          //      so setting the length to 0 seems unnecessary... anyhow, I'll issue the
          //      warning in case someone changes the server and makes a mistake.
          warn("LayerMap: Error accessing URL="+request.url+" (try "+i+") possible "
             + "conflict in content length and status in HTML packet. HTTP response code="+code);
          done = false;
        } else if (code == HttpURLConnection.HTTP_NO_CONTENT) {
          warn("received code HTTP_NO_CONTENT");
          img  = NULL_IMAGE; // Blank Tile
          done = true;
        } else if (request.checkLength && (conn.getContentLength() < 0L)) {
          info("content length is not known");
          // JC4: I've seen this several times... since the MapServer ALWAYS sets the content
          //      length to a positive value (may be 0 if there is no content) this should be
          //      impossible. My guess is a pre-mature connection is being made, but not sure.
          //      For now I will just ignore this case and let it re-try since I've rarely seen
          //      it hit this case twice in a row.
          done = false;
          if (i == request.trys) {
            // JC4: If this happens too often it may be a sign of a server problem.
            warn("LayerMap: Error accessing URL="+request.url+" (try "+i+"): "
               + "Invalid content length in HTTP header. HTTP response code="+code);
          }
        } else if (!MP.cancel) {
          double read1 = (connectionDebug) ? nxm.sys.lib.Time.current() : 0; // for debug only
          is = conn.getInputStream();
          bis = new BufferedInputStream(is);
          img  = ImageIO.read(bis);
          double read2 = (connectionDebug) ? nxm.sys.lib.Time.current() : 0; // for debug only
          if (connectionDebug) midas.info("[DEBUG CONNECTION] read time:"+(read2-read1));
          lastNumRetries = i;
          keepTile = done = (i <= numRetriesIfBGColor) ? isImageNotJustBackground(img,request) : true; // try one more time if just get background color
        }
      } catch (MapServerException e) {
        warn("Error accessing URL="+request.url+" HTTP response code="+code, e);
        // emptyErrorStream(conn); // recommended if want TCP connection reused, but skipping until needed
      } catch (Exception e) {
        warn("Error accessing URL="+request.url+" (try "+i+") HTTP response code="+code, e);
        // emptyErrorStream(conn); // recommended if want TCP connection reused, but skipping until needed
      }
      //if (conn != null) { conn.disconnect(); } // commented out in 4.0.0 to reuse TCP connection if possible
      if (!done) Time.sleep(pause); // give server a little time to recover (so that we don't over load it immediately with the same request)
    } // end for loop
    if (!done && !error && keepTile && !MP.cancel) {
      warn("LayerMap: Unable to access URL="+request.url+" after "+request.trys+" trys.");
      img = getBlankImage("Image Not Available", request, Color.red);
    }
    try {
      // Since 4.0.0 to ensure InputStreams are closed - possible TCP connection reusue
      if (is != null) is.close();
      if (bis != null) bis.close();
    }
    catch (IOException io) {
      warning("LayerMap exception closing input streams:"+io);
    }
    if (connectionDebug) midas.info("[DEBUG CONNECTION] __________end_downloadImage_________________\n");
    return img;
  }

  /** Get the number of tries it took to get the last image
   *
   *  @return number of tries
   */
  @InternalUseOnly
  public int getLastNumRetries () {
    return lastNumRetries;
  }

  /** image to make sure it is not just the BGCOLOR
   *
   *  @param img     the image
   *  @param request the request (contains the BGCOLOR)
   *  @return true as soon a one tile pixel does not match BGCOLOR
   */
  private boolean isImageNotJustBackground (BufferedImage img, DownloadRequest request) {
    int h = img.getTileHeight();
    int w = img.getTileWidth();
    String requestStr = request.toString();
    int idxBGCOLOR = requestStr.indexOf("BGCOLOR=");
    if (idxBGCOLOR == -1) return true;
    int locBGCOLOR = idxBGCOLOR + 10; // location hex color (note: length of BGCOLOR=0x is 10 characters)
    String bgColorStr = requestStr.substring(locBGCOLOR, locBGCOLOR+6); // 6 HEX values RRGGBB
    int[] pixels = new int[h*w];
    PixelGrabber pg = new PixelGrabber(img, 0, 0, w, h, pixels, 0, 256);
    try {
      pg.grabPixels();
      int bgColor = Integer.parseInt(bgColorStr,16);
      double beforeTileCheck = Time.current();
      for (int pixel : pixels) {
        if ((pixel&0xFFFFFF) != bgColor) { // just check RRGGBB
          return true;
        }
      }
      double afterTileCheck = Time.current();
      if ((debugOptions & D_CONNECTION) != 0) midas.info("[DEBUG CONNECTION] tile !BGCOLOR check time:"+(afterTileCheck -beforeTileCheck));
    } catch (Exception e) {
      warn("issue with checking if image is just background color:"+e);
    }
    return false;

  }

  // new to make sure even error stream emptied - thanks Allen
  private void emptyErrorStream (HttpURLConnection conn) {
    try {
      InputStream es = conn.getErrorStream();
      int ret = 0;
      byte[] buf = null ;
      while ((ret = es.read(buf)) > 0) {
         //proccesBuf(buf);
      }
    }
    catch (IOException ex) {
      warn("LayerMap: unable to close error stream on conn:"+conn);
    }
  }

  /** Gets a blank image with the given height and width.
      @param text    The text to display.
      @param request The download request.
      @return An image with the given dimensions.
   */
  private BufferedImage getBlankImage (String text, DownloadRequest request, Color fgcolor) {
    Color bgcolor = TRANSPARENT;

    BufferedImage img = lastBlankImage;

    int w = request.width;
    int h = request.height;

    if ((img == null) || !text.equals(lastBlankText) || (img.getHeight() != h) || (img.getWidth() != w)) {

      if ((w > 4) && (h > 4)) {
        img = new BufferedImage(w,h,BufferedImage.TYPE_INT_ARGB);
        Graphics g = img.getGraphics();
        g.setColor(bgcolor);
        g.fillRect(0, 0, w, h);

        g.setColor(fgcolor);
        g.drawRect(1, 1, w-2, h-2);
        if ((w > 36) && (h > 36)) {
          g.setClip(2, 2, w-4, h-4);
          g.drawString(text, 4, 16);
        }
        g.dispose();
        lastBlankImage = img;
        lastBlankText  = text;
      }
    }
    return img;
  }

  /** Prints an info message. */
  protected void info (String msg) { midas.info(className+": "+msg); }

  /** Prints a warning message. */
  protected void warn (String msg) { midas.warning(className+": "+msg); }

  /** Prints a warning message. */
  protected void warn (String msg, Throwable t) {
    if (debug) midas.printStackTrace(className+": "+msg, t);
    else       midas.warning(className+": "+msg+": "+t.getMessage());
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Methods for managing download threads
  //////////////////////////////////////////////////////////////////////////////////////////////////

  @Override
  public void close() {
    setDownloadThreads(0); // Stops all download threads
    super.close();
  }

  /** Sets the number of download threads to use. The threads will be used to load images from the
      server into the local cache. The download process works like this:
      <pre>
        Plot Drawing Thread
        ===================
                 +-------------------+      YES
        START---&gt;| Is image in local |--------------------------------------+
        DRAW     | image cache?      |                                      |
                 +-------------------+                                      |
                         | NO                                               |
                         v                                                  v
                 +-------------------+    +-------------------+    +-------------------+
                 | Using download    | NO | Download image in |    | Plot image from   |
                 | threads?          |---&gt;| current thread and|---&gt;| local image cache |
                 |                   |    | add image to cache|    |                   |
                 +-------------------+    +-------------------+    +-------------------+
                         | YES                                              |
                         v                                                  |
                 +-------------------+                                      v
                 | Add image to queue|-----------------------------------&gt; DONE (plot can continue
                 | for downloading.  |                                           with other layers)
                 +-------------------+

        Download Thread
        ===============           +-----+
                                  v     | NO (wait N seconds and try again)
                 +-------------------+  |
        START---&gt;| Any images to     |--+
        THREAD   | download?         |&lt;-------+&lt;----------------+
                 +-------------------+        |                 |
                         | YES                |                 |
                         v                    |                 |
                 +-------------------+        |                 |
                 | Download image and|        |                 |
                 | add image to local|        |                 |
                 | image cache.      |        |                 |
                 +-------------------+        |                 |
                         |                    |                 |
                         v                    |                 |
                 +-------------------+   NO   |        +-------------------+
                 | Is that the last  |--------+  YES   | Ready to draw, ask|
                 | image to download?|----------------&gt;| plot to redraw    |
                 +-------------------+                 +-------------------+
      </pre>
      Note that some subclasses may impose a limit on the number of download threads where such is
      applicable to that layer/protocol.
      @param count Number of download threads to use (0=None, download in current thread). Be
                   careful not to set this too high because it will cause the threads
                   to get stuck in a "download loop" where it is always downloading new images.
      @since NeXtMidas 2.5.0
   */
  public void setDownloadThreads (int count) {
    DownloadThread[] toStop;
    synchronized (getLockObj()) {
      toStop = downloadThreads;
      if (count == 0) {
        downloadThreads = null;
      } else {
        downloadThreads = new DownloadThread[count];
        for (int i = 0; i < downloadThreads.length; i++) {
          downloadThreads[i] = new DownloadThread(this, ""+instance+"."+i);
          downloadThreads[i].start();
        }
      }
    }
    if (toStop != null) {
      for (int i = 0; i < toStop.length; i++) {
        toStop[i].abort();
      }
    }
  }

  /** Gets the number of download threads to use.
      @return Number of download threads to use (0=None, download in current thread).
      @since NeXtMidas 2.5.0
   */
  public int getDownloadThreads () {
    return (downloadThreads == null)? 0 : downloadThreads.length;
  }

  /** Gets a download request from the queue.
      Must synchronize on geLockObj() before calling this.
      @return The request or null if there are no requests in the queue.
   */
  DownloadRequest getDownloadRequest () {
    DownloadRequest request = null;
    synchronized (getLockObj()) {
      if (!toDownload.empty()) {
        request = toDownload.pop();
      }
    }
//jph 03MAR08 -- DR 1230:
// If I do not comment out this, the cursor immmediately resets to default
//  when it should show wait cursor.
//    if (request != null) setProgress(progress());
    return request;
  }

  /** Adds a download request to the queue.
      @param request The download request.
   */
  void addDownloadRequest (DownloadRequest request) {
    boolean skip = toDownload.contains(request);

    for (int i = 0; !skip && (i < downloadThreads.length); i++) {
      skip = request.equals(downloadThreads[i].getRequest());
    }

    if (!skip) {
      if (debug) info("Adding to download queue URL="+request);
      synchronized (getLockObj()) {
        toDownload.push(request);
      }
    }
  }

  /** Called by the download thread when a download has completed.
      Must synchronize on geLockObj() before calling this.
      @param request  The original request.
      @param img      The image downloaded.
   */
  void imageDownloaded (DownloadRequest request, BufferedImage img) {
    if (img != null) imgCache.put(request.url, img);
    int prog = progress();

    setProgress(prog);
    if (prog == -1) {
      refresh();
    }
  }

  /** Current download progress. */
  private int progress () {
    int count;
    synchronized (getLockObj()) {
      count = toDownload.size();
      if (downloadThreads != null) {
        for (int i = 0; i < downloadThreads.length; i++) {
          if (downloadThreads[i].isDownloading()) count++;
        }
      }
    }

    int status;
    if (count == 0) {
      status = -1;
    } else {
      status = (int)(((float)getCurrentCacheSize() - (float)count) * 100f / (float)getCurrentCacheSize());
    }
    return status;
  }

  /** Number of retries on image if image is solid background color
   *
   * @param num number of retries
   */
  @ProvisionalUseOnly("experimental in 4.0.0")
  public void setNumRetriesIfBGColor (int num) {
    this.numRetriesIfBGColor = num;
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Inner Classes
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Defines an exception. */
  @SuppressWarnings("serial")
  protected static class MapServerException extends MidasException {
    /** Error message in image? */
    private final boolean inImage;

    /** Creates a new instance with the given message and nested exception.
        @param msg     Error message.
     */
    protected MapServerException (String msg) {
      this(msg,false);
    }

    /** Creates a new instance with the given message and nested exception.
        @param msg     Error message.
        @param t       Any nested exception.
     */
    protected MapServerException (String msg, Throwable t) {
      this(msg,t,false);
    }

    /** Creates a new instance with the given message and nested exception.
        @param msg     Error message.
        @param inImage Is the error described in-image?
     */
    protected MapServerException (String msg, boolean inImage) {
      super(msg);
      this.inImage = inImage;
    }

    /** Creates a new instance with the given message and nested exception.
        @param msg     Error message.
        @param t       Any nested exception.
        @param inImage Is the error described in-image?
     */
    protected MapServerException (String msg, Throwable t, boolean inImage) {
      super(msg,t);
      this.inImage = inImage;
    }

    /** Is the error described in-image? */
    public boolean isInImage() { return inImage; }

    @Override
    public String toString () {
      return super.toString() + " inImage="+inImage;
    }
  }

  /** A download request. */
  private static class DownloadRequest {
    private final String  url;          // The URL of the image to get.
    private final int     trys;         // The number of tries.
    private final boolean checkLength;  // Check for valid content length (true=Error when len<0, false=Skip check).
    private final int     width;        // Expected width.
    private final int     height;       // Expected width.


    DownloadRequest (String url, int trys, boolean checkLength, int width, int height) {
      this.url         = url;
      this.trys        = trys;
      this.checkLength = checkLength;
      this.width       = width;
      this.height      = height;
    }

    @Override
    public String toString () {
      return url;
    }

    @Override
    public boolean equals (Object obj) {
      if ((obj == null) || !(obj instanceof DownloadRequest)) return false;

      DownloadRequest req = (DownloadRequest)obj;

      return url.equals(req.url) && (width == req.width) && (height == req.height)
                                 && (trys == req.trys) && (checkLength == req.checkLength);
    }

    @Override
    public int hashCode () {
      return url.hashCode() ^ width ^ height ^ trys ^ ((checkLength)? 1 : 0);
    }
  }

  /** Download thread used for downloading images. */
  private static class DownloadThread extends Thread {
    // Note: This class can not hold onto a direct pointer to the layer because this will
    //       prevent the layer from being finalized.

    private final WeakReference<LayerMap> layer;       // The associated layer
    private final double          minWait;             // Minimum wait time (ms).
    private final double          maxWait;             // Maximum wait time (ms).
    private       boolean         abort       = false; // Is the thread done?
    private       boolean         downloading = false; // Currently downloading?
    private       DownloadRequest request     = null;  // Current download request.

    DownloadThread (LayerMap layer, String threadID) {
      super(layer.getClass().getName() + ": DownloadThread "+threadID);
      this.layer   = new WeakReference<>(layer);
      this.minWait = 0.01;
      this.maxWait = 0.10;
    }

    @Override
    public void run () {
      double   wait = minWait;
      LayerMap lay;

      while (!abort) {
        lay = layer.get();

        if (lay == null) break;

        synchronized (lay.getLockObj()) {
          request = lay.getDownloadRequest();
          downloading = (request != null);
        }

        if (request != null) {
          BufferedImage img = lay.downloadImage(request);
          synchronized (lay.getLockObj()) {
            downloading = false;
            lay.imageDownloaded(request, img);
            request = null;
          }
          wait = minWait; // reset wait time
        } else {
          Time.sleep(wait);
          wait = Math.min(wait * 2, maxWait);
        }
      }
    }

    public void abort () {
      this.abort = true;
      try {
        this.join();
      } catch (InterruptedException e) {
        Shell.printStackTrace(e); // This should be quite rare
      }
    }

    /** Gets the current download request, null if none is being downloaded. */
    private DownloadRequest getRequest() { return request; }

    /** Is the thread in the process of downloading? */
    private boolean isDownloading () { return downloading; }
  }

  /** Set Debug Options
   *
   *  @param arg pipe separated list of debug options
   */
  public void setDebug (String arg) {
    debugOptions = Parser.mask(debugList,arg,debugOptions);
  }
}