package nxm.sys.libg;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Point;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Vector;

import javax.swing.JFrame;
import javax.swing.JOptionPane;

import nxm.sys.inc.Installation;
import nxm.sys.inc.InternalUseOnly;
import nxm.sys.lib.Convert;
import nxm.sys.lib.FileUtil;
import nxm.sys.lib.MidasException;
import nxm.sys.lib.Parser;
import nxm.sys.lib.Shell;
import nxm.sys.lib.StringUtil;
import nxm.sys.lib.Table;
import nxm.sys.lib.XmlFile;

/** A class that plots images loaded from a Web Map Server (WMS) compliant with the OpenGIS WMS
    protocol specifications.

    @author  J. Czechowski IV
    @since   NeXtMidas 2.3.1
*/
public class LayerWMS extends LayerMap {
  // Good test cases for version 1.1.1:
  //    http://iq.usgs.gov/cgi-bin/nuiwms.exe
  //    http://webhosts.cr.usgs.gov/wmsconnector/com.esri.wms.Esrimap/nm_overview (Bad SRS)
  //    http://www.geographynetwork.ca/wmsconnector/com.esri.wms.Esrimap/OBM_Full_I
  //    http://wms.co.fulton.ga.us/wms/public   (Has Styles)
  //
  // Good test cases for version 1.3.0:
  //    http://nextmidas.com:8888/wms/mapServer
  //

  // KEY NAMES /////////////////////////////////////////////////////////////////////////////////////
  //   - These names used in local layers tables
  //   - Package private to allow config window to access
  //   - May change without notice!!!
  static final String CRS_KEY     = "LOCAL_CRS";
  static final String STYLE_KEY   = "LOCAL_STYLE";
  static final String STYLES_KEY  = "LOCAL_STYLES";
  static final String LAYERS_KEY  = "LOCAL_LAYERS";
  static final String ENABLED_KEY = "LOCAL_ENABLED";

  // INTERNAL DEFINITIONS //////////////////////////////////////////////////////////////////////////
  private static final String  DEF_BGCOLOR = "0x000066"; // Default background color.
  private static final String  WMS_111     = "1.1.1";    // Identifier for protocol version 1.1.1
  private static final String  WMS_130     = "1.3.0";    // Identifier for protocol version 1.3.0
  private static final String  EX_XML      = "XML";      // Exception format: XML (required)
  private static final String  EX_INIMAGE  = "INIMAGE";  // Exception format: INIMAGE (optional)
  private static final String  EX_BLANK    = "BLANK";    // Exception format: BLANK (optional)
  private static final String  EX_MIMEPRE  = "application/vnd.ogc.se_";      // MIME type prefix used in 1.1.1
  private static final String  XLINK_XLINK = "http://www.w3.org/1999/xlink"; // Required xlink:xlink in 1.1.1 and 1.3.0
  private static final String  XLINK_TYPE  = "simple";   // Required xlink:type in 1.1.1 and 1.3.0
  private static final int     DEFAULT_MAX_TRYS = 1;     // Default Max tries on downloading image
  private static final int     DOWNLOAD_THREADS = 32;    // 4.0.0 introduced a new default number of download threads to address slow map loading
  private static final int     OLD_DOWNLOAD_THREADS = 4; // old number of download threads default
  private static       boolean useOldDownloadThreadsDefault = false; // use the pre-4.0.0 default
  private static final String  GEOWEBCACHE_CRS = "EPSG:4326"; // standard GeoWebCache CRS that NeXtMidas supports

  private static final String[] INHERIT_REP_KEYS         // Inherited keys, used in getWmsLayer(..)
          = { "EX_GEOGRAPHICBOUNDINGBOX", "BOUNDINGBOX", "DIMENSION", "ATTRIBUTION",
              "MINSCALEDENOMINATOR", "MAXSCALEDENOMINATOR", "QUERYABLE", "CASCADED",
              "OPAQUE", "NOSUBSETS", "FIXEDWIDTH", "FIXEDHEIGHT" };
  private static final String[] INHERIT_ADD_KEYS         // Inherited keys, used in getWmsLayer(..)
          = { STYLES_KEY /*, "AUTHORITYURL"*/ };

  // EXTERNAL DEFINITIONS //////////////////////////////////////////////////////////////////////////

  /** The list of OpenGIS WMS protocol versions supported in increasing order
      (<code>value={@value}</code>). The OpenGIS WMS specs assume that the newest version
      supported by both the client and server is the best choice.
   */
  public static final String WMS_VERSIONS = WMS_111+","+WMS_130;

  /** The list of image types supported by this client in order of preference, with the most
      preferable one last (<code>value={@value}</code>).
   */
  public static final String IMAGE_TYPES = "image/jpeg,image/gif,image/png";

  /** The list of exception types supported by this client in order of preference, with the most
      preferable one last (<code>value={@value}</code>).
   */
  public static final String EXCEPTION_TYPES = EX_BLANK+","+EX_XML+","+EX_INIMAGE;


  // VARIABLES /////////////////////////////////////////////////////////////////////////////////////
  //   Variable naming scheme:
  //     supXxxs     - List of options supported by client.
  //     wmsXxx      - Option selected to match server, or option that matches server capabilities
  //     [other]     - Other variables
  //
  protected String  requestedVersion   = "";                // WMS protocol versions specifically requested
  protected String  supVersions        = WMS_VERSIONS;      // WMS protocol versions supported
  protected String  supImageTypes      = IMAGE_TYPES;       // Image types supported
  protected String  supExceptionTypes  = EXCEPTION_TYPES;   // Exception types supported

  protected String  wmsVersion         = null;              // WMS protocol version (null=NotConnected)
  protected String  wmsFormat          = null;              // Image format (MIME type) to use
  protected String  wmsExceptions      = null;              // Exception format to use
  protected String  wmsGetMapUrl       = null;              // URL for GetMap requests (ends in '?')
  protected Color   wmsBgColor         = null;              // Background color
  protected boolean wmsTransparent     = false;             // Should layers be transparent?
  protected int     wmsMaxWidth        = Integer.MAX_VALUE; // Maximum width in pixels
  protected int     wmsMaxHeight       = Integer.MAX_VALUE; // Maximum width in pixels

  protected Table   capabilities       = null;              // The server's capabilities
  protected Table   allLayersTable     = null;              // The layers available from the server
  protected Vector<Table> allLayersList= null;              // A "flat" view of allLayersTable (in priority order)
  protected Vector<Table> allLocalCrs  = null;              // All locally-valid CRS options
  protected int     hiResMultiplier    = 2;                 // Multiplier to use in high-res mode
  protected String  status             = null;              // Status message
  protected LayerWmsConfig configPanel = null;              // Configuration Panel
  protected JFrame  configFrame        = null;              // Frame holding configPanel
  protected String  pendingLayers      = null;              // Layers to use (waiting to be set)
  private   String  pendingLayer       = null;              // Layer to use when doing layer transition and initialization is not complete
  private   String[] layersSet = null;                      // Layers set initially specified when layer transitions are enabled

  private GraphicsDevice gdev   = null;                     // Where to put the config menu (Bug 2580) (Since 3.1.1)
  private boolean        popped = false;                    // whether the config menu was set for a popped panel
  private boolean        autoConnect=true;                  // Whether to automatically connect to the server during setUrl
  /** since NeXtMidas 3.5.3 (and deprecated) defaults to false (it was implicitly true in prior versions) */
  private boolean allowSpaceAsDelimInSetLayers = Installation.ApplyBugFix.PLOT_LAYERWMS_ALLOW_SPACE_AS_DELIMITER;
  /** since 4.0.0 assume XML format for Exceptions if Exception Element block not specified */
  private boolean requiringExceptionElementBlock = false;
  /** Ignore the HREF provided by GetCapabilities and use original URL - avoid waiting for timeout */
  private boolean ignoreHREF = false;
  /** Layer transition points - based view area diameters pixel vs degrees */
  private double[] transitions = null;
  /** Transitions based on GWC EPSG 4326 Gridset levels */
  private boolean  usingGWCGridsetLevels = false;

  private static String urlWMSCoordSys; // NXM-3755 since NeXtMidas 4.0.1

  /** Zoom level to which we hold EPSG:4326 grid bounds */
  private static final int    GWC_BOUNDS_ZOOM_LEVEL = 12;
  /** Number of entries, besides an entry for 0, for the zoom level supported (add a power of 2 to support latitude) */
  private static final double GWC_NUM_GRID_BOUNDS   = Math.pow(2,GWC_BOUNDS_ZOOM_LEVEL+1);
  /** Array to hold GeoWebCache compatible bounds - only initialized if checking becomes enabled */
  private static double[] gwcCompatibleGridBounds = null;

  /** List of debug readout options. <br>
    Possible values = <code>{@value}</code>
  */
  public static final String debugList = "Capabilities,URLs,Transitions";
  private static final String ucDebugList = debugList.toUpperCase();
  /** Debug Option: List Capabilities Response. */
  public static final int D_CAPABILITIES =    0x0001;
  /** Debug Option: List Request URLs */
  public static final int D_URLS         =    0x0002;
  /** Debug Option: Transitions debug information. */
  public static final int D_TRANSITIONS  =    0x0004;

  private int debugOptions = 0;

  /* debug option to check if all URLs follow GeoWebCache guidelines */
  private static boolean gwcCompatibilityChecking = false;
  /* all URLs have been GeoWebCache compliant */
  private static boolean gwcCompatibleURLs;

  static {
    // This is added so that LayerWMS unit tests can access wms_coord_sys.xml during a Gradle build command
    ClassLoader loader = LayerWMS.class.getClassLoader();
    urlWMSCoordSys = FileUtil.changePathSeparator(loader.getResource("nxm/sys/cfg/wms_coord_sys.xml").toString(),"/");
  }


  /** Creates a new instance. */
  public LayerWMS () {
    super("WMS");
    setCoordRefSystems(null);
    int numDownloadThreads = (useOldDownloadThreadsDefault) ? OLD_DOWNLOAD_THREADS : DOWNLOAD_THREADS;
    setDownloadThreads(numDownloadThreads);
    status = "Not connected, URL not set yet.";
  }

  public Table getConfig () {
    Table  tbl = super.getConfig();
    String lay = getLayers();
    String bg  = (wmsBgColor == null)? null: MColor.toString(wmsBgColor);

    if (lay != null   )        { tbl.put("LAYERS",      lay           ); }
    if (bg  != null   )        { tbl.put("BGCOLOR",     bg            ); }
    if (wmsTransparent)        { tbl.put("TRANSPARENT", wmsTransparent); }
    if ((transitions != null)) { tbl.put("TRANSITIONS", Arrays.toString(transitions)); }
    if (getGeoWebCache())      { tbl.put("GEOWEBCACHE", true); }

    return tbl;
  }

  /** Sets the URL (or comma-separated list of URLs) of the applicable map server. <br><br>
      Note: Since 3.3.2, a comma-separated list of URLs can be passed in to this method.
      If the first URL on the list is not reachable, then the Layer will attempt to connect to
      the second URL in the list, and so on.<br><br>
      Note: Unless the autoConnect boolean is set to false (since NeXtMidas 3.3.3) , this method
      automatically connects  to the given URL, so all other settings should be already set.
   */
  public void setURL (String url) {
    if (url==null) disconnect();   // disconnect if passed in null
    url = updateMapServerList(url);  // add to list of Map Servers (since 3.3.2 - Bug 2581)
    super.setURL(url);
    if (mapServer == null) {       // url not reachable, so try next one - since 3.3.2
      for (String wmsUrl : mapServerList) {
        super.setURL(wmsUrl);
        if (mapServer!=null) {
          if (autoConnect) connect();  // only connect if auto connect is true - since 3.3.3
          return;
        }
      }
      status = "Not Connected";
    } else {
      if (autoConnect) connect();  // only connect if auto connect is true - since 3.3.3
    }
  }

  /** Connect to the WMS Server (previously set via {@link #setURL(String)}).
      @since NeXtMidas 3.3.3.
   */
  public void connect () {
    if (mapServer == null) {
      for (String wmsUrl : mapServerList) {
        super.setURL(wmsUrl);
        if (mapServer!=null) break;
      }
      if (mapServer == null) {
        status = "Not Connected";
        warn("Map Server URL not set or unreachable. Please call setURL(String URL) with appropriate WMS Sevrer URL.");
        return;
      }
    }

    status = "Connecting to "+getHostPort();
    wmsVersion = null;
    loadCapabilities();
    checkInit();
    setNeedsRescale(true);
  }

  /** Disconnect from the WMS Map Server.
      @since 3.3.3
   */
  @Override
  public void disconnect () {
    wmsVersion = null;
    mapServer = null;
    setNeedsRescale(true); // this is needed for so that findRange is called for BUG 1349
    refresh();
  }

  /** Set autoConnect to indicate setURL should automatically connect to the Map Server.
      @param newVal true to automatically connect to the Map Server
      @since 3.3.3
   */
  public void setAutoConnect (boolean newVal) {
    autoConnect = newVal;
  }

  /** Get the autoConnect indicator
      @return true to automatically connect to the Map Server, otherwise false
      @since 3.3.3
   */
  public boolean isAutoConnect() {
    return autoConnect;
  }

  /** Status string used in config window. */
  String getStatus () {
    return status;
  }

  /** Gets the high-resolution multiplication factor.
      You should NEVER need to use this method!
      @return the high-resolution multiplication factor
   */
  public int getHiResMultiplier () {
    return hiResMultiplier;
  }

  /** Sets the high-resolution multiplication factor.
      You should NEVER need to use this method, it always issues a warning.
      @param mult the high-resolution multiplication factor
   */
  public void setHiResMultiplier (int mult) {
    warn("HiResMultiplier has been changed from "+hiResMultiplier+" to "+mult+". "
        +"You should NEVER need to use this method!");
    hiResMultiplier = mult;
  }

  /** Override some of the internal parameters of this class. Overridable parameters include:
      <pre>
        WMS_VERSIONS    - Override {@link #WMS_VERSIONS}
        IMAGE_TYPES     - Override {@link #IMAGE_TYPES}
        EXCEPTION_TYPES - Override {@link #EXCEPTION_TYPES}
      </pre>
      @param tbl parameter to override
      @deprecated Override some of the internal parameters of this class. This has always been
                  deprecated. It is only here for use in debug the client, and may be
                  removed/changed at any time without notice!
   */
  @Deprecated
  @InternalUseOnly
  public void setInternalParams (Table tbl) {
    warn("************************ WARNING ************************");
    warn("* LayerWMS: Internal parameters are being overridden.   *");
    warn("*   This may cause LayerWMS to malfunction, use at your *");
    warn("*   own risk!                                           *");
    warn("*********************************************************");
    supVersions       = tbl.getS("WMS_VERSIONS",supVersions);
    supImageTypes     = tbl.getS("IMAGE_TYPES",supImageTypes);
    supExceptionTypes = tbl.getS("EXCEPTION_TYPES",supExceptionTypes);
    setRequestedVersion(this.requestedVersion); // since 4.0.0 for NXM-3692
  }

  /** Sets the list of locally-valid Coordinate Reference Systems (CRS) allowed. See
      <code>nxm/sys/cfg/wms_coord_sys.xml</code> for more info.
      @param table A table of the valid coordinate systems (or null to use the defaults).
   */
  public synchronized void setCoordRefSystems (Object table) {
    if (table == null) {
      table = XmlFile.fastXmlFileToTable(midas, urlWMSCoordSys);
    }
    Table tbl = Convert.o2t(table, midas);

    if (tbl == null) {
      throw new MidasException("LayerWMS: Unable to load CRS from "+table);
    }

    allLocalCrs  = new Vector<>();
    String[] keys = tbl.getKeys();
    for (int i = 0; i < keys.length; i++) {
      allLocalCrs.add(tbl.getTable(keys[i]));
    }
  }

  /** Set the local CRSs based on a comma separated set of CRS names
   *  and using nxm.sys.cfg.wms_coord_sys.xml to grab the associated table.<br>
   *  Note: This allows a user to work with a map server that returns a getCapabilities
   *  with more CRSs than it truly support. If crsNames is empty, the default CRS
   *  table will be used.
   *
   *  @param crsNames comma separates CRS names of CRSs to be in the local CRS table
   *  @since NeXtMidas 4.0.1 for NXM-3743
   */
  @InternalUseOnly
  public synchronized void setByNameCRSs (String crsNames) {

    Table  origTable = XmlFile.fastXmlFileToTable(midas, urlWMSCoordSys);

    if (StringUtil.isEmpty(crsNames)) {
      // use defaults from XML
      allLocalCrs  = new Vector<>();
      String[] keys = origTable.getKeys();
      for (int i = 0; i < keys.length; i++) {
        allLocalCrs.add(origTable.getTable(keys[i]));
      }
    }
    else {
      // make a Table with Key being NAME and Value being CRS Table KEY
      Table nameKey = new Table();
      for (String key : origTable) {
        Table crsTableEntry = Convert.o2t(origTable.get(key));
        String name = crsTableEntry.get("NAME").toString();
        nameKey.put(name, key);
      }
      // selectively grab values from XML, based on CRS name
      String[] selectedCRSs = crsNames.split(",");

      allLocalCrs  = new Vector<>();

      for (String crsName : selectedCRSs) {
        String origKey = nameKey.get(crsName.trim()).toString();
        Table crsTable = origTable.getTable(origKey);
        if (crsTable != null) allLocalCrs.add(crsTable);
      }
    }
  }

  /** Gets a COPY of the list of all locally-valid Coordinate Reference Systems (CRS) allowed.
   *  @return a COPY of the list of all locally-valid Coordinate Reference Systems (CRS) allowed */
  public synchronized Table getCoordRefSystems () {
    Table tbl = new Table();
    for (int i = 0; i < allLocalCrs.size(); i++) {
      Table crs = allLocalCrs.get(i);
      tbl.put("CRS_"+i, crs.copy());
    }
    return tbl;
  }

  /** Indicates if the background should be transparent.
      @param transparent true if the background should be transparent
      @since NeXtMidas 2.4.2
   */
  public void setTransparent (boolean transparent) {
    if (wmsTransparent != transparent) {
      wmsTransparent = transparent;
      refresh();
    }
  }

  /** Indicates if the background should be transparent.
      @return true if the background should be transparent
      @since NeXtMidas 2.4.2
   */
  public boolean getTransparent () {
    return wmsTransparent;
  }

  /** Sets the background color for the plot (usually used for the oceans, null=UseDefault).
   *  @param color the background color */
  public void setBgColor (Object color) {
    Color bgcolor = MColor.getColor(color);
    if (bgcolor != wmsBgColor) {
      wmsBgColor = bgcolor;
      refresh();
    }
  }

  /** Gets the background color for the plot (usually used for the oceans, null=UseDefault).
   *  When GWC=TRUE, BGCOLOR will not be included in request, in order to get cached results.
   *  The default BGCOLOR, when not specified, is white.
   *  @return the background color */
  public String getBgColor () {
    if (getGeoWebCache()) return Color.WHITE.toString();
    return (wmsBgColor == null)? DEF_BGCOLOR: MColor.toString(wmsBgColor);
  }

  /** Gets the background color as a 6-digit hex value (i.e. 0xRRGGBB). This method follows the
      OpenGIS specs, note that the 'x' must be lower-case.
   */
  private String getBgColorHex () {
    String bgcolor = DEF_BGCOLOR;
    if (wmsBgColor != null) {
      int rgb = 0x00FFFFFF & wmsBgColor.getRGB();
      bgcolor = "0x"+StringUtil.padLeft(Integer.toHexString(rgb), 6, '0');
    }
    return bgcolor;
  }

  @Override
  public boolean isConnected () {
    return (wmsVersion != null);
  }

  /** Get the capabilities for the server.
   *  @return copy of capabilities Table
   */
  public Table getCapabilities () {
    return capabilities.copy();
  }

  /** Sets the range appropriate for a map of the world. */
  @Override
  public void findRange () {
    super.findRange(); // Start with default values.

    if (allLayersList != null) {
      boolean rangeSet = false;
      @SuppressWarnings("unchecked")
      Vector<Table> layersList = (Vector<Table>) allLayersList.clone();
      for (Table info : layersList) {
        Table boundingBox = info.getTable("EX_GEOGRAPHICBOUNDINGBOX");
        if (boundingBox != null) {
          if (!rangeSet) {
            rangeSet = true;
            x1 = boundingBox.getD("WESTBOUNDLONGITUDE",x1);
            x2 = boundingBox.getD("EASTBOUNDLONGITUDE",x2);
            y1 = boundingBox.getD("SOUTHBOUNDLATITUDE",y1);
            y2 = boundingBox.getD("NORTHBOUNDLATITUDE",y2);
          } else {
            x1 = Math.min(x1, boundingBox.getD("WESTBOUNDLONGITUDE",x1));
            x2 = Math.max(x2, boundingBox.getD("EASTBOUNDLONGITUDE",x2));
            y1 = Math.min(y1, boundingBox.getD("SOUTHBOUNDLATITUDE",y1));
            y2 = Math.max(y2, boundingBox.getD("NORTHBOUNDLATITUDE",y2));
          }
        }
      } // end for loop
    }

    // Set Minimum and Maximum actual auto-scale value (since 3.7.0 for NXM-2984)
    a1 = y1;
    a2 = y2;
  }

  /** Get the information about all layers available from the server.
      @return The layers in a standard tree structure. This returns null if info has
              not yet been loaded from server.
   */
  Table getAllLayersTable () {
    return allLayersTable;
  }

  /** Get the information about all layers available from the server.
      @return The layers in a "flat" priority order. This returns null if info has
              not yet been loaded from server.
   */
  List<Table> getAllLayersList () {
    return allLayersList;
  }

  /** Get the information about a single layer available from the server. Must provide the layer
      access key.
   */
  Table getAvailableLayer (String key) {
    return (key.equals(""))? getAllLayersTable(): getAllLayersTable().getTable(key);
  }

  /** Get the information about a single layer available from the server. Must provide the layer access key. */
  synchronized Table findAvailableLayer (String name) {
    Table layer = (name.equals(""))? getAllLayersTable(): null;

    if (layer == null) {
      int index = findLayer(name);
      if (index >= 0) {
        layer = allLayersList.get(index);
      }
    }

    return layer;
  }

  /** Get the information about a single layer available from the server. Must provide the layer
      access key.
   */
  Table findAvailableLayer (int index) {
    Table layer = null;
    if ((allLayersList != null) && (index >= 0) && (index < allLayersList.size())) {
      layer = allLayersList.get(index);
    }
    return layer;
  }

  /** Get the information about a single layer available from the server. Must provide the layer
      access key.
   */
  private int findLayer (String name) {
    int index = -1;

    for (int i = 0; (index < 0) && (i < allLayersList.size()); i++) {
      Table  lay     = allLayersList.get(i);
      String layName = lay.getS("NAME");
      if (name.equals(layName)) {
        index = i;
      }
    }
    return index;
  }

  /** Translates layer name into key name for that layer (note: all layers have a key, but not
      all layers have a name).
   */
  String findLayerKey (String name) {
    String key = null;

    if (name == null) {
      key = null;
    } else if (name.equals("")) {
      key = "";
    } else {
      key = findLayerKey(name, allLayersTable, "");
    }
    return key;
  }

  /** Translates layer name into key name. */
  private synchronized String findLayerKey (String name, Table layer, String prefix) {
    Table  layers  = (layer == null)? null: layer.getTable(LayerWMS.LAYERS_KEY);
    String key     = null;
    String layName = layer.getS("NAME", null);

    if ((layName != null) && layName.equals(name)) {
      key = prefix;
    } else if (layers != null) {
      String[] keys = layers.getKeys();
      for (int i = 0; (key == null) && (i < keys.length); i++) {
        String tag = LAYERS_KEY + "." + keys[i];
        String dot = (prefix.length() == 0)? "": ".";

        key = findLayerKey(name, layers.getTable(keys[i]), (prefix+dot+tag));
      }
    }
    return key;
  }

  /** Shifts the order of one of the layers.
      @param name  The layer to move.
      @param index The new index of the layer.
      @return true if moved, false otherwise.
   */
  synchronized boolean moveAvailableLayer (String name, int index) {
    int     from = findLayer(name);
    int     to   = (index < from)? index: (index-1);
    boolean move = (from >= 0) && (from != index);

    if (move) {
      Table layer = allLayersList.remove(from);
      allLayersList.insertElementAt(layer,to);
      refresh();
    }
    return move;
  }

  /** Set the list of layers enabled and their styles. The layers are specified in order (first is
      bottom, same as <code>PLOT</code>) Since NeXtMidas 2.4.2, this method accepts the following
      syntax when specifying a layer:
      <pre>
         [layer]          Adds the named layer with no style setting
        +[layer]          Adds the named layer with no style setting
        -[layer]          Removes the named layer
         [layer]/[style]  Adds the named layer with the given style
        +[layer]/[style]  Adds the named layer with the given style
        -[layer]/[style]  Removes the named layer (style is ignored)
        &nbsp;*                Adds all layers with no style setting
        +*                Adds all layers with no style setting
        -*                Removes all layers
      </pre>
      If the first layer in the list is NOT specified using a "+" or "-" all previous settings
      are discarded. Some examples:
      <pre>
        1. Show ONLY the "shapePolitical" layer:

             setLayers("shapePolitical")

        2. Show the "shapePolitical" layer and the "worldRivers" layer, with
           "worldRivers" on top:

             setLayers("shapePolitical,worldRivers")

        3. Add the "worldRivers" layer on top of any existing layers, but have
           it use the "grayscale" style:

             setLayers("+worldRivers/grayscale")

        4. Include all layers, except the "worldRivers" layer:

             setLayers("*,-worldRivers")
      </pre>
      After a connection has been made to the server, it is possible to get a list of all available
      layers and all available styles for the given layer. From a macro this would look like:
      <pre>
        if reg.myplot.layers.wms.isConnected() then
          invoke allLayers reg.myplot.layers.wms.getAllLayers()

          foreach layer inList allLayers
            invoke allStyles reg.myplot.layers.wms.getAllStyles("^layer")

            info "layer='^{layer}' styles='^{allStyles}'"
          endfor
        else
          warn "Not connected"
        endif
      </pre>
      @param layerList The list of layers (in order). If a layer has a style set it should be
             listed as "[layer]/[style]". NOTE: layer names and styles are case-sensitive (this
             is a "limitation" of the WMS protocol).
      @see #getLayers()
      @see #getAllLayers()
      @see #getAllStyles(String)
   */
  public void setLayers (String layerList) {
    if (layerList == null) { layerList = ""; }

    layersSet = layerList.split(","); // NXM-3726 to support user defined layer transitions

    if (!checkInit()) {
      pendingLayers = layerList;
    } else {
      if (transitions != null) { // NXM-3726
        warn("Layer transitions are being turned off, as layers are being overriden after initial setup. New layerList:"+layerList);
        transitions = null;
      }
      doSetLayers(layerList);
      refresh();
    }
  }

  /** Set the list of layers enabled.
      @param layerList The comma-separated list of layers (in order).
   */
  private void doSetLayers (String layerList) {
    // Implementation notes:
    //   (1) This can not use the "standard" NeXtMidas mask parser since it assumes order is not
    //       important. However, it is important for map layers since it defines stacking order.
    //   (2) Prior to NeXtMidas 2.7.0, "+<layer>" and "-<layer>" could alter the stacking order,
    //       this was changed in 2.7.0 (when "~<layer>" was added) to prevent situations where
    //       "+MyLayer" followed by "-MyLayer" (or "~MyLayer" followed by "~MyLayer") resulted
    //       in a reordering.
    layerList.replace('|', ','); // <-- for the benefit of users who try a traditional mask string
    Parser parser = new Parser(layerList);
    parser.setUseSpaceDelimiter(isAllowSpaceAsDelimiter()); // since 3.5.3 defaults to false b/c spaces can appear in layer names (BUG 2880)

    for (int i = 1; i <= parser.elements(); i++) {
      String  layer   = parser.get(i);
      String  style   = "";
      boolean reorder = true;
      boolean enabled = true;
      boolean toggle  = false;

      if (layer.startsWith("-")) {
        reorder = false;
        enabled = false;
        layer   = layer.substring(1);
      } else if (layer.startsWith("+")) {
        reorder = false;
        enabled = true;
        layer   = layer.substring(1);
      } else if (layer.startsWith("~")) {
        reorder = false;
        toggle  = true;
        layer   = layer.substring(1);
      } else if (i == 1) {
        // Defining all layers from scratch, clear out current settings then enable.
        doSetAllLayers(false,"");
      }

      int index = layer.indexOf('/');
      if (index > 0) {
        style = layer.substring(index+1);
        layer = layer.substring(0,index);
      }

      if (layer.equals("*")) {
        doSetAllLayers(enabled,style);
      } else {
        Table info = findAvailableLayer(layer);
        if (info == null) { // since 3.5.3, try trimmed layer name since new default does not allowing space as delimiter in Parser (BUG 2880)
          info = findAvailableLayer(layer.trim()); // so that layerList="name1, name2" will find "name2" vs not finding " name2"
        }
        if (info == null) {
          warn("Unknown layer '"+layer+"' in '"+layerList+"', note that layer names are case-sensitive.");
        }
        else {
          if (toggle) {
            enabled = !info.getState(ENABLED_KEY, false);
          }
          info.put(ENABLED_KEY, enabled);
          info.put(STYLE_KEY,   style);
          if (enabled && reorder) {
            allLayersList.remove(info);
            allLayersList.add(info);
          }
        }
      }
    }
  }

  /** Clears any set layers. */
  private void doSetAllLayers (boolean enabled, String style) {
    for (int i = 0; i < allLayersList.size(); i++) {
      Table info = allLayersList.get(i);
      info.put(ENABLED_KEY, enabled);
      info.put(STYLE_KEY,   style);
    }
  }

  /** Get the list of available styles for a given layer.
      @param layer The name of the layer (case-sensitive). If the layer is listed as "[name]/[style]"
                   the style part is ignored.
      @return The list of styles as a comma-separated list. If no styles are available the list
              will be blank.
      @see #getLayers()
      @since NeXtMidas 2.4.2
   */
  public String getAllStyles (String layer) {
    if (layer.indexOf('/') > 0) {
      layer = layer.substring(0,layer.indexOf('/'));
    }

    String styles = null;
    Table  info   = findAvailableLayer(layer);

    if (info == null) {
      if (isConnected()) {
        warn("Unknown layer='"+layer+"', note that layers are case-sensitive.");
      }
    } else {
      Table        tbl   = info.getTable(STYLES_KEY);
      String[]     keys  = tbl.getKeys();
      StringBuilder list = new StringBuilder();

      for (int i = 0; i < keys.length; i++) {
        if (i != 0) list.append(',');
        list.append(tbl.getTable(keys[i]).getS("NAME"));
      }
      styles = list.toString();
    }
    return styles;
  }

  /** Get the information about all layers available from the server.
      @return The list of layers as a comma-separated list. If a layer has a style set it will
              be listed as "[layer]/[style]". This returns null if info has not yet been loaded
              from server.
      @see #getLayers()
   */
  public String getAllLayers () {
    return (checkInit())? getLayers(true): null;
  }

  /** Get the list of layers enabled.
      @return The list of layers as a comma-separated list. If a layer has a style set it will
              be listed as "&lt;layer&gt;/&lt;style&gt;".
   */
  public String getLayers () {
    return getLayers(false);
  }

  /** Get the list of layers enabled. */
  private String getLayers (boolean all) {
    StringBuilder layers = new StringBuilder(); // Lowest priority listed last

    if (!checkInit()) {
   // base this on pendingLayers if we do not have getCapabilities information
      if ((transitions == null) || all || (pendingLayer==null)) {
        layers.append(pendingLayers);
      }
      else {
        // if layer transitions is enabled, then only one layer is active at a time
        layers.append(pendingLayer);
      }
    } else {
      for (int i = allLayersList.size()-1; i >= 0; i--) {
        Table   layer   = allLayersList.get(i);
        String  name    = layer.getS("NAME");
        String  style   = layer.getS(STYLE_KEY, "");
        boolean enabled = layer.getState(ENABLED_KEY);

        if ((name != null) && (all || enabled)) {
          if (layers.length() > 0) { layers.append(','); }
          layers.append(name);

          if ((style != null) && (style.length() > 0)) {
            layers.append("/").append(style);
          }
        }
      }
    }
    return layers.toString();
  }

  /** Get the list of layers enabled and styles enabled.
      @return An array holding { layers, styles, crs, layerList }.
   */
  private synchronized String[] getEnabledLayersAndStyles () {
    StringBuilder layers   = new StringBuilder(); // Lowest priority listed last
    StringBuilder styles   = new StringBuilder(); // Same order as layers
    Vector<Table> validCrs = new Vector<>(allLocalCrs);

    if (checkInit()) {
      for (int i = 0; i < allLayersList.size(); i++) {
        Table   layer   = allLayersList.get(i);
        String  name    = layer.getS("NAME");
        String  style   = layer.getS(STYLE_KEY, "");
        String  crs     = layer.getS(CRS_KEY, "");
        boolean enabled = layer.getState(ENABLED_KEY);

        if ((name != null) && enabled) {
          if (style == null) { style = ""; }

          if (layers.length() > 0) {
            layers.append(',');
            styles.append(',');
          }
          layers.append(StringUtil.encodePercentEncoding(name));  // since 3.5.3 URL encode layer name for BUG 2880
          styles.append(StringUtil.encodePercentEncoding(style)); // since 3.5.3 URL encode style name for BUG 2880
          updateValidCrs(validCrs, crs);
        }
      }
    } else {
      // base this on pendingLayers if we do not have getCapabilities information
      if ((transitions == null) || (pendingLayer==null)) {
        layers.append(pendingLayers);
      }
      else {
        // if layer transitions is enabled, then only one layer is active at a time
        layers.append(pendingLayer);
      }
    }
    // Allow choice of CRS to be based if we are since 4.0.1 for NXM-3793
    String crsChoice = (getGeoWebCache()) ? preferGeoWebCacheCRS(validCrs) : getFirstValid(validCrs);
    return new String[] { layers.toString(), styles.toString(), crsChoice };
  }


  /** Used by getLayersAndStyles() */
  private void updateValidCrs (List<Table> allValid, String layValid) {
    for (int i = 0; i < allValid.size(); i++) {
      Table crs = allValid.get(i);
      if (crs != null) {
        String  name  = crs.getS("NAME",  null);
        boolean allow = crs.getState("ALLOW", false);
        int     index = -1;

        if (allow && (name != null)) {
          index = Parser.find(layValid, name, -1) - 1;
        }
        if (index < 0) {
          allValid.set(i,null);
        }
      }
    }
  }

  /** Used by getEnabledLayersAndStyles() */
  private String getFirstValid (List<Table> allValid) {
    String name = null;
    for (Table crs : allValid) {
      if (crs != null) {
        name = crs.getS("NAME", null);
        if (name != null) break; // found it so break out of loop
      }
    }
    return name;
  }

  /** Used by getEnabledLayersAndStyles() */
  private String preferGeoWebCacheCRS (List<Table> allValid) {
    String firstValid = null;
    String name = null;
    for (Table crs : allValid) {
      if (crs != null) {
        name = crs.getS("NAME", null);
        if (name != null) {
          if (GEOWEBCACHE_CRS.equals(name)) break; // we are done!
          if (firstValid == null) firstValid = name;
          name = null; // reset, since we did not get our preference
        }
      }
    }
    if (name == null) {
      name = firstValid; // backup plan is to go with the first valid
      info("Using CRS "+name+" since GeoWebCache standard "+GEOWEBCACHE_CRS+" is not supported.");
    }
    return name;
  }

  /** Checks initialization of layers, styles, etc and updates them as necessary.
      @return true if layer info has been loaded from server, false if not.
   */
  private synchronized boolean checkInit () {
    if (pendingLayers != null) {
      doSetLayers(pendingLayers);
      pendingLayers = null;
    }
    return (allLayersList != null);
  }

  /** Checks the height and width; if out of range, re-scale image. */
  private Dimension getImageDimension (int width, int height, boolean hiRes, boolean warn) {
    Dimension dim;

    if ((width > wmsMaxWidth) || (height > wmsMaxHeight)) {
      double ratio  = ((double)height) / ((double)width);
      double limitW = (ratio / wmsMaxHeight);
      double maxW   = Math.min((double)wmsMaxWidth, (wmsMaxHeight / ratio));

      int w = (int)Math.max((double)wmsMaxWidth,  maxW);
      int h = (int)(ratio * w);
      dim = new Dimension(w,h);

      if (warn) {
        warn("Image size of "+width+"x"+height+" pixels exceeds server limit of "
           + wmsMaxWidth+"x"+wmsMaxHeight+" using "+w+"x"+h);
      }
    } else if (hiRes) {
      dim = getImageDimension(width*hiResMultiplier, height*hiResMultiplier, false, false);
    } else {
      dim = new Dimension(width,height);
    }
    return dim;
  }

  protected int drawArea (double minLon, double maxLon, double minLat, double maxLat,
                          int width, int height, boolean hiRes, String pos) {
    final String version = wmsVersion;  // keep local ref to prevent exceptions from wmsVersion being nulled out in various other methods
    if (version == null) {
      // No connection to server.
    } else {
      Dimension dim             = getImageDimension(width, height, hiRes, debug);
      String    bounds          = ""+minLon+","+minLat+","+maxLon+","+maxLat;

      // Handle Layer Transitions and/or displaying of WMS level readout
      double level = 0;
      if ((transitions != null)) {
        if (usingGWCGridsetLevels) {
          if (gwcLevel != 0) level = gwcLevel;
          else level = calcTileLevel(minLon, maxLon, minLat, maxLat, width, height, hiRes)[0];
        }
        else level = calculateWMSLevel(maxLon-minLon, maxLat-minLat, width, height);
        updateEnabledLayer(level); // NXM-3726 if layer transitions enabled, enable only proper layer
      }
      else if (MP.isDisplayingWMSLevel()) level = calculateWMSLevel(maxLon-minLon, maxLat-minLat, width, height);  // NXM-3724
      MP.setWMSLevel(level);

      String[]  layersAndStyles = getEnabledLayersAndStyles();
      String    layers          = layersAndStyles[0];
      boolean   blank           = (layers.length() == 0);

      if (blank) {
        if (debug) { info("GetMap url = <blank>"); }
      } else {
        String styles      = layersAndStyles[1];
        String crs         = layersAndStyles[2];
        String url         = null;
        String bgcolor     = getBgColorHex();
        String transparent = (wmsTransparent)? "TRUE" : "FALSE"; // Must be exactly "TRUE" or "FALSE"
        BufferedImage img;

        if (crs == null) { // TODO: JC4: Need a better way of handling this.
          warn("Warning no matching CRS for layers " + layers);
          crs = "EPSG:4326"; // NXM-3684 -better default contributed by Andrew Jarvis
        }

        // Do not include BGCOLOR if trying to take advantage of GeoWebCache cached tiles
        String urlBGCOLOR = (getGeoWebCache()) ? "" : "&BGCOLOR=" + bgcolor;

        if (version.equals(WMS_130)) {

          // if this crs has x=lat/y=lon then we need to switch order on the BBOX bounds
          // In 1.3.0 the EPSG namespace has the axis ordering latitude/longitude,
          // which is different from the 1.1.1 specification of longitude/latitude
          // NXM-3684 - fix contributed by Andrew Jarvis - since 4.0.0
          Table crsTable = null;
          for (Table t : allLocalCrs) {
            if (t != null) {
              String n = t.getS("NAME", null);
              if (crs.equalsIgnoreCase(n)) {
                crsTable = t;
                break;
              } // found it so break out of loop
            }
          }
          if(null != crsTable && crsTable.getS("X", "").equalsIgnoreCase("latitude")) {
            // in this case, for 1.3.0, we switch the BBOX for x=latitude
            bounds          = ""+minLat+","+minLon+","+maxLat+","+maxLon;
          }

          url = wmsGetMapUrl                                          // wmsGetMapUrl ends in a '?'
              +  "VERSION="     + version
              + "&REQUEST="     + "GetMap"
              + "&CRS="         + crs
              + "&BBOX="        + bounds
              + "&WIDTH="       + dim.width
              + "&HEIGHT="      + dim.height
              + "&LAYERS="      + layers
              + "&STYLES="      + styles
              + "&FORMAT="      + wmsFormat
              + "&EXCEPTIONS="  + wmsExceptions
              +  urlBGCOLOR
              + "&TRANSPARENT=" + transparent
              + "&TILED="       + getUseTiles();  // 4.0.0 NXM-3717
        } else if (version.equals(WMS_111)) {
          url = wmsGetMapUrl                                          // wmsGetMapUrl ends in a '?'
              +  "VERSION="     + version
              + "&REQUEST="     + "GetMap"
              + "&SRS="         + crs                                 // <-- This differs from 1.3.0
              + "&BBOX="        + bounds
              + "&WIDTH="       + dim.width
              + "&HEIGHT="      + dim.height
              + "&LAYERS="      + layers
              + "&STYLES="      + styles
              + "&FORMAT="      + wmsFormat
              + "&EXCEPTIONS="  + getExceptionMimeType(wmsExceptions) // <-- This differs from 1.3.0
              +  urlBGCOLOR
              + "&TRANSPARENT=" + transparent
              + "&TILED="       + getUseTiles();  // 4.0.0 NXM-3717
        } else {
          throw new MidasException("Unknown WMS protocol version " + version);
        }
        if ((debugOptions & D_URLS) != 0) midas.info("[DEBUG URLS] url:"+url);

        if (gwcCompatibilityChecking) gwcCompatibleURLs &=
            gwcCompatibilityCheck(crs, layers, dim, minLon, maxLon, minLat, maxLat, urlBGCOLOR);
        if (wmsVersion == null) {}  // no connection
        else {
          int numRetrys = Math.max(DEFAULT_MAX_TRYS, numRetriesIfBGColor + 1); // 4.0.0 for NXM-3725
          img = requestImage(url,numRetrys,false,dim.width,dim.height);
          if (img != null) {
            drawImage(img, minLon, maxLon, minLat, maxLat);
          }
        }

      }
    }
    return 0;
  }

  /** Check if this URL's entries are GeoWebCache compatible
   *  <pre>
   *  Tiled:     getTiled() is true
   *  CRS:       EPSG:4326
   *  LAYERS:    only 1 specified (i.e. no comma separated layers)
   *  Dimension: must be 256x256 tile request
   *  LatLon:    image requested lines up with grid bounds
   *             check that all bounds must are 180 divided by a power of 2
   *             (i.e. have no fractional portion or have the fractional portion
   *             be 0.5 or end in 25 or 75)
   *  BGCOLOR:   needs to be omitted from the URL
   *  </pre>
   *
   *  @param crs        coordinate reference system
   *  @param layers     comma-separated list of layers
   *  @param dim        tile dimensions
   *  @param minLon     bbox minimum longitudGWC_COMPATIBLE_GRID_BOUNDSe
   *  @param maxLon     bbox maximum longitude
   *  @param minLat     bbox minimum latitude
   *  @param maxLat     bbox maximum latitude
   *  @param urlBGcolor url background color
   *  @return true if URL is GeoWebCache compliant
   */
  private boolean gwcCompatibilityCheck (String crs, String layers, Dimension dim,
      double minLon, double maxLon, double minLat, double maxLat, String urlBGcolor) {
    boolean validURL = true;

    // Perform checks not related to bounds first
    if (!crs.equals(GEOWEBCACHE_CRS) || layers.contains(",") ||
        dim.getWidth() != 256 || dim.getHeight() != 256 || !urlBGcolor.equals("")) return false;

    double[] bounds = {minLon,maxLon,minLat,maxLat};
    for (double bound : bounds) {
      if (bound < 0) bound *= -1;
      if (Arrays.binarySearch(gwcCompatibleGridBounds, bound) < 0) {
        if (maxLon-minLon >= gwcCompatibleGridBounds[1]) {
          return false;
        }
      }
    }

    return validURL;
  }

  /** calculate new WMS level
   *
   *  @param widthDegrees  view width in degrees
   *  @param heightDegrees view height in degrees
   *  @param widthPixels   view width in pixels
   *  @param heightPixels  view height in pixels
   *  @since NeXtMidas 4.0.1 for NXM-3726
   */
  private double calculateWMSLevel(double widthDegrees, double heightDegrees, int widthPixels, int heightPixels) {
    double diag2Degrees = widthDegrees*widthDegrees + heightDegrees*heightDegrees;
    double diag2Pixels = widthPixels*widthPixels + heightPixels*heightPixels;
    double pix2Degrees = diag2Pixels / diag2Degrees;

    return pix2Degrees;
  }

  /** set layer visible based on view area
   *  requires transitions to be set ahead of call
   *
   *  @param level new WMS or GWC Gridset level
   *  @since NeXtMidas 4.0.1 for NXM-3726
   */
  private void updateEnabledLayer(double level) {
    boolean transitionsDebug = (debugOptions & D_TRANSITIONS) != 0;
    if (transitionsDebug) midas.info("[DEBUG TRANSITIONS] LayerWMS.updateEnabledLayer pix2Degrees:"+level);
    int transitionLocation = Arrays.binarySearch(transitions, level);
    int activeLayer = (transitionLocation < 0) ? Math.abs(transitionLocation) - 1 : transitionLocation + 1;
    activeLayer = Math.min(activeLayer, layersSet.length - 1); // handle case where too many transitions were set

    if (allLayersList != null) {
      int indexStyle = layersSet[activeLayer].indexOf("/");
      String activeLayerName = (indexStyle < 0) ? layersSet[activeLayer] : layersSet[activeLayer].substring(0, indexStyle);
      if (transitionsDebug) midas.info("[DEBUG TRANSITIONS] LayerWMS.updateEnabledLayer using allLayersList active layer:"+activeLayer+" name:"+activeLayerName);
      activeLayer = findLayer(activeLayerName); // update to index in allLayersList
      // TODO could make separate field that got set with indexes, after allLayersList is created - would save steps here
      for (String layerInfo : layersSet) {
        indexStyle = layerInfo.indexOf("/");
        String layerName = (indexStyle < 0) ? layerInfo : layerInfo.substring(0, indexStyle);
        int layerIndex = findLayer(layerName);
        boolean enabled = (layerIndex == activeLayer);
        Table lay = allLayersList.get(layerIndex);
        lay.put(ENABLED_KEY, enabled);
      }
    }
    else {
      if (transitionsDebug) midas.info("[DEBUG TRANSITIONS] LayerWMS.updateEnabledLayer active layer:"+activeLayer);
      pendingLayer = layersSet[activeLayer];
    }
  }

  /** Gets the MIME type for an exception type as used in WMS 1.1.1. */
  private static String getExceptionMimeType (String type) {
    return EX_MIMEPRE + type.toLowerCase();
  }

  /** Gets the name for an exception from its MIME type as used in WMS 1.1.1. */
  private static String getExceptionName (String mimeType) {
    if (EX_MIMEPRE.length() > mimeType.length()) return "INVALID"; // added in 4.0.0 to avoid IndexOutOfBoundsException with bad getCapabilities
    return mimeType.substring(EX_MIMEPRE.length()).toUpperCase();
  }

  /** Gets the name for an exception from its MIME type as used in WMS 1.1.1. */
  private static String[] getExceptionNames (String[] mimeTypes) {
    String[] names = new String[mimeTypes.length];
    for (int i = 0; i < names.length; i++) {
      names[i] = getExceptionName(mimeTypes[i]);
    }
    return names;
  }

  protected HttpURLConnection openConnection (String url) throws IOException {
    HttpURLConnection conn = super.openConnection(url);
    Object content = conn.getHeaderFields().get("Content-Type");
    if (content == null) { // since 4.0.0 for NXM-3684
        content = conn.getHeaderFields().get("Content-type");
    }
    String type    = (content == null)? null : content.toString();

    if (wmsExceptions.equals(EX_XML) && (type == null)) {
      throw new MapServerException("Content-Type is not set.", false);

    } else if (type.equals("[text/xml]") || type.startsWith("[text/xml;")
                                         || type.equals("[application/vnd.ogc.se_xml]")) {
      try {
        BufferedReader in   = new BufferedReader(new InputStreamReader(conn.getInputStream()));
        StringBuilder  msg  = new StringBuilder();
        String         line = null;
        while ((line = in.readLine()) != null) {
          msg.append('\n').append(line);
        }
        throw new MapServerException(msg.toString(), false);
      } catch (MapServerException e) {
        throw e;
      } catch (Exception e) {
        throw new MapServerException("Unable to load XML exception info.", e, false);
      }
    } else if (type.equals("[application/vnd.ogc.se_inimage]")) {
      throw new MapServerException("Exception described in image.", true);
    } else if (type.equals("[application/vnd.ogc.se_blank]")) {
      throw new MapServerException("Unknown server exception, image is blank.", false);
    }
    return conn;
  }

  /** Loads the capabilities from the server and negotiates a common protocol to use.
      This initializes many fields, including wmsVersion. If an error occurs, the
      wmsVersion will be set to null indicating that an connection can not be established
      or that the options provided by the server do not match those provided by the client.
   */
  private void loadCapabilities () {
    // adding any requested version to the end of the list, so it get priority
    // this will effectively cause a duplicate if all versions end up being checked
    // since 4.0.0 for NXM_3692
    String requestedAtEnd = ((requestedVersion==null || requestedVersion.isEmpty())) ?
                             "" : ","+requestedVersion;
    Parser  versions  = new Parser(supVersions+requestedAtEnd);
    int     index     = versions.elements();
    String  serverVer = null;

    try {
      // CHECK SERVER VERSION
      //   This follows the "Version number negotiation" specs for version 1.3.0 (section 6.2.4)
      while ((index > 0) && (wmsVersion == null)) {
        String localVer = versions.get(index);

        if ((serverVer == null) || (StringUtil.compareVersions(localVer, serverVer) < 0)) {
          String url = mapServer+"?VERSION="+localVer+"&REQUEST=GetCapabilities&SERVICE=WMS";
          String timeoutQual = "{CONNECTTIMEOUT="+connectTimeout+",TIMEOUT="+readTimeout+"}";

          if (debug) { info("GetCapabilities url = "+url+" timeouts="+timeoutQual); }
          capabilities = XmlFile.fastXmlFileToTable(midas, url+timeoutQual);
          if ((debugOptions & D_CAPABILITIES) !=0) midas.info("[DEBUG CAPABILITIES] LayerWMS.loadCapabilities :"+capabilities);
          serverVer    = capabilities.getS("VERSION");
        }
        if (serverVer == null) {
          index = -1;
        } else {
          int comp = StringUtil.compareVersions(localVer, serverVer);
          if (comp == 0) {
            wmsVersion = localVer;
          } else if (comp < 0) {
            index = -1; // All local versions are too old
          } else {
            index--; // This local version is too old, try a newer one
          }
        }
      }
      if (serverVer == null) {
        throw new MidasException("Server does not support the same OpenGIS WMS protocol version "
                               + "as the client.");
      }

      // CHECK IMAGE FORMATS
      Table getMap = getCapTable("CAPABILITY.REQUEST.GETMAP");
      if (getMap == null) {
        throw new MidasException("Could not find GETMAP section in the server capabilities.");
      }
      wmsFormat = getBest(supImageTypes, getAllS(getMap, "FORMAT"));

      if (wmsFormat == null) {
        throw new MidasException("Server does not support any of the image formats supported "
                               + "by the client.");
      }

      // GET URL FOR HTTP/GET
      Table httpGet = getCapTable("CAPABILITY.REQUEST.GETMAP.DCPTYPE.HTTP.GET.ONLINERESOURCE");
      if (httpGet == null) {
        warn("Could not find online resource for GetMap");
        wmsGetMapUrl = mapServer+"?";
      } else {
        String xlink = httpGet.getS("XLINK:XLINK",XLINK_XLINK); // Fixed value in 1.1.1/1.3.0
        String href  = httpGet.getS("XLINK:HREF", null       ); // Required parameter
        String type  = httpGet.getS("XLINK:TYPE", XLINK_TYPE ); // Fixed value in 1.1.1/1.3.0

        wmsGetMapUrl = checkGetMapHref(href);
        if (!xlink.equals(XLINK_XLINK)) { // This is the only supported value in ver 1.1.1/1.3.0
          warn("Invalid xlink for GetMap, xlink:xlink="+xlink);
        }
        if (!type.equals(XLINK_TYPE)) {   // This is the only supported value in ver 1.1.1/1.3.0
          warn("Invalid type for GetMap, xlink:type="+type);
        }
      }

      // GET ALL LAYERS
      loadWmsLayers(getCapTable("CAPABILITY.LAYER"));

      // Get resolution limits
      Table service = getCapTable("SERVICE");

      // NXM-2947 fix requested if <MAXWIDTH/> and <MAXHEIGHT/> are received empty from XML response
      // according to OCW 1.3.0 the maxWidth and MaxHeight are optional and when defined
      // they must be a positiveInteger which is defined as an Integer value (1,2,..)
      if (service != null) {
        try {
          wmsMaxWidth  = service.getL("MAXWIDTH",  Integer.MAX_VALUE);
          wmsMaxHeight = service.getL("MAXHEIGHT", Integer.MAX_VALUE);
        } catch (Exception e) {
          warn("An incorrect value received from geoServer for maxwidth: "+wmsMaxWidth+" or maxheight: "+wmsMaxHeight);
          wmsMaxWidth = Integer.MAX_VALUE;
          wmsMaxHeight = Integer.MAX_VALUE;
        }
      }

      // CHECK ERROR TYPES
      Table    exception = getCapTable("CAPABILITY.EXCEPTION");
      String[] formats   = getAllS(exception,"FORMAT");
      boolean isWMS111_server = WMS_111.equals(wmsVersion);
      if (isWMS111_server) {
        formats = getExceptionNames(formats); // convert WMS1.1.1 to WMS1.3.0 exception format
      }
      wmsExceptions = getBest(supExceptionTypes,formats);

      if ((wmsExceptions == null) && !requiringExceptionElementBlock) {
        // Exception element block may be optional and in all cases XML should be supported
        // since 4.0.0 for NXM-3684
        wmsExceptions = EX_XML;
      }
      else {
        // 2010-10-19 NTN: fall back to WMS 1.1.1 exception format (BUG 2267)
        if ((wmsExceptions == null) && !isWMS111_server) {
          formats = getExceptionNames(formats); // convert WMS1.1.1 to WMS1.3.0 exception format
          wmsExceptions = getBest(supExceptionTypes,formats);
          if (WMS_130.equals(wmsVersion)) {
            warn("Server claims to be WMS 1.3.0 but is using WMS 1.1.1 exception format: "+mapServer);
          }
        }

        if (wmsExceptions == null) {
          throw new MidasException("Server does not support any of the exception formats supported "
                                 + "by the client.");
        }
      }

    } catch(Exception e) {
      warn("Unable to connect to server on "+mapServer+" due to:\n    "+e+"\n    "+e.getCause());
      wmsVersion = null; // Indicate that we are unable to connect to this server.
      status     = "Unable to connect to "+getHostPort();
    }

    if (wmsVersion != null) {
      status = "Connected to "+getHostPort()+" (WMS "+wmsVersion+")";
    }
  }

  /** Checks the HREF given for use with a GetMap request. As per the WMS specs (1.1.1 and 1.3.0),
      the href should end with "?" or "?key1=val1&...&keyN=valN&", some omit the last "?" or
      "&" in violation of the WMS spec, so add it with a warning. <br>
      <br>
      If we can't get to the server, try using the same server as GetCapabilities. This does not
      follow the WMS specs (which say that the server will always give a valid href), but use it
      as a fall-back after issuing a warning.
   */
  private String checkGetMapHref (String href) {
    String url = null;

    if (href != null) {
      href = href.replaceAll("&amp;", "&"); // JC4: I've seen this before...

      if (href.endsWith("?") || (href.indexOf("?")>0 && href.endsWith("&"))) {
        url = href;
      } else {
        url = (href.indexOf("?")>0)? (href+"&") : (href+"?");
        warn("GetCapabilities for '"+mapServer+"' specified invalid href for "
           + "GetMap requests (xlink:href='"+href+"'), will try using '"+url+"'.");
      }
    }

    try {
      if (ignoreHREF) url  = mapServer+"?"; // if true, ignore the WMS server's GetCapabilities HREF
      checkReachable(url);
    } catch (MidasException e) {
      String getCapabilitiesHREF = url;
      url  = mapServer+"?";
      warn(e.getMessage());
      warn("Unable to use URL specified in GetCapabilities for GetMap requests: "+getCapabilitiesHREF+
           "\n       will try using '"+url+"'.");
    }

    return url;
  }

  public void configure () {
    setConfigPanelVisible(true);
  }

  public void close () {
    // NOTE: the jvm hang field in Shell can inadvertently be set before the LayerWMS Config
    // menu is "closed" (i.e. all download threads are stopped).
    // Therefore, removeNotify() should be called before super.close(). This ensures that the
    // jvm hang field doesn't get reset until after removeNotify() has been called.
    if (configFrame != null && Shell.needRemoveNotifyToAvoidJVMHang()) { // NXM-3986
      configFrame.removeNotify();
    }
    super.close();
    setConfigPanelVisible(false);
  }

  /** Sets the configuration panel to visible/invisible.
   *  @param visible true if configuration is visible */
  public void setConfigPanelVisible (boolean visible) {
    if (isConnected()) {
      if (configFrame!=null && ((MP.isPopped() != popped) ||
          (MP.isPopped()&& gdev != MP.getPopDevice()))) {
        configFrame = null;  // if plot was popped then replaced, need to renew config panel to put it on the right screen (Bug 2580)
      }
      if ((configFrame == null) && visible) {
        checkInit();
        configPanel = new LayerWmsConfig(this);
       // configFrame = new JFrame("LayerWMS Configuration");  // pre 3.1.1
        if (MP.isPopped()) {  // if plot is popped, put the config panel on the right screen (Bug 2580)
          gdev = MP.getPopDevice();
          if (gdev==null) gdev =  GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
          configFrame = new JFrame("LayerWMS Configuration", gdev.getDefaultConfiguration());
          popped = true;
        } else {
          configFrame = new JFrame("LayerWMS Configuration");
          popped = false;
        }

        configFrame.getContentPane().add(configPanel);
        configFrame.pack();
      }
      if ((configFrame != null) && (configFrame.isVisible() != visible)) {
        if (visible && (MP != null) && (MP.getPanel() != null)) {
          Point point = MP.getPanel().getLocationOnScreen();
          point.x += 20;
          point.y += 20;
          configFrame.setLocation(point);
        }
        configFrame.setVisible(visible);
      }
    } else {
      if (configFrame != null) {
        configFrame.setVisible(false);
      }
      if (visible) {
        String  message = "LayerWMS: "+getStatus();
        MJPopup mjpopup = MJPopup.createOk(MP.getPanel(), "LayerWMS Configuration",
                                           JOptionPane.ERROR_MESSAGE, message);
        mjpopup.display(0);
        warn("LayerWMS:"+ this.name+"  unable to display configuration panel: "+ getStatus());
      }
    }
  }

  public void refresh () {
    super.refresh();
    if ((configFrame != null) && configFrame.isVisible()) {
      configPanel.refreshConfig();
    }
  }

  /** Reads the WMS layers out of a table. This will take care of any inheritance issues and
      convert the flat layer structure...
      <pre>
        LAYER_1={NAME=<name1>,...}
        LAYER_2={NAME=<name2>,...}
        STYLE_1={NAME=<name1>,...}
        STYLE_2={NAME=<name2>,...}
      </pre>
      ...into a nested one.
      <pre>
        LAYERS={
          <NAME1>={NAME=<name1>,...}
          <NAME2>={NAME=<name1>,...}
        }
        STYLES={
          <NAME1>={NAME=<name1>,...}
          <NAME2>={NAME=<name1>,...}
        }
      </pre>
   */
  private void loadWmsLayers (Table tbl) {
    allLayersList  = new Vector<>();
    allLayersTable = getWmsLayer(tbl,null,allLayersList);
  }


  /** Gets WMS Layer info. Only used by loadWmsLayers(..) */
  private Table getWmsLayer (Table tbl, Table inheritFrom, Vector<Table> flatView) {
    final String[] inheritAddKeys = { STYLES_KEY /*, "AUTHORITYURL"*/  };

    if (tbl == null) { return null; }

    Table   layer   = new Table();
    Table   layers  = new Table();
    Table   styles  = new Table();
    String  crs     = "";
    String  tempCrs = "";

    // Insert fields and take care of merging.
    flatView.add(layer);
    layer.put(LAYERS_KEY, layers);
    layer.put(STYLES_KEY, styles);
    layer.put(ENABLED_KEY,false);
    layer.put(STYLE_KEY,  "");
    if (inheritFrom != null) {
      copyTableValues(INHERIT_REP_KEYS, inheritFrom, layer); // Items that replace
      copyTableTables(INHERIT_ADD_KEYS, inheritFrom, layer); // Items that add (by merging)
      crs = inheritFrom.getS(CRS_KEY); // Items that add (by appending, see below)
    }
    layer.merge(tbl);

    // Handle the CRS (ver 1.3.0) vs SRS (ver 1.1.1) issue; plus handle merging them.
    crs = mergeLists(crs, extractStrings("CRS",layer)); // WMS version 1.3.0
    crs = mergeLists(crs, extractStrings("SRS",layer)); // WMS version 1.1.1
    layer.put(CRS_KEY,crs);

    // Convert ver 1.1.1 bounding box to ver 1.3.0
    Table bounds = layer.getTable("LATLONBOUNDINGBOX");
    if (bounds != null) {
      layer.rename("LATLONBOUNDINGBOX", "EX_GEOGRAPHICBOUNDINGBOX");
      bounds.rename("MINY", "SOUTHBOUNDLATITUDE");
      bounds.rename("MAXY", "NORTHBOUNDLATITUDE");
      bounds.rename("MINX", "WESTBOUNDLONGITUDE");
      bounds.rename("MAXX", "EASTBOUNDLONGITUDE");
    }

    // Convert ver 1.1.1 scale hints to ver 1.3.0 scale denominators
    Table scaleHint = layer.getTable("SCALEHINT");
    if (scaleHint != null) {
      layer.remove("SCALEHINT");
      layer.put("MINSCALEDENOMINATOR", 1.0/scaleHint.getD("MIN")); // <-- JC4: Check this computation
      layer.put("MAXSCALEDENOMINATOR", 1.0/scaleHint.getD("MAX")); // <-- JC4: Check this computation
    }

    // Move individual styles to table.
    extractFixAndCopy("STYLE", "NAME", layer, styles);

    // Move individual layers to table. (***RECURSIVE***)
    Object[] allLayers = extractAll(layer,"LAYER");
    for (int i = 0; i < allLayers.length; i++) {
      Table  subLay = getWmsLayer((Table)allLayers[i],layer,flatView);
      String name   = subLay.getS("NAME");
      layers.put(fixName(name), subLay);
    }

    return layer;
  }

  /** Merges two lists. */
  private String mergeLists (String first, String last) {
    String str;
    if (first.length() == 0) {
      str = last;
    } else if (last.length() == 0) {
      str = first;
    } else {
      Parser parser = new Parser(last);
      str = first;
      for (int i = 1; i <= parser.elements(); i++) {
        String elem = parser.get(i);
        if (Parser.find(str,elem,-1) > 0) {
          str = str + "," + elem;
        }
      }
    }
    return str;
  }

  /** Copies nested values from one table into another. Used by getWmsLayer(..). */
  private void copyTableValues (String[] keys, Table src, Table dest) {
    for (int i = 0; i < keys.length; i++) {
      Object val = src.get(keys[i]);
      if (val != null) { dest.put(keys[i], val); }
    }
  }

  /** Merges nested tables from one table into another. Used by getWmsLayer(..). */
  private void copyTableTables (String[] keys, Table src, Table dest) {
    for (int i = 0; i < keys.length; i++) {
      Table srcTable  = src.getTable(keys[i]);
      Table destTable = dest.getTable(keys[i]);
      if (destTable != null) {
        srcTable.merge(destTable);
      }
    }
  }

  /** Merges nested tables from one table into another. Used by getWmsLayer(..). */
  private void copyTableString (String[] keys, Table src, Table dest) {
    for (int i = 0; i < keys.length; i++) {
      String sStr = src.getS(keys[i],"");
      String dStr = dest.getS(keys[i],"");
      src.put(keys[i], (sStr + dStr));
    }
  }

  /** Extract, fix and copy. Used by getWmsLayer(..). */
  private void extractFixAndCopy (String tableKey, String nameKey, Table src, Table dest) {
    Object[] tables = extractAll(src,tableKey);

    for (int i = 0; i < tables.length; i++) {
      Table  tbl  = (Table)tables[i];
      String name = tbl.getS(nameKey);
      dest.put(fixName(name),tbl);
    }
  }

  /** Fixes a name so it can be used as a key in a table. */
  private static String fixName (String name) {
    return (name==null)? null: name.replaceAll("[^A-Za-z0-9_]","_").toUpperCase();
  }

  /** Gets all strings in a table matching a given key. */
  private String[] getAllS (Table tab, String key) {
    ArrayList<String> values = new ArrayList<>();

    String val = tab.getS(key);
    if (val != null) {
      values.add(val);
    } else {
      for (int i = 1; (i == 1) || (val != null); i++) {
        val = tab.getS(key+"_"+i);
        if (val != null) {
          values.add(val);
        }
      }
    }
    return values.toArray(StringUtil.EMPTY_STRING_ARRAY);
  }

  /** Removes all objects in a table matching a given key. */
  private Object[] extractAll (Table tab, String key) {
    ArrayList<Object> values = new ArrayList<>();

    Object val = tab.get(key);
    if (val != null) {
      values.add(val);
      tab.remove(key);
    } else {
      for (int i = 1; (i == 1) || (val != null); i++) {
        String tag = key+"_"+i;
        val = tab.get(tag);
        if (val != null) {
          values.add(val);
          tab.remove(tag);
        }
      }
    }
    return values.toArray();
  }

  /** Removes all objects in a table matching a given key. */
  private String extractStrings (String tableKey, Table src) {
    return StringUtil.join(extractAll(src,tableKey), ",");
  }

  /** For a given list of options, choose the best option available. The list of options should
      be in increasing order of preference ("Low,...,High").
      @param list    The list of supported options. (client side)
      @param options The list of available options. (server side)
      @return The name of the best option or null if none match.
   */
  private String getBest (String list, String[] options) {
    String value = null;
    int    index = -1;

    for (int i = 0; i < options.length; i++) {
      int idx = Parser.find(list, options[i], -1);
      if (idx > index) {
        index = idx;
        value = options[i];
      }
    }
    return value;
  }

  /** Get a named table from the list of capabilities. */
  private Table getCapTable (String key) {
    return (capabilities == null)? null: capabilities.getTable(key);
  }

  /** Get a named string from the list of capabilities. */
  private String getCapS (String key) {
    return (capabilities == null)? null: capabilities.getS(key);
  }

  /** @return true if space can be used as a delimiter in {@link #setLayers(String)} in addition to commas
      @deprecated Since this method was added in NeXtMidas 3.5.3
   */
  @Deprecated
  public boolean isAllowSpaceAsDelimiter () {
    return allowSpaceAsDelimInSetLayers;
  }

  /** @param value if space can be used as a delimiter in {@link #setLayers(String)} in addition to commas
      @deprecated Since this method was added in NeXtMidas 3.5.3 to allow reverting to old undocumented/unsupported behavior
   */
  @Deprecated
  public void setAllowSpaceAsDelimiter (boolean value) {
    this.allowSpaceAsDelimInSetLayers = value;
  }

  /** Set if getCapabilities should error out if no Exception element block
   *  is found specifying formats allowed
   *
   *  @param required true = error out if not there, false default to XML format
   */
  public void setRequiringExceptionElementBlock (boolean required) {
    this.requiringExceptionElementBlock = required;
  }

  /** Is this in the mode where it will error out if no Exception element block
   *  is found specifying formats allowed
   *
   *  @return required true = error out if not there, false default to XML format
   */
  public boolean isRequiringExceptionElementBlock () {
    return requiringExceptionElementBlock;
  }

  /**  set Requested WMS Version
   *   If requested version is supported, the requested version will be given preference
   *   over other versions. If is is not supported, version request will be ignored.
   *
   *   @param requestedVersion requested version or "" to disable
   *   @since NeXtMidas 4.0.0 for NXM-3692
   */
  public void setRequestedVersion (String requestedVersion) {
    if (requestedVersion==null || requestedVersion.isEmpty()) {
      this.requestedVersion = "";
    }
    boolean requestedIsSupported = isRequestedVersionSupported(requestedVersion);
    if (requestedIsSupported) {
      this.requestedVersion = requestedVersion;
    }
    else {
      this.requestedVersion = "";
    }
  }

  private boolean isRequestedVersionSupported (String requestedVersion) {
    boolean requestedIsSupported = false;
    try {
      requestedIsSupported = Arrays.asList(supVersions.split(",")).contains(requestedVersion);
    }
    catch (Exception e) {
      requestedIsSupported = false;
    }
    if (!requestedIsSupported) {
      warn("Requested Version ("+requestedVersion+") is not listed in supported versions {" +
            supVersions+"}, so version request is not active");
    }
    return requestedIsSupported;
  }

  /** Restore old very limited number of download threads default
   *
   *  @param useOld use the old default
   *  @since 4.0.0 for NXM-3717
   */
  public void setUseOldDownloadThreadsDefault (boolean useOld) {
    LayerWMS.useOldDownloadThreadsDefault = useOld;
  }

  /** Should be set to true to avoid timeout ONLy when WMS server is returning bad HREF in GetCapabilities
   *
   *  @param ignoreGetCapabilitiesHREF set to true if GetCapabilities returning bad HREF
   *  @since 4.0.0 for NXM-3717
   */
  public void setIgnoreHREF (boolean ignoreGetCapabilitiesHREF) {
    this.ignoreHREF = ignoreGetCapabilitiesHREF;
  }


  /** Set pixel-to-degree ratios at which layers should transition
   *
   *  @param transitionsString comma separated string of doubles (1 less than number of layers)
   */
  public void setTransitions (String transitionsString) {
    if (StringUtil.isEmpty(transitionsString)) {
      transitions = null;
      return;
    }
    String[] values = transitionsString.split(",");
    transitions = new double[values.length];
    usingGWCGridsetLevels = values[0].startsWith("L");
    try {
      for (int i=0; i < values.length; i++) {
        boolean isGWCLevel = values[i].startsWith("L");
        usingGWCGridsetLevels |= isGWCLevel;
        String strTransition = (isGWCLevel) ? values[i].substring(1) : values[i];
        transitions[i] = Double.valueOf(strTransition);
      }
    }
    catch (NumberFormatException npe) {
      warn("LayerWMS could not set layer transitions, as one value or more is not a double: "+transitionsString);
      transitions = null;
    }
  }

  /** Turn on or off GeoWebCache compatibly checking for URLs
   *  (for debug and testing only)
   *
   *  @param checkingEnabled true to enable checking
   */
  @InternalUseOnly
  public static void setGwcCompatibilityChecking (boolean checkingEnabled) {
    if (gwcCompatibilityChecking != checkingEnabled) {
      gwcCompatibilityChecking = checkingEnabled;
      if (gwcCompatibilityChecking) {
        gwcCompatibleURLs = true; // default to okay
        if (gwcCompatibleGridBounds == null) initGwcCompatibleBounds();
      }
    }
  }

  /** Get whether or not GeoWebCache URL Compatibility Checking is enabled
   *  @return true if GeoWebCache URL Compatibility Checking is enabled
   */
  @InternalUseOnly
  public static boolean getGwcCompatibilityChecking () {
    return gwcCompatibilityChecking;
  }

  /** Initalize GeoWebCache Compatible Bounds array only once and only
   *  if gwcCompatibilityChecking gets set to true.
   *  This holds valid GWC grid bounds up to the zoom level we are storing
   */
  private static void initGwcCompatibleBounds() {
    gwcCompatibleGridBounds = new double[(int)GWC_NUM_GRID_BOUNDS+1];
    gwcCompatibleGridBounds[0] = 0.0;
    double gridSeparation = GWC_4326_X_MAX/GWC_NUM_GRID_BOUNDS;
    for (int i = 1; i < gwcCompatibleGridBounds.length; i++) {
      gwcCompatibleGridBounds[i] = gwcCompatibleGridBounds[i-1] + gridSeparation;
    }
  }

  /** Get results from GeoWebCache URL Compatibility check
   *  @return true if all URLs have been GWC compatible (since GWC was enabled)
   */
  @InternalUseOnly
  public static boolean getGwcCompatibleURLs () {
    return gwcCompatibleURLs;
  }

  public void setDebug (String arg) {
    // Split up debug arguments by which are for LayerWMS and which
    // are for layerMap. Make split character the ','.
    arg = arg.replace('|', ',');
    if (arg.startsWith(",")) arg = arg.substring(1);
    if (arg.endsWith(",")) arg = arg.substring(0, arg.length() -1);
    String[] args = arg.split(",");
    String wmsDebug = "";
    String mapDebug = "";
    for (String debugOpt : args) {
      if (ucDebugList.contains(debugOpt.toUpperCase())) {
        if (wmsDebug.isEmpty()) wmsDebug = debugOpt;
        else wmsDebug += "|" + debugOpt;
      }
      else {
        if (mapDebug.isEmpty()) mapDebug = debugOpt;
        else mapDebug += "|" + debugOpt;
      }
    }
    if (!mapDebug.isEmpty()) super.setDebug(mapDebug);
    if (!wmsDebug.isEmpty()) debugOptions = Parser.mask(debugList,wmsDebug,debugOptions);
  }
}