Models/WebMapServiceCatalogItem.js

"use strict";

/*global require*/
var URI = require("urijs");
var moment = require("moment");
var i18next = require("i18next").default;

var clone = require("terriajs-cesium/Source/Core/clone").default;
var combine = require("terriajs-cesium/Source/Core/combine").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var Ellipsoid = require("terriajs-cesium/Source/Core/Ellipsoid").default;

var GeographicTilingScheme = require("terriajs-cesium/Source/Core/GeographicTilingScheme")
  .default;
var GetFeatureInfoFormat = require("terriajs-cesium/Source/Scene/GetFeatureInfoFormat")
  .default;
var getToken = require("./getToken");
var ImageryProvider = require("terriajs-cesium/Source/Scene/ImageryProvider")
  .default;
var JulianDate = require("terriajs-cesium/Source/Core/JulianDate").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var loadXML = require("../Core/loadXML");
var Rectangle = require("terriajs-cesium/Source/Core/Rectangle").default;
var TimeInterval = require("terriajs-cesium/Source/Core/TimeInterval").default;
var TimeIntervalCollection = require("terriajs-cesium/Source/Core/TimeIntervalCollection")
  .default;
var UrlTemplateImageryProvider = require("terriajs-cesium/Source/Scene/UrlTemplateImageryProvider")
  .default;
var WebMapServiceImageryProvider = require("terriajs-cesium/Source/Scene/WebMapServiceImageryProvider")
  .default;
var WebMercatorTilingScheme = require("terriajs-cesium/Source/Core/WebMercatorTilingScheme")
  .default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;

var containsAny = require("../Core/containsAny");
var Metadata = require("./Metadata");
var MetadataItem = require("./MetadataItem");
var TerriaError = require("../Core/TerriaError");
var ImageryLayerCatalogItem = require("./ImageryLayerCatalogItem");
var inherit = require("../Core/inherit");
var runLater = require("../Core/runLater");
var overrideProperty = require("../Core/overrideProperty");
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var unionRectangleArray = require("../Map/unionRectangleArray");
var xml2json = require("../ThirdParty/xml2json");
var LegendUrl = require("../Map/LegendUrl");
var callWebCoverageService = require("./callWebCoverageService");

/**
 * A {@link ImageryLayerCatalogItem} representing a layer from a Web Map Service (WMS) server.
 *
 * @alias WebMapServiceCatalogItem
 * @constructor
 * @extends ImageryLayerCatalogItem
 *
 * @param {Terria} terria The Terria instance.
 */
var WebMapServiceCatalogItem = function(terria) {
  ImageryLayerCatalogItem.call(this, terria);

  this._rawMetadata = undefined;
  this._thisLayerInRawMetadata = undefined;
  this._allLayersInRawMetadata = undefined;

  this._metadata = undefined;
  this._getCapabilitiesUrl = undefined;

  this._rectangle = undefined;
  this._rectangleFromMetadata = undefined;
  this._intervalsFromMetadata = undefined;

  this._lastToken = undefined;
  this._newTokenRequestInFlight = undefined;

  /**
   * Gets or sets the WMS layers to include.  To specify multiple layers, separate them
   * with a commas.  This property is observable.
   * @type {String}
   */
  this.layers = "";

  /**
   * Gets or sets the URL of a WCS that enables clip-and-ship for this WMS item. This
   * proerty is part of an experimental feature and may be subject to change.
   * @type {String}
   */
  this.linkedWcsUrl = undefined;

  /**
   * Gets or sets the coverage name for linked WCS for clip-and-ship. This proerty is part
   * of an experimental feature and may be subject to change.
   * @type {String}
   */
  this.linkedWcsCoverage = "";

  /**
   * Gets or sets the comma-separated list of styles to request, one per layer list in {@link WebMapServiceCatalogItem#layers}.
   * This property is observable.
   * @type {String}
   */
  this.styles = "";

  /**
   * Gets or sets the additional parameters to pass to the WMS server when requesting images.
   * All parameter names must be entered in lowercase in order to be consistent with references in TerrisJS code.
   * If this property is undefined, {@link WebMapServiceCatalogItem.defaultParameters} is used.
   * @type {Object}
   */
  this.parameters = {};

  /**
   * Gets or sets the tiling scheme to pass to the WMS server when requesting images.
   * If this property is undefiend, the default tiling scheme of the provider is used.
   * @type {Object}
   */
  this.tilingScheme = undefined;

  /**
   * Gets or sets the formats in which to try WMS GetFeatureInfo requests.  If this property is undefined, the `WebMapServiceImageryProvider` defaults
   * are used.  This property is observable.
   * @type {GetFeatureInfoFormat[]}
   */
  this.getFeatureInfoFormats = undefined;

  /**
   * Gets or sets a value indicating whether a time dimension, if it exists in GetCapabilities, should be used to populate
   * the {@link ImageryLayerCatalogItem#intervals}.  If the {@link ImageryLayerCatalogItem#intervals} property is set explicitly
   * on this catalog item, the value of this property is ignored.
   * @type {Boolean}
   * @default true
   */
  this.populateIntervalsFromTimeDimension = true;

  /**
   * Gets or sets the denominator of the largest scale (smallest denominator) for which tiles should be requested.  For example, if this value is 1000, then tiles representing
   * a scale larger than 1:1000 (i.e. numerically smaller denominator, when zooming in closer) will not be requested.  Instead, tiles of the largest-available scale, as specified by this property,
   * will be used and will simply get blurier as the user zooms in closer.
   * @type {Number}
   */
  this.minScaleDenominator = undefined;

  /**
   * Gets or sets a value indicating whether to continue showing tiles or hide tiles when the {@link WebMapServiceCatalogItem#minScaleDenominator}
   * is exceeded. This property is observable.
   * @type {Boolean}
   * @default true
   */
  this.hideLayerAfterMinScaleDenominator = false;

  /**
   * Gets or sets the maximum number of intervals that can be created by a single
   * date range, when specified in the form time/time/periodicity.
   * eg. 2015-04-27T16:15:00/2015-04-27T18:45:00/PT15M has 11 intervals
   * @type {Number}
   */
  this.maxRefreshIntervals = 1000;

  /**
   * Gets or sets whether this WMS has been identified as being provided by a GeoServer.
   * @type {Boolean}
   */
  this.isGeoServer = undefined;

  /**
   * Gets or sets whether this WMS has been identified as being provided by an Esri ArcGIS MapServer. No assumption is made about where an ArcGIS MapServer endpoint also exists.
   * @type {Boolean}
   */
  this.isEsri = undefined;

  /**
   * Gets or sets whether this WMS has been identified as being provided by ncWMS.
   * @type {Boolean}
   */
  this.isNcWMS = undefined;

  /**
   * Gets or sets whether this WMS server has been identified as supporting the COLORSCALERANGE parameter.
   * @type {Boolean}
   */
  this.supportsColorScaleRange = undefined;

  /**
   * Gets or sets how many seconds time-series data with a start date but no end date should last, in seconds.
   * @type {Number}
   */
  this.displayDuration = undefined;

  /**
   * Gets or sets a value indicating whether the user's ability to change the display properties of this
   * catalog item is disabled.  For example, if true, {@link WebMapServiceCatalogItem#styles} should not be
   * changeable through the user interface.
   * This property is observable.
   * @type {Boolean}
   * @default false
   */
  this.disableUserChanges = false;

  /**
   * Gets or sets the available styles for each selected layer in {@link WebMapServiceCatalogItem#layers}.  If undefined,
   * this property is automatically populated from the WMS GetCapabilities on load.  This property is an object that has a
   * property named for each layer.  The value of the property is an array where each element in the array is a style supported
   * by the layer.  The style has `name`, `title`, `abstract`, and `legendUrl` properties.
   * This property is observable.
   * @type {Object}
   * @example
   * wmsItem.availableStyles = {
   *     'FVCOM-NECOFS-GOM3/x': [
   *         {
   *              name: 'default-scalar/default',
   *              title: 'default-scalar/default',
   *              abstract: 'default-scalar style, using the default palette.',
   *              legendUrl: new LegendUrl('http://www.smast.umassd.edu:8080/ncWMS2/wms?REQUEST=GetLegendGraphic&PALETTE=default&COLORBARONLY=true&WIDTH=110&HEIGHT=264', 'image/png')
   *         }
   *     ]
   * };
   */
  this.availableStyles = undefined;

  /**
   * Gets or sets the minumum of the color scale range.  Because COLORSCALERANGE is a non-standard
   * property supported by ncWMS servers, this property is ignored unless {@link WebMapServiceCatalogItem#supportsColorScaleRange}
   * is true.  {@link WebMapServiceCatalogItem#colorScaleMaximum} must be set as well.
   * @type {Number}
   */
  this.colorScaleMinimum = undefined;

  /**
   * Gets or sets the maximum of the color scale range.  Because COLORSCALERANGE is a non-standard
   * property supported by ncWMS servers, this property is ignored unless {@link WebMapServiceCatalogItem#supportsColorScaleRange}
   * is true.  {@link WebMapServiceCatalogItem#colorScaleMinimum} must be set as well.
   * @type {Number}
   */
  this.colorScaleMaximum = undefined;

  /**
   * Gets or sets the list of additional dimensions (e.g. elevation) and their possible values available from the
   * WMS server.  If undefined, this property is automatically populated from the WMS GetCapabilities on load.
   * This property is an object that has a property named for each layer.  The value of the property is an array
   * of dimensions available for this layer.  A dimension has the fields shown in the example below.  See the
   * WMS 1.3.0 specification, section C.2, for a description of the fields.  All fields are optional except
   * `name` and `options`.  This property is observable.
   * @type {Object}
   * @example
   * wmsItem.availableDimensions = {
   *     mylayer: [
   *         {
   *             name: 'elevation',
   *             units: 'CRS:88',
   *             unitSymbol: 'm',
   *             default: -0.03125,
   *             multipleValues: false,
   *             nearestValue: false,
   *             options: [
   *                 -0.96875,
   *                 -0.90625,
   *                 -0.84375,
   *                 -0.78125,
   *                 -0.71875,
   *                 -0.65625,
   *                 -0.59375,
   *                 -0.53125,
   *                 -0.46875,
   *                 -0.40625,
   *                 -0.34375,
   *                 -0.28125,
   *                 -0.21875,
   *                 -0.15625,
   *                 -0.09375,
   *                 -0.03125
   *             ]
   *         }
   *     ]
   * };
   */
  this.availableDimensions = undefined;

  /**
   * Gets or sets the selected values for dimensions available for this WMS layer.  The value of this property is
   * an object where each key is the name of a dimension and each value is the value to use for that dimension.
   * Note that WMS does not allow dimensions to be explicitly specified per layer.  So the selected dimension values are
   * applied to all layers with a corresponding dimension.
   * This property is observable.
   * @type {Object}
   * @example
   * wmsItem.dimensions = {
   *     elevation: -0.65625
   * };
   */
  this.dimensions = undefined;

  /**
   * Gets or sets the URL to use for requesting tokens. Typically, this is set to `/esri-token-auth` to use
   * the ArcGIS token mechanism built into terriajs-server.
   * @type {String}
   */
  this.tokenUrl = undefined;

  /**
   * Gets or sets the name of the URL query parameter used to provide the token
   * to the server. This property is ignored if {@link WebMapServiceCatalogItem#tokenUrl} is undefined.
   * @type {String}
   * @default 'token'
   */
  this.tokenParameterName = "token";

  /**
   * Gets or sets the set of HTTP status codes that indicate that a token is invalid.
   * This property is ignored if {@link WebMapServiceCatalogItem#tokenUrl} is undefined.
   * @type {Number[]}
   * @default [401, 498, 499]
   */
  this.tokenInvalidHttpCodes = [401, 498, 499];

  /**
   * A HTML string to show above the chart as a disclaimer
   * @type {String}
   * @default null
   */
  this.chartDisclaimer = null;

  this._sourceInfoItemNames = ["GetCapabilities URL"];

  knockout.track(this, [
    "_getCapabilitiesUrl",
    "_rectangle",
    "_rectangleFromMetadata",
    "_intervalsFromMetadata",
    "layers",
    "styles",
    "parameters",
    "getFeatureInfoFormats",
    "tilingScheme",
    "populateIntervalsFromTimeDimension",
    "minScaleDenominator",
    "disableUserChanges",
    "availableStyles",
    "colorScaleMinimum",
    "colorScaleMaximum",
    "availableDimensions",
    "dimensions",
    "tokenUrl",
    "tokenParameterName",
    "tokenInvalidHttpCodes",
    "_lastToken",
    "_thisLayerInRawMetadata",
    "_allLayersInRawMetadata"
  ]);

  knockout.defineProperty(this, "_colorScaleRange", {
    get: function() {
      return [this.colorScaleMinimum, this.colorScaleMaximum];
    }
  });

  this._refreshInProgress = undefined;
  knockout.getObservable(this, "_colorScaleRange").subscribe(function() {
    if (this.isEnabled && !this._refreshInProgress) {
      this._refreshInProgress = runLater(() => {
        this.refresh();
        this._refreshInProgress = undefined;
      });
    }
  }, this);

  // getCapabilitiesUrl and legendUrl are derived from url if not explicitly specified.
  overrideProperty(this, "getCapabilitiesUrl", {
    get: function() {
      if (defined(this._getCapabilitiesUrl)) {
        return this._getCapabilitiesUrl;
      }

      if (defined(this.metadataUrl)) {
        return this.metadataUrl;
      }

      if (!defined(this.url)) {
        return undefined;
      }

      return (
        cleanUrl(this.url) +
        "?service=WMS&version=1.3.0&request=GetCapabilities"
      );
    },
    set: function(value) {
      this._getCapabilitiesUrl = value;
    }
  });

  var legendUrlsBase = Object.getOwnPropertyDescriptor(this, "legendUrls");

  overrideProperty(this, "legendUrls", {
    get: function() {
      if (defined(this._legendUrls)) {
        return this._legendUrls;
      } else if (defined(this._legendUrl)) {
        return [this._legendUrl];
      } else {
        return computeLegendUrls(this);
      }
    },
    set: function(value) {
      legendUrlsBase.set.call(this, value);
    }
  });

  // The dataUrl must be explicitly specified.  Don't try to use `url` as the the dataUrl, because it won't work for a WMS URL.
  overrideProperty(this, "dataUrl", {
    get: function() {
      return this._dataUrl;
    },
    set: function(value) {
      this._dataUrl = value;
    }
  });

  overrideProperty(this, "dataUrlType", {
    get: function() {
      return this._dataUrlType;
    },
    set: function(value) {
      this._dataUrlType = value;
    }
  });
};

inherit(ImageryLayerCatalogItem, WebMapServiceCatalogItem);

Object.defineProperties(WebMapServiceCatalogItem.prototype, {
  /**
   * Gets the type of data item represented by this instance.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {String}
   */
  type: {
    get: function() {
      return "wms";
    }
  },

  /**
   * Gets a human-readable name for this type of data source, 'Web Map Service (WMS)'.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {String}
   */
  typeName: {
    get: function() {
      return i18next.t("models.webMapServiceCatalogItem.wms");
    }
  },

  /**
   * Gets a value indicating whether this {@link ImageryLayerCatalogItem} supports the {@link ImageryLayerCatalogItem#intervals}
   * property for configuring time-dynamic imagery.
   * @type {Boolean}
   */
  supportsIntervals: {
    get: function() {
      return true;
    }
  },

  /**
   * Gets the metadata associated with this data source and the server that provided it, if applicable.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {Metadata}
   */
  metadata: {
    get: function() {
      if (!defined(this._metadata)) {
        this._metadata = requestMetadata(this);
      }
      return this._metadata;
    }
  },

  /**
   * Gets the set of functions used to update individual properties in {@link CatalogMember#updateFromJson}.
   * When a property name in the returned object literal matches the name of a property on this instance, the value
   * will be called as a function and passed a reference to this instance, a reference to the source JSON object
   * literal, and the name of the property.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {Object}
   */
  updaters: {
    get: function() {
      return WebMapServiceCatalogItem.defaultUpdaters;
    }
  },

  /**
   * Gets the set of functions used to serialize individual properties in {@link CatalogMember#serializeToJson}.
   * When a property name on the model matches the name of a property in the serializers object literal,
   * the value will be called as a function and passed a reference to the model, a reference to the destination
   * JSON object literal, and the name of the property.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {Object}
   */
  serializers: {
    get: function() {
      return WebMapServiceCatalogItem.defaultSerializers;
    }
  },

  /**
   * Gets the set of names of the properties to be serialized for this object when {@link CatalogMember#serializeToJson} is called
   * for a share link.
   * @memberOf WebMapServiceCatalogItem.prototype
   * @type {String[]}
   */
  propertiesForSharing: {
    get: function() {
      return WebMapServiceCatalogItem.defaultPropertiesForSharing;
    }
  },

  /**
   * Gets the title of each of the layers in {@link WebMapServiceCatalogItem#layers}.  If the layer
   * titles are not yet known (because GetCapabilities has not been loaded yet, for example), this
   * property will return undefined.
   * @memberOf ImageryLayerCatalogItem.prototype
   * @type {String[]}
   */
  layerTitles: {
    get: function() {
      if (!defined(this._allLayersInRawMetadata)) {
        return undefined;
      }
      return this._allLayersInRawMetadata.map(function(layer) {
        return layer.Title || layer.Name;
      });
    }
  }
});

WebMapServiceCatalogItem.defaultUpdaters = clone(
  ImageryLayerCatalogItem.defaultUpdaters
);

WebMapServiceCatalogItem.defaultUpdaters.tilingScheme = function(
  wmsItem,
  json,
  propertyName,
  options
) {
  if (json.tilingScheme === "geographic") {
    wmsItem.tilingScheme = new GeographicTilingScheme();
  } else if (json.tilingScheme === "web-mercator") {
    wmsItem.tilingScheme = new WebMercatorTilingScheme();
  } else {
    wmsItem.tilingScheme = json.tilingScheme;
  }
};

WebMapServiceCatalogItem.defaultUpdaters.getFeatureInfoFormats = function(
  wmsItem,
  json,
  propertyName,
  options
) {
  var formats = [];

  for (var i = 0; i < json.getFeatureInfoFormats.length; ++i) {
    var format = json.getFeatureInfoFormats[i];
    formats.push(new GetFeatureInfoFormat(format.type, format.format));
  }

  wmsItem.getFeatureInfoFormats = formats;
};

Object.freeze(WebMapServiceCatalogItem.defaultUpdaters);

WebMapServiceCatalogItem.defaultSerializers = clone(
  ImageryLayerCatalogItem.defaultSerializers
);

// Serialize the underlying properties instead of the public views of them.
WebMapServiceCatalogItem.defaultSerializers.getCapabilitiesUrl = function(
  wmsItem,
  json,
  propertyName
) {
  json.getCapabilitiesUrl = wmsItem._getCapabilitiesUrl;
};

WebMapServiceCatalogItem.defaultSerializers.tilingScheme = function(
  wmsItem,
  json,
  propertyName
) {
  if (wmsItem.tilingScheme instanceof GeographicTilingScheme) {
    json.tilingScheme = "geographic";
  } else if (wmsItem.tilingScheme instanceof WebMercatorTilingScheme) {
    json.tilingScheme = "web-mercator";
  } else {
    json.tilingScheme = wmsItem.tilingScheme;
  }
};

// Do not serialize availableDimensions, availableStyles, intervals, description, info - these can be huge and can be recovered from the server.
// Normally when you share a WMS item, it is inside a WMS group, and when CatalogGroups are shared, they share their contents applying the
// CatalogMember.propertyFilters.sharedOnly filter, which only shares the "propertiesForSharing".
// However, if you create a straight WMS item outside a group (eg. by duplicating it), then share it, it will serialize everything it can.
WebMapServiceCatalogItem.defaultSerializers.availableDimensions = function() {};
WebMapServiceCatalogItem.defaultSerializers.availableStyles = function() {};
WebMapServiceCatalogItem.defaultSerializers.intervals = function() {};
WebMapServiceCatalogItem.defaultSerializers.description = function() {};
WebMapServiceCatalogItem.defaultSerializers.info = function() {};

Object.freeze(WebMapServiceCatalogItem.defaultSerializers);

/**
 * Gets or sets the default set of properties that are serialized when serializing a {@link CatalogItem}-derived object
 * for a share link.
 * @type {String[]}
 */
WebMapServiceCatalogItem.defaultPropertiesForSharing = clone(
  ImageryLayerCatalogItem.defaultPropertiesForSharing
);
WebMapServiceCatalogItem.defaultPropertiesForSharing.push("styles");
WebMapServiceCatalogItem.defaultPropertiesForSharing.push("colorScaleMinimum");
WebMapServiceCatalogItem.defaultPropertiesForSharing.push("colorScaleMaximum");
WebMapServiceCatalogItem.defaultPropertiesForSharing.push("dimensions");

Object.freeze(WebMapServiceCatalogItem.defaultPropertiesForSharing);

/**
 * The collection of strings that indicate an Abstract property should be ignored.  If these strings occur anywhere
 * in the Abstract, the Abstract will not be used.  This makes it easy to filter out placeholder data like
 * Geoserver's "A compliant implementation of WMS..." stock abstract.
 * @type {Array}
 */
WebMapServiceCatalogItem.abstractsToIgnore = [
  "A compliant implementation of WMS"
];

WebMapServiceCatalogItem.getAllAvailableStylesFromCapabilities = function(
  capabilities,
  layers,
  result,
  inheritedStyles
) {
  if (!defined(result)) {
    result = {};
    layers =
      capabilities && capabilities.Capability
        ? capabilities.Capability.Layer
        : [];
  }

  if (!defined(layers)) {
    return result;
  }

  layers = Array.isArray(layers) ? layers : [layers];

  for (var i = 0; i < layers.length; ++i) {
    var layer = layers[i];
    var styles = WebMapServiceCatalogItem.getSingleLayerStylesFromCapabilities(
      layer,
      inheritedStyles
    );
    if (defined(layer.Name) && layer.Name.length > 0) {
      result[layer.Name] = styles;
    }
    WebMapServiceCatalogItem.getAllAvailableStylesFromCapabilities(
      capabilities,
      layer.Layer,
      result,
      styles
    );
  }

  return result;
};

WebMapServiceCatalogItem.getSingleLayerStylesFromCapabilities = function(
  layerInCapabilities,
  inheritedStyles
) {
  inheritedStyles = inheritedStyles || [];

  if (!defined(layerInCapabilities) || !defined(layerInCapabilities.Style)) {
    return inheritedStyles;
  }

  var styles = Array.isArray(layerInCapabilities.Style)
    ? layerInCapabilities.Style
    : [layerInCapabilities.Style];
  return inheritedStyles.concat(
    styles.map(function(style) {
      var legendUrl = Array.isArray(style.LegendURL)
        ? style.LegendURL[0]
        : style.LegendURL;

      var legendUri, legendMimeType;
      if (
        legendUrl &&
        legendUrl.OnlineResource &&
        legendUrl.OnlineResource["xlink:href"]
      ) {
        legendUri = new URI(
          decodeURIComponent(legendUrl.OnlineResource["xlink:href"])
        );
        legendMimeType = legendUrl.Format;
      }

      return {
        name: style.Name,
        title: style.Title,
        abstract: style.Abstract,
        legendUri: legendUri
          ? new LegendUrl(legendUri.toString(), legendMimeType)
          : undefined
      };
    })
  );
};

WebMapServiceCatalogItem.getAllAvailableDimensionsFromCapabilities = function(
  capabilities,
  layers,
  result,
  inheritedDimensions
) {
  if (!defined(result)) {
    result = {};
    layers =
      capabilities && capabilities.Capability
        ? capabilities.Capability.Layer
        : [];
  }

  if (!defined(layers)) {
    return result;
  }

  layers = Array.isArray(layers) ? layers : [layers];

  for (var i = 0; i < layers.length; ++i) {
    var layer = layers[i];
    var dimensions = WebMapServiceCatalogItem.getSingleLayerDimensionsFromCapabilities(
      layer,
      inheritedDimensions
    );
    if (defined(layer.Name) && layer.Name.length > 0) {
      result[layer.Name] = dimensions;
    }
    WebMapServiceCatalogItem.getAllAvailableDimensionsFromCapabilities(
      capabilities,
      layer.Layer,
      result,
      dimensions
    );
  }

  return result;
};

WebMapServiceCatalogItem.getSingleLayerDimensionsFromCapabilities = function(
  layerInCapabilities,
  inheritedDimensions
) {
  inheritedDimensions = inheritedDimensions || [];

  if (
    !defined(layerInCapabilities) ||
    !defined(layerInCapabilities.Dimension)
  ) {
    return inheritedDimensions;
  }

  var dimensions = Array.isArray(layerInCapabilities.Dimension)
    ? layerInCapabilities.Dimension
    : [layerInCapabilities.Dimension];

  // WMS 1.1.1 puts dimension values in an Extent element instead of directly in the Dimension element.
  var extents = layerInCapabilities.Extent
    ? Array.isArray(layerInCapabilities.Extent)
      ? layerInCapabilities.Extent
      : [layerInCapabilities.Extent]
    : [];

  // Filter out inherited dimensions that are duplicated here.  Child layer dimensions override parent layer dimensions.
  inheritedDimensions = inheritedDimensions.filter(
    inheritedDimension =>
      dimensions.filter(dimension => dimension.name === inheritedDimension.name)
        .length === 0
  );

  return inheritedDimensions.concat(
    dimensions.map(dimension => {
      var correspondingExtent = extents.filter(
        extent => extent.name === dimension.name
      )[0];

      var options;
      if (correspondingExtent && correspondingExtent.split) {
        options = correspondingExtent.split(",");
      } else if (dimension.split) {
        options = dimension.split(",");
      } else {
        options = [];
      }

      return {
        name: dimension.name,
        units: dimension.units,
        unitSymbol: dimension.unitSymbol,
        default: dimension.default,
        multipleValues: dimension.multipleValues,
        nearestValue: dimension.nearestValue,
        options: options
      };
    })
  );
};

/**
 * Updates this catalog item from a WMS GetCapabilities document.
 * @param {Object|XMLDocument} capabilities The capabilities document.  This may be a JSON object or an XML document.  If it
 *                             is a JSON object, each layer is expected to have a `_parent` property with a reference to its
 *                             parent layer.
 * @param {Boolean} [overwrite=false] True to overwrite existing property values with data from the capabilities; false to
 *                  preserve any existing values.
 * @param {Object} [thisLayer] A reference to this layer within the JSON capabilities object.  If this parameter is not
 *                 specified or if `capabilities` is an XML document, the layer is found automatically based on this
 *                 catalog item's `layers` property.
 * @param {Object} [infoDerivedFromCapabilities] Additional information already derived from the GetCapabilities document, including:
 * @param {Object} [infoDerivedFromCapabilities.availableStyles] The available styles from this WMS server, structured as in the
 *                 {@link WebMapServiceCatalogItem#availableStyles} property.
 * @param {Object} [infoDerivedFromCapabilities.availableDimensions] The available dimensions from this WMS server, structured as in
 *                 the {@link WebMapServiceCatalogItem#availableDimensions} property.
 */
WebMapServiceCatalogItem.prototype.updateFromCapabilities = function(
  capabilities,
  overwrite,
  thisLayer,
  infoDerivedFromCapabilities
) {
  if (defined(capabilities.documentElement)) {
    capabilities = capabilitiesXmlToJson(this, capabilities);
    thisLayer = undefined;
  }

  if (!defined(this.availableStyles)) {
    if (
      defined(infoDerivedFromCapabilities) &&
      defined(infoDerivedFromCapabilities.availableStyles)
    ) {
      this.availableStyles = infoDerivedFromCapabilities.availableStyles;
    } else {
      this.availableStyles = WebMapServiceCatalogItem.getAllAvailableStylesFromCapabilities(
        capabilities
      );
    }
  }

  if (!defined(this.availableDimensions)) {
    if (
      defined(infoDerivedFromCapabilities) &&
      defined(infoDerivedFromCapabilities.availableDimensions)
    ) {
      this.availableDimensions =
        infoDerivedFromCapabilities.availableDimensions;
    } else {
      this.availableDimensions = WebMapServiceCatalogItem.getAllAvailableDimensionsFromCapabilities(
        capabilities
      );
    }
  }

  if (
    !defined(this.isGeoServer) &&
    capabilities &&
    capabilities.Service &&
    capabilities.Service.KeywordList &&
    capabilities.Service.KeywordList.Keyword &&
    capabilities.Service.KeywordList.Keyword.indexOf("GEOSERVER") >= 0
  ) {
    this.isGeoServer = true;
  }

  if (
    (!defined(this.isEsri) && defined(capabilities["xmlns:esri_wms"])) ||
    this.url.match(/\/MapServer\//)
  ) {
    this.isEsri = true;
  }

  if (
    !defined(this.isNcWMS) &&
    capabilities &&
    capabilities.Capability &&
    capabilities.Capability.Layer
  ) {
    var myLayer = findLayers(capabilities.Capability.Layer, this.layers);
    if (defined(myLayer) && myLayer.length > 0) {
      myLayer = myLayer[0];
      if (myLayer && myLayer.Style && myLayer.Style.length > 0) {
        for (var j = 0; j < myLayer.Style.length; ++j) {
          if (
            !defined(this.isNcWMS) &&
            myLayer.Style[j].Name &&
            (myLayer.Style[j].Name.match(/boxfill\/rainbow/i) ||
              myLayer.Style[j].Name.match(/default-scalar\/default/i) ||
              myLayer.Style[j].Name.match(/default-vector\/default/i))
          ) {
            this.isNcWMS = true;
          }
        }
      }
    }
  }

  if (!defined(this.supportsColorScaleRange)) {
    this.supportsColorScaleRange = this.isNcWMS;

    if (!this.supportsColorScaleRange) {
      var hasExtendedRequests =
        capabilities.Capability &&
        capabilities.Capability.ExtendedCapabilities &&
        capabilities.Capability.ExtendedCapabilities.ExtendedRequest;

      if (hasExtendedRequests) {
        var extendedRequests =
          capabilities.Capability.ExtendedCapabilities.ExtendedRequest;
        extendedRequests = Array.isArray(extendedRequests)
          ? extendedRequests
          : [extendedRequests];

        var extendedGetMap = extendedRequests.filter(
          request => request.Request === "GetMap"
        )[0];
        if (extendedGetMap) {
          var urlParameters = Array.isArray(extendedGetMap.UrlParameter)
            ? extendedGetMap.UrlParameter
            : [extendedGetMap.UrlParameter];
          var colorScaleRangeParameter = urlParameters.filter(
            parameter => parameter.ParameterName === "COLORSCALERANGE"
          )[0];
          this.supportsColorScaleRange = defined(colorScaleRangeParameter);
        }
      }
    }
  }

  if (!defined(thisLayer)) {
    thisLayer = findLayers(capabilities.Capability.Layer, this.layers);

    if (defined(this.layers)) {
      var layers = this.layers.split(",");
      var styles = (this.styles || this.parameters.styles || "").split(",");
      for (var i = 0; i < thisLayer.length; ++i) {
        if (!defined(thisLayer[i])) {
          if (thisLayer.length > 1) {
            console.log(
              'A layer with the name or ID "' +
                layers[i] +
                '" does not exist on the WMS Server - ignoring it.'
            );
            thisLayer.splice(i, 1);
            layers.splice(i, 1);
            styles.splice(i, 1);
            --i;
          } else {
            var suggested =
              capabilities &&
              capabilities.Capability &&
              capabilities.Capability.Layer &&
              capabilities.Capability.Layer.Layer &&
              capabilities.Capability.Layer.Layer.Name;
            suggested = suggested
              ? ' (Perhaps it should be "' + suggested + '").'
              : "";
            throw new TerriaError({
              title: i18next.t(
                "models.webMapServiceCatalogItem.noLayerFoundTitle"
              ),
              message: i18next.t(
                "models.webMapServiceCatalogItem.noLayerFoundMessage",
                {
                  name: this.name,
                  layers: this.layers,
                  suggested: suggested,
                  email:
                    '<a href="mailto:' +
                    this.terria.supportEmail +
                    '">' +
                    this.terria.supportEmail +
                    "</a>.",
                  line: "\n"
                }
              )
            });
          }
        } else {
          layers[i] = thisLayer[i].Name;
        }
      }

      this.layers = layers.join(",");
      this.styles = styles.join(",");
    }

    if (thisLayer.length === 0) {
      return;
    }
  }

  this._rawMetadata = capabilities;

  if (Array.isArray(thisLayer)) {
    this._thisLayerInRawMetadata = thisLayer[0];
    this._allLayersInRawMetadata = thisLayer;
    thisLayer = this._thisLayerInRawMetadata;
  } else {
    this._thisLayerInRawMetadata = thisLayer;
    this._allLayersInRawMetadata = [thisLayer];
  }

  this._overwriteFromGetCapabilities = overwrite;
};

function loadFromCapabilities(wmsItem) {
  var thisLayer = wmsItem._thisLayerInRawMetadata;
  if (!defined(thisLayer)) {
    return;
  }

  var overwrite = wmsItem._overwriteFromGetCapabilities;
  var capabilities = wmsItem._rawMetadata;

  if (
    !containsAny(thisLayer.Abstract, WebMapServiceCatalogItem.abstractsToIgnore)
  ) {
    updateInfoSection(
      wmsItem,
      overwrite,
      i18next.t("models.webMapServiceCatalogItem.dataDescription"),
      thisLayer.Abstract
    );
  }

  var service = defined(capabilities.Service) ? capabilities.Service : {};

  // Show the service abstract if there is one, and if it isn't the Geoserver default "A compliant implementation..."
  if (
    !containsAny(
      service.Abstract,
      WebMapServiceCatalogItem.abstractsToIgnore
    ) &&
    service.Abstract !== thisLayer.Abstract
  ) {
    updateInfoSection(
      wmsItem,
      overwrite,
      i18next.t("models.webMapServiceCatalogItem.serviceDescription"),
      service.Abstract
    );
  }

  // If style is defined in parameters, use that, but only if a style with that name can be found.
  // Otherwise use first style in list.
  var style = Array.isArray(thisLayer.Style)
    ? thisLayer.Style[0]
    : thisLayer.Style;
  if (defined(wmsItem.parameters.styles)) {
    var styleName = wmsItem.parameters.styles;
    if (Array.isArray(thisLayer.Style)) {
      for (var ind = 0; ind < thisLayer.Style.length; ind++) {
        if (thisLayer.Style[ind].Name === styleName) {
          style = thisLayer.Style[ind];
        }
      }
    } else {
      if (defined(thisLayer.style) && thisLayer.style.styleName === styleName) {
        style = thisLayer.style;
      }
    }
  }

  if (defined(style) && defined(style.MetadataURL)) {
    var metadataUrls = (Array.isArray(style.MetadataURL)
      ? style.MetadataURL
      : [style.MetadataURL]
    )
      .map(function(metadataUrl) {
        return metadataUrl && metadataUrl.OnlineResource
          ? metadataUrl.OnlineResource["xlink:href"]
          : undefined;
      })
      .filter(url => defined(url))
      .join("<br>");

    updateInfoSection(
      wmsItem,
      overwrite,
      i18next.t("models.webMapServiceCatalogItem.metadataUrls"),
      metadataUrls
    );
  }

  // Show the Access Constraints if it isn't "none" (because that's the default, and usually a lie).
  if (
    defined(service.AccessConstraints) &&
    !/^none$/i.test(service.AccessConstraints)
  ) {
    updateInfoSection(
      wmsItem,
      overwrite,
      i18next.t("models.webMapServiceCatalogItem.accessConstraints"),
      service.AccessConstraints
    );
  }

  updateInfoSection(
    wmsItem,
    overwrite,
    i18next.t("models.webMapServiceCatalogItem.accessConstraints"),
    getServiceContactInformation(capabilities)
  );
  updateInfoSection(
    wmsItem,
    overwrite,
    i18next.t("models.webMapServiceCatalogItem.getCapabilitiesUrl"),
    wmsItem.getCapabilitiesUrl
  );

  updateValue(
    wmsItem,
    overwrite,
    "minScaleDenominator",
    thisLayer.MinScaleDenominator
  );
  updateValue(
    wmsItem,
    overwrite,
    "getFeatureInfoFormats",
    getFeatureInfoFormats(capabilities)
  );
  updateValue(
    wmsItem,
    overwrite,
    "rectangle",
    getRectangleFromLayers(wmsItem._allLayersInRawMetadata)
  );
  updateValue(
    wmsItem,
    overwrite,
    "intervals",
    getIntervalsFromLayer(wmsItem, thisLayer)
  );

  var crs = defaultValue(
    getInheritableProperty(thisLayer, "CRS", true),
    getInheritableProperty(thisLayer, "SRS", true)
  );

  var tilingScheme;
  var srs;

  if (defined(crs)) {
    if (crsIsMatch(crs, "EPSG:3857")) {
      // Standard Web Mercator
      tilingScheme = new WebMercatorTilingScheme();
      srs = "EPSG:3857";
    } else if (crsIsMatch(crs, "EPSG:900913")) {
      // Older code for Web Mercator
      tilingScheme = new WebMercatorTilingScheme();
      srs = "EPSG:900913";
    } else if (crsIsMatch(crs, "EPSG:4326")) {
      // Standard Geographic
      tilingScheme = new GeographicTilingScheme();
      srs = "EPSG:4326";
    } else if (crsIsMatch(crs, "CRS:84")) {
      // Another name for EPSG:4326
      tilingScheme = new GeographicTilingScheme();
      srs = "CRS:84";
    } else if (crsIsMatch(crs, "EPSG:4283")) {
      // Australian system that is equivalent to EPSG:4326.
      tilingScheme = new GeographicTilingScheme();
      srs = "EPSG:4283";
    } else {
      // No known supported CRS listed.  Try the default, EPSG:3857, and hope for the best.
      tilingScheme = new WebMercatorTilingScheme();
      srs = "EPSG:3857";
    }
  }

  updateValue(wmsItem, overwrite, "tilingScheme", tilingScheme);

  if (!defined(wmsItem.parameters)) {
    wmsItem.parameters = {};
  }
  updateValue(wmsItem.parameters, overwrite, "srs", srs);

  if (wmsItem.supportsColorScaleRange) {
    updateValue(wmsItem, overwrite, "colorScaleMinimum", -50);
    updateValue(wmsItem, overwrite, "colorScaleMaximum", 50);
  }
}

function addToken(url, tokenParameterName, token) {
  if (!defined(token)) {
    return url;
  } else {
    return new URI(url).setQuery(tokenParameterName, token).toString();
  }
}

WebMapServiceCatalogItem.prototype._load = function() {
  var that = this;

  var promise = when();
  if (this.tokenUrl) {
    promise = getToken(this.terria, this.tokenUrl, this.url);
  }

  return promise.then(function(token) {
    that._lastToken = token;

    var promises = [];

    if (!defined(that._rawMetadata) && defined(that.getCapabilitiesUrl)) {
      promises.push(
        loadXML(
          proxyCatalogItemUrl(
            that,
            addToken(
              that.getCapabilitiesUrl,
              that.tokenParameterName,
              that._lastToken
            ),
            "1d"
          )
        ).then(function(xml) {
          var metadata = capabilitiesXmlToJson(that, xml);
          that.updateFromCapabilities(metadata, false);
          loadFromCapabilities(that);
        })
      );
    } else {
      loadFromCapabilities(that);
    }

    // Query WMS for wfs or wcs URL if no dataUrl is present
    if (!defined(that.dataUrl) && defined(that.url)) {
      var describeLayersURL =
        cleanUrl(that.url) +
        "?service=WMS&version=1.1.1&sld_version=1.1.0&request=DescribeLayer&layers=" +
        encodeURIComponent(that.layers);

      promises.push(
        loadXML(
          proxyCatalogItemUrl(
            that,
            addToken(
              describeLayersURL,
              that.tokenParameterName,
              that._lastToken
            ),
            "1d"
          )
        )
          .then(function(xml) {
            var json = xml2json(xml);
            // LayerDescription could be an array. If so, only use the first element
            var LayerDescription =
              json.LayerDescription instanceof Array
                ? json.LayerDescription[0]
                : json.LayerDescription;
            if (
              defined(LayerDescription) &&
              defined(LayerDescription.owsURL) &&
              defined(LayerDescription.owsType)
            ) {
              switch (LayerDescription.owsType.toLowerCase()) {
                case "wfs":
                  if (
                    defined(LayerDescription.Query) &&
                    defined(LayerDescription.Query.typeName)
                  ) {
                    that.dataUrl = addToken(
                      cleanUrl(LayerDescription.owsURL) +
                        "?service=WFS&version=1.1.0&request=GetFeature&typeName=" +
                        LayerDescription.Query.typeName +
                        "&srsName=EPSG%3A4326&maxFeatures=1000",
                      that.tokenParameterName,
                      that._lastToken
                    );
                    that.dataUrlType = "wfs-complete";
                  } else {
                    that.dataUrl = addToken(
                      cleanUrl(LayerDescription.owsURL),
                      that.tokenParameterName,
                      that._lastToken
                    );
                    that.dataUrlType = "wfs";
                  }
                  break;
                case "wcs":
                  if (
                    defined(LayerDescription.Query) &&
                    defined(LayerDescription.Query.typeName)
                  ) {
                    that.dataUrl = addToken(
                      cleanUrl(LayerDescription.owsURL) +
                        "?service=WCS&version=1.1.1&request=DescribeCoverage&identifiers=" +
                        LayerDescription.Query.typeName,
                      that.tokenParameterName,
                      that._lastToken
                    );

                    if (that.linkedWcsUrl && that.linkedWcsCoverage === "") {
                      that.linkedWcsCoverage = LayerDescription.Query.typeName;
                    }
                    that.dataUrlType = "wcs-complete";
                  } else {
                    that.dataUrl = addToken(
                      cleanUrl(LayerDescription.owsURL),
                      that.tokenParameterName,
                      that._lastToken
                    );
                    that.dataUrlType = "wcs";
                  }
                  break;
              }
            }
          })
          .otherwise(function(err) {})
      ); // Catch potential XML error - doesn't matter if URL can't be retrieved
    }

    return when.all(promises).then(() => {
      that.terria.checkNowViewingForTimeWms();
    });
  });
};

function fixPlaceholders(urlString) {
  return urlString.replace(/%7B/g, "{").replace(/%7D/g, "}");
}

WebMapServiceCatalogItem.prototype.handleTileError = function(
  detailsRequestPromise,
  imageryProvider,
  x,
  y,
  level
) {
  if (!defined(this.tokenUrl)) {
    return detailsRequestPromise;
  }

  const that = this;
  return detailsRequestPromise.otherwise(function(e) {
    if (e && (e.statusCode === 498 || e.statusCode === 499)) {
      // This looks like an invalid token error, so try requesting a new one.
      if (!defined(that._newTokenRequestInFlight)) {
        that._newTokenRequestInFlight = getToken(
          that.terria,
          that.tokenUrl,
          that.url
        ).then(function(token) {
          that._lastToken = token;

          // Turns out setting a parameter after the WMS provider is created is not a thing we can do elegantly.
          // So here we do it super hackily.
          const oldTemplateProvider = imageryProvider._tileProvider;
          const newTemplateProvider = new UrlTemplateImageryProvider({
            url: fixPlaceholders(
              addToken(
                oldTemplateProvider.url,
                that.tokenParameterName,
                that._lastToken
              )
            ),
            pickFeaturesUrl: fixPlaceholders(
              addToken(
                oldTemplateProvider.pickFeaturesUrl,
                that.tokenParameterName,
                that._lastToken
              )
            ),
            tilingScheme: oldTemplateProvider.tilingScheme,
            rectangle: oldTemplateProvider.rectangle,
            tileWidth: oldTemplateProvider.tileWidth,
            tileHeight: oldTemplateProvider.tileHeight,
            minimumLevel: oldTemplateProvider.minimumLevel,
            maximumLevel: oldTemplateProvider.maximumLevel,
            proxy: oldTemplateProvider.proxy,
            subdomains: oldTemplateProvider.subdomains,
            tileDiscardPolicy: oldTemplateProvider.tileDiscardPolicy,
            credit: oldTemplateProvider.credit,
            getFeatureInfoFormats: oldTemplateProvider.getFeatureInfoFormats,
            enablePickFeatures: oldTemplateProvider.enablePickFeatures,
            hasAlphaChannel: oldTemplateProvider.hasAlphaChannel,
            urlSchemeZeroPadding: oldTemplateProvider.urlSchemeZeroPadding
          });
          newTemplateProvider._errorEvent = oldTemplateProvider._errorEvent;

          imageryProvider._tileProvider = newTemplateProvider;

          that._newTokenRequestInFlight = undefined;
        });
      }

      return that._newTokenRequestInFlight;
    } else {
      return when.reject(e);
    }
  });
};

WebMapServiceCatalogItem.prototype._createImageryProvider = function(time) {
  var parameters = objectToLowercase(this.parameters);

  if (defined(time)) {
    parameters = combine({ time: time }, parameters);
  }

  if (defined(this._lastToken)) {
    parameters = combine({ [this.tokenParameterName]: this._lastToken });
  }

  parameters = combine(parameters, WebMapServiceCatalogItem.defaultParameters);
  // request one more feature than we will show, so that we can tell the user if there are more not shown
  if (defined(parameters.feature_count)) {
    console.log(
      this.name +
        ": using parameters.feature_count (" +
        parameters.feature_count +
        ") to override maximumShownFeatureInfos (" +
        this.maximumShownFeatureInfos +
        ")."
    );
    if (parameters.feature_count === 1) {
      this.maximumShownFeatureInfos = 1;
    } else {
      this.maximumShownFeatureInfos = parameters.feature_count - 1;
    }
  } else {
    parameters.feature_count = this.maximumShownFeatureInfos + 1;
  }

  if (
    defined(this.styles) &&
    (!defined(parameters.styles) || parameters.styles.length === 0)
  ) {
    parameters.styles = this.styles;
  }

  if (
    defined(this.colorScaleMinimum) &&
    defined(this.colorScaleMaximum) &&
    !defined(parameters.colorscalerange)
  ) {
    parameters.colorscalerange = [
      this.colorScaleMinimum,
      this.colorScaleMaximum
    ].join(",");
  }

  var maximumLevel = scaleDenominatorToLevel(this.minScaleDenominator);

  if (
    defined(this.dimensions) &&
    (!defined(parameters.dimensions) || parameters.dimensions.length === 0)
  ) {
    for (var dimensionName in this.dimensions) {
      if (this.dimensions.hasOwnProperty(dimensionName)) {
        // elevation is specified as simply elevation.
        // Other (custom) dimensions are prefixed with 'dim_'.
        // See WMS 1.3.0 spec section C.3.2 and C.3.3.
        if (dimensionName.toLowerCase() === "elevation") {
          parameters.elevation = this.dimensions[dimensionName];
        } else {
          parameters["dim_" + dimensionName] = this.dimensions[dimensionName];
        }
      }
    }
  }

  const imageryOptions = {
    url: cleanAndProxyUrl(this, this.url),
    layers: this.layers,
    getFeatureInfoFormats: this.getFeatureInfoFormats,
    parameters: parameters,
    getFeatureInfoParameters: parameters,
    tilingScheme: defined(this.tilingScheme)
      ? this.tilingScheme
      : new WebMercatorTilingScheme(),
    maximumLevel: maximumLevel
  };

  if (this.hideLayerAfterMinScaleDenominator) {
    imageryOptions.maximumLevel = maximumLevel + 1;
  }

  var imageryProvider = new WebMapServiceImageryProvider(imageryOptions);

  if (this.hideLayerAfterMinScaleDenominator) {
    var realRequestImage = imageryProvider.requestImage;
    var messageDisplayed = false;

    var that = this;
    imageryProvider.requestImage = function(x, y, level) {
      if (level > maximumLevel) {
        if (!messageDisplayed) {
          that.terria.error.raiseEvent(
            new TerriaError({
              title: i18next.t(
                "models.webMapServiceCatalogItem.datasetScaleErrorTitle"
              ),
              message: i18next.t(
                "models.webMapServiceCatalogItem.datasetScaleErrorMessage",
                { name: that.name }
              )
            })
          );
          messageDisplayed = true;
        }
        return ImageryProvider.loadImage(
          imageryProvider,
          that.terria.baseUrl + "images/blank.png"
        );
      }
      return realRequestImage.call(imageryProvider, x, y, level);
    };
  }

  return imageryProvider;
};

WebMapServiceCatalogItem.prototype.exportData = function() {
  // Use linked WCS to export data
  if (!defined(this.linkedWcsUrl)) {
    return undefined;
  }
  callWebCoverageService(this);
};

WebMapServiceCatalogItem.defaultParameters = {
  transparent: true,
  format: "image/png",
  exceptions: "application/vnd.ogc.se_xml",
  styles: "",
  tiled: true
};

function cleanAndProxyUrl(catalogItem, url) {
  return proxyCatalogItemUrl(catalogItem, cleanUrl(url));
}

function cleanUrl(url) {
  // Strip off the search portion of the URL
  var uri = new URI(url);
  uri.search("");
  return uri.toString();
}

function scaleDenominatorToLevel(minScaleDenominator) {
  if (!defined(minScaleDenominator) || minScaleDenominator <= 0.0) {
    return undefined;
  }

  var metersPerPixel = 0.00028; // from WMS 1.3.0 spec section 7.2.4.6.9
  var tileWidth = 256;

  var circumferenceAtEquator = 2 * Math.PI * Ellipsoid.WGS84.maximumRadius;
  var distancePerPixelAtLevel0 = circumferenceAtEquator / tileWidth;
  var level0ScaleDenominator = distancePerPixelAtLevel0 / metersPerPixel;

  // 1e-6 epsilon from WMS 1.3.0 spec, section 7.2.4.6.9.
  var ratio = level0ScaleDenominator / (minScaleDenominator - 1e-6);
  var levelAtMinScaleDenominator = Math.log(ratio) / Math.log(2);
  return levelAtMinScaleDenominator | 0;
}

function getRectangleFromLayer(layer) {
  var egbb = layer.EX_GeographicBoundingBox; // required in WMS 1.3.0
  if (defined(egbb)) {
    return Rectangle.fromDegrees(
      egbb.westBoundLongitude,
      egbb.southBoundLatitude,
      egbb.eastBoundLongitude,
      egbb.northBoundLatitude
    );
  } else {
    var llbb = layer.LatLonBoundingBox; // required in WMS 1.0.0 through 1.1.1
    if (defined(llbb)) {
      return Rectangle.fromDegrees(llbb.minx, llbb.miny, llbb.maxx, llbb.maxy);
    }
  }
  return undefined;
}

function getRectangleFromLayers(layers) {
  if (!Array.isArray(layers)) {
    return getRectangleFromLayer(layers);
  }

  return unionRectangleArray(
    layers.map(function(item) {
      return getRectangleFromLayer(item);
    })
  );
}

function updateIntervalsFromIsoSegments(intervals, isoSegments, time, wmsItem) {
  // Note parseZone will create a moment with the original specified UTC offset if there is one,
  // but if not, it will create a moment in UTC.
  var start = moment.parseZone(isoSegments[0]);
  var stop = moment.parseZone(isoSegments[1]);

  if (isoSegments.length === 2) {
    // Does this situation ever arise?  The standard is confusing:
    // Section 7.2.4.6.10 of the standard, defining getCapabilities, refers to sections 6.7.5 through 6.7.7.
    // Section 6.7.6 is about Temporal CS, and says in full:
    //     Some geographic information may be available at multiple times (for example, an hourly weather map). A WMS
    //     may announce available times in its service metadata, and the GetMap operation includes a parameter for
    //     requesting a particular time. The format of a time string is specified in Annex D. Depending on the context, time
    //     values may appear as a single value, a list of values, or an interval, as specified in Annex C. When providing
    //     temporal information, a server should declare a default value in service metadata, and a server shall respond with
    //     the default value if one has been declared and the client request does not include a value.
    // Annex D says only moments and periods are allowed - it does not mention intervals.
    // Annex C describes how to request layers - not what getCapabilities returns: but it does allow for intervals.
    //     In either case, value uses the format described in Table C.2 to provide a single value, a comma-separated list, or
    //     an interval of the form start/end without a resolution... An interval in a request
    //     value is a request for all the data from the start value up to and including the end value.
    // This seems to imply getCapabilities should only return dates or periods, but that you can request a period, and receive
    // a server-defined aggregation of the layers in that period.
    //
    // But MapServer actually gives an example getCapabilities which contains a period:
    //     http://mapserver.org/ogc/wms_time.html#getcapabilities-output
    //     <Extent name="time" default="2004-01-01 14:10:00" nearestValue="0">2004-01-01/2004-02-01</Extent>
    // The standard defines nearestValue such that: 0 = request value(s) must correspond exactly to declared extent value(s),
    // and yet the default is not exactly a declared extend value.
    // So it looks like Map Server defines a period in GetCapabilities, but actually wants it requested using a date,
    // not a period, and that any date in that interval will return the same thing.
    intervals.addInterval(
      new TimeInterval({
        start: JulianDate.fromIso8601(start.format()),
        stop: JulianDate.fromIso8601(stop.format()),
        data: start // Convert the period to a date for requests (see discussion above).
      })
    );
  } else {
    // Note WMS uses extension ISO19128 of ISO8601; ISO 19128 allows start/end/periodicity
    // and does not use the "R[n]/" prefix for repeated intervals
    // eg. Data refreshed every 30 min: 2000-06-18T14:30Z/2000-06-18T14:30Z/PT30M
    // See 06-042_OpenGIS_Web_Map_Service_WMS_Implementation_Specification.pdf section D.4
    var duration = moment.duration(isoSegments[2]);
    if (
      duration.isValid() &&
      (duration.milliseconds() > 0 ||
        duration.seconds() > 0 ||
        duration.minutes() > 0 ||
        duration.hours() > 0 ||
        duration.days() > 0 ||
        duration.weeks() > 0 ||
        duration.months() > 0 ||
        duration.years() > 0)
    ) {
      var thisStop = start.clone();
      var prevStop = start;
      var stopDate = stop;
      var count = 0;

      // Add intervals starting at start until:
      //    we go past the stop date, or
      //    we go past the max limit
      while (
        thisStop &&
        prevStop.isSameOrBefore(stopDate) &&
        count < wmsItem.maxRefreshIntervals
      ) {
        thisStop.add(duration);
        intervals.addInterval(
          new TimeInterval({
            start: JulianDate.fromIso8601(prevStop.format()),
            stop: JulianDate.fromIso8601(thisStop.format()),
            data: formatMomentForWms(prevStop, duration) // used to form the web request
          })
        );
        prevStop = thisStop.clone();
        ++count;
      }
    } else {
      wmsItem.terria.error.raiseEvent(
        new TerriaError({
          title: i18next.t(
            "models.webMapServiceCatalogItem.badlyFormatedTitle"
          ),
          message: i18next.t(
            "models.webMapServiceCatalogItem.badlyFormatedMessage",
            { name: wmsItem.name, isoSegments: isoSegments[2] }
          )
        })
      );
    }
  }
}

function formatMomentForWms(m, duration) {
  // If the original moment only contained a date (not a time), and the
  // duration doesn't include hours, minutes, or seconds, format as a date
  // only instead of a date+time.  Some WMS servers get confused when
  // you add a time on them.
  if (
    duration.hours() > 0 ||
    duration.minutes() > 0 ||
    duration.seconds() > 0 ||
    duration.milliseconds() > 0
  ) {
    return m.format();
  } else if (m.creationData().format.indexOf("T") >= 0) {
    return m.format();
  } else {
    return m.format(m.creationData().format);
  }
}

function updateIntervalsFromTimes(result, times, index, defaultDuration) {
  var start = JulianDate.fromIso8601(times[index]);
  var stop;

  if (defaultDuration) {
    stop = JulianDate.addMinutes(start, defaultDuration, new JulianDate());
  } else if (index < times.length - 1) {
    // if the next date has a slash in it, just use the first part of it
    var nextTimeIsoSegments = times[index + 1].split("/");
    stop = JulianDate.fromIso8601(nextTimeIsoSegments[0]);
  } else if (result.length > 0) {
    var previousInterval = result.get(result.length - 1);
    var duration = JulianDate.secondsDifference(
      previousInterval.stop,
      previousInterval.start
    );
    stop = JulianDate.addSeconds(start, duration, new JulianDate());
  } else {
    // There's exactly one timestamp, so we set stop = start.
    stop = start;
  }
  result.addInterval(
    new TimeInterval({
      start: start,
      stop: stop,
      data: times[index]
    })
  );
}

function getIntervalsFromLayer(wmsItem, layer) {
  var dimensions = wmsItem.availableDimensions[layer.Name];

  if (!defined(dimensions)) {
    return undefined;
  }

  if (!(dimensions instanceof Array)) {
    dimensions = [dimensions];
  }

  var result = new TimeIntervalCollection();

  for (var i = 0; i < dimensions.length; ++i) {
    var dimension = dimensions[i];

    if (dimension.name && dimension.name.toLowerCase() !== "time") {
      continue;
    }

    var times = dimension.options;

    for (var j = 0; j < times.length; ++j) {
      var isoSegments = times[j].split("/");
      if (isoSegments.length > 1) {
        updateIntervalsFromIsoSegments(result, isoSegments, times[j], wmsItem);
      } else {
        updateIntervalsFromTimes(result, times, j, wmsItem.displayDuration);
      }
    }
  }

  return result;
}

function getFeatureInfoFormats(capabilities) {
  var supportsJsonGetFeatureInfo = false;
  var supportsXmlGetFeatureInfo = false;
  var supportsHtmlGetFeatureInfo = false;
  var xmlContentType = "text/xml";

  if (
    defined(capabilities.Capability.Request) &&
    defined(capabilities.Capability.Request.GetFeatureInfo) &&
    defined(capabilities.Capability.Request.GetFeatureInfo.Format)
  ) {
    var format = capabilities.Capability.Request.GetFeatureInfo.Format;
    if (format === "application/json") {
      supportsJsonGetFeatureInfo = true;
    } else if (
      defined(format.indexOf) &&
      format.indexOf("application/json") >= 0
    ) {
      supportsJsonGetFeatureInfo = true;
    }

    if (format === "text/xml" || format === "application/vnd.ogc.gml") {
      supportsXmlGetFeatureInfo = true;
      xmlContentType = format;
    } else if (defined(format.indexOf) && format.indexOf("text/xml") >= 0) {
      supportsXmlGetFeatureInfo = true;
      xmlContentType = "text/xml";
    } else if (
      defined(format.indexOf) &&
      format.indexOf("application/vnd.ogc.gml") >= 0
    ) {
      supportsXmlGetFeatureInfo = true;
      xmlContentType = "application/vnd.ogc.gml";
    } else if (defined(format.indexOf) && format.indexOf("text/html") >= 0) {
      supportsHtmlGetFeatureInfo = true;
    }
  }

  var result = [];

  if (supportsJsonGetFeatureInfo) {
    result.push(new GetFeatureInfoFormat("json"));
  }
  if (supportsXmlGetFeatureInfo) {
    result.push(new GetFeatureInfoFormat("xml", xmlContentType));
  }
  if (supportsHtmlGetFeatureInfo) {
    result.push(new GetFeatureInfoFormat("html"));
  }

  return result;
}

function requestMetadata(wmsItem) {
  var result = new Metadata();

  result.isLoading = true;

  result.promise = when(wmsItem.load())
    .then(function() {
      var json = wmsItem._rawMetadata;
      if (json && json.Service) {
        populateMetadataGroup(result.serviceMetadata, json.Service);
      } else {
        result.serviceErrorMessage =
          "Service information not found in GetCapabilities operation response.";
      }

      if (wmsItem._thisLayerInRawMetadata) {
        populateMetadataGroup(
          result.dataSourceMetadata,
          wmsItem._thisLayerInRawMetadata
        );
      } else {
        result.dataSourceErrorMessage =
          "Layer information not found in GetCapabilities operation response.";
      }

      result.isLoading = false;
    })
    .otherwise(function() {
      result.dataSourceErrorMessage =
        "An error occurred while invoking the GetCapabilities service.";
      result.serviceErrorMessage =
        "An error occurred while invoking the GetCapabilities service.";
      result.isLoading = false;
    });

  return result;
}

/* Given a comma-separated string of layer names, returns the layer objects corresponding to them. */
function findLayers(startLayer, names) {
  return names.split(",").map(function(name) {
    // Look for an exact match on the name.
    let match = findLayer(startLayer, name, false);

    if (!match) {
      const colonIndex = name.indexOf(":");
      if (colonIndex >= 0) {
        // This looks like a namespaced name.  Such names will (usually?) show up in GetCapabilities
        // as just their name without the namespace qualifier.
        const nameWithoutNamespace = name.substring(colonIndex + 1);
        match = findLayer(startLayer, nameWithoutNamespace, false);
      }
    }

    if (!match) {
      // Try matching by title.
      match = findLayer(startLayer, name, true);
    }

    return match;
  });
}

function findLayer(startLayer, name, allowMatchByTitle) {
  if (
    startLayer.Name === name ||
    (allowMatchByTitle && startLayer.Title === name && defined(startLayer.Name))
  ) {
    return startLayer;
  }

  var layers = startLayer.Layer;
  if (!defined(layers)) {
    return undefined;
  }

  var found = findLayer(layers, name, allowMatchByTitle);
  for (var i = 0; !found && i < layers.length; ++i) {
    var layer = layers[i];
    found = findLayer(layer, name, allowMatchByTitle);
  }

  return found;
}

function populateMetadataGroup(metadataGroup, sourceMetadata) {
  if (typeof sourceMetadata === "string" || sourceMetadata instanceof String) {
    return;
  }

  for (var name in sourceMetadata) {
    if (sourceMetadata.hasOwnProperty(name) && name !== "_parent") {
      var value = sourceMetadata[name];

      var dest;
      if (name === "BoundingBox" && value instanceof Array) {
        for (var i = 0; i < value.length; ++i) {
          var subValue = value[i];

          dest = new MetadataItem();
          dest.name = name + " (" + subValue.CRS + ")";
          dest.value = subValue;

          populateMetadataGroup(dest, subValue);

          metadataGroup.items.push(dest);
        }
      } else {
        dest = new MetadataItem();
        dest.name = name;
        dest.value = value;

        populateMetadataGroup(dest, value);

        metadataGroup.items.push(dest);
      }
    }
  }
}

function updateInfoSection(item, overwrite, sectionName, sectionValue) {
  if (!defined(sectionValue) || sectionValue.length === 0) {
    return;
  }

  var section = item.findInfoSection(sectionName);
  if (!defined(section)) {
    item.info.push({
      name: sectionName,
      content: sectionValue
    });
  } else if (overwrite) {
    section.content = sectionValue;
  }
}

function updateValue(item, overwrite, propertyName, propertyValue) {
  if (!defined(propertyValue)) {
    return;
  }

  if (overwrite || !defined(item[propertyName])) {
    item[propertyName] = propertyValue;
  }
}

function crsIsMatch(crs, matchValue) {
  if (crs === matchValue) {
    return true;
  }

  if (crs instanceof Array && crs.indexOf(matchValue) >= 0) {
    return true;
  }

  return false;
}

function getInheritableProperty(layer, name, appendValues) {
  var value = [];
  while (defined(layer)) {
    if (defined(layer[name])) {
      if (appendValues) {
        value = value.concat(
          layer[name] instanceof Array ? layer[name] : [layer[name]]
        );
      } else {
        return layer[name];
      }
    }
    layer = layer._parent;
  }

  return value.length > 0 ? value : undefined;
}

function capabilitiesXmlToJson(item, capabilitiesXml) {
  var json = xml2json(capabilitiesXml);
  if (!defined(json.Capability)) {
    throw new TerriaError({
      title: i18next.t("models.webMapServiceCatalogItem.missingDataTitle"),
      message: i18next.t("models.webMapServiceCatalogItem.missingDataMessage", {
        name: item.name,
        email:
          '<a href="mailto:' +
          item.terria.supportEmail +
          '">' +
          item.terria.supportEmail +
          "</a>.",
        line: "\n"
      })
    });
  }
  updateParentReference(json.Capability);
  return json;
}

function updateParentReference(capabilitiesJson, parent) {
  capabilitiesJson._parent = parent;

  var layers = capabilitiesJson.Layer;

  if (layers instanceof Array) {
    for (var i = 0; i < layers.length; ++i) {
      updateParentReference(layers[i], capabilitiesJson);
    }
  } else if (defined(layers)) {
    updateParentReference(layers, capabilitiesJson);
  }
}

function getServiceContactInformation(capabilities) {
  if (defined(capabilities.Service.ContactInformation)) {
    var contactInfo = capabilities.Service.ContactInformation;

    var text = "";

    var primary = contactInfo.ContactPersonPrimary;
    if (defined(primary)) {
      if (
        defined(primary.ContactOrganization) &&
        primary.ContactOrganization.length > 0
      ) {
        text += primary.ContactOrganization + "<br/>";
      }
    }

    if (
      defined(contactInfo.ContactElectronicMailAddress) &&
      contactInfo.ContactElectronicMailAddress.length > 0
    ) {
      text +=
        "[" +
        contactInfo.ContactElectronicMailAddress +
        "](mailto:" +
        contactInfo.ContactElectronicMailAddress +
        ")";
    }

    return text;
  } else {
    return undefined;
  }
}

// This is copied directly from Cesium's WebMapServiceImageryProvider.
function objectToLowercase(obj) {
  var result = {};
  for (var key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key.toLowerCase()] = obj[key];
    }
  }
  return result;
}

function computeLegendUrls(catalogItem) {
  var result = [];
  var layers = catalogItem._allLayersInRawMetadata;
  if (!defined(layers)) {
    return result;
  }

  var styles = catalogItem.styles.split(",");

  if (styles.length === 1 && styles[0] === "") {
    styles = [];
  }

  // Find or create a legend for each layer we're using
  for (var i = 0; i < layers.length; ++i) {
    var legend = computeLegendForLayer(catalogItem, layers[i], styles[i]);
    if (defined(legend)) {
      result.push(legend);
    }
  }

  return result;
}

function computeLegendForLayer(catalogItem, thisLayer, styleName) {
  var legendUri, legendMimeType;

  // If we're using a specific styleName, use the legend associated with that style (if any).
  // Otherwise, use the legend associated with the first style in the list.
  var style = Array.isArray(thisLayer.Style)
    ? thisLayer.Style[0]
    : thisLayer.Style;
  if (defined(styleName)) {
    if (Array.isArray(thisLayer.Style)) {
      for (var i = 0; i < thisLayer.Style.length; ++i) {
        if (thisLayer.Style[i].Name === styleName) {
          style = thisLayer.Style[i];
        }
      }
    } else {
      if (defined(thisLayer.Style) && thisLayer.Style.styleName === styleName) {
        style = thisLayer.Style;
      }
    }
  }

  if (defined(style) && defined(style.LegendURL)) {
    // Use the legend from the style.
    // According to the WMS schema, LegendURL is unbounded.  Use the first legend in the style.
    var legendUrl = Array.isArray(style.LegendURL)
      ? style.LegendURL[0]
      : style.LegendURL;
    if (
      defined(legendUrl) &&
      defined(legendUrl.OnlineResource) &&
      defined(legendUrl.OnlineResource["xlink:href"])
    ) {
      legendUri = new URI(
        decodeURIComponent(legendUrl.OnlineResource["xlink:href"])
      );
      legendMimeType = legendUrl.Format;
    }
  }

  if (!defined(legendUri)) {
    // Construct a GetLegendGraphic request.
    legendUri = new URI(
      cleanUrl(catalogItem.url) +
        "?service=WMS&version=1.1.0&request=GetLegendGraphic&format=image/png&transparent=True&layer=" +
        encodeURIComponent(thisLayer.Name)
    );
    if (defined(style) && defined(style.Name))
      legendUri.addQuery("styles", style.Name);

    legendMimeType = "image/png";
  }

  if (defined(legendUri)) {
    // Tweak the URL to produce a better looking legend when possible.
    if (legendUri.toString().match(/GetLegendGraphic/i)) {
      if (catalogItem.isGeoServer) {
        legendUri.setQuery("version", "1.1.0");
        var legendOptions = "fontSize:14;forceLabels:on;fontAntiAliasing:true";
        legendUri.setQuery("transparent", "True"); // remove if our background is no longer light
        // legendOptions += ';fontColor:0xDDDDDD' // enable if we can ensure a dark background
        // legendOptions += ';dpi:182';           // enable if we can scale the image back down by 50%.
        legendUri.setQuery("LEGEND_OPTIONS", legendOptions);
      } else if (catalogItem.isEsri) {
        // This sets the total dimensions of the legend, but if we don't know how many styles are included, we could make it worse
        // In some cases (eg few styles), we could increase the height to give them more room. But if we always force the height
        // and there are many styles, they'll end up very cramped. About the only solution would be to fetch the default legend, and then ask
        // for a legend that's a bit bigger than the default.
        // uri.setQuery('width', '300');
        // uri.setQuery('height', '300');
      }

      // Include all of the parameters in the legend URI as well.
      if (defined(catalogItem.parameters)) {
        for (var key in catalogItem.parameters) {
          if (catalogItem.parameters.hasOwnProperty(key)) {
            legendUri.setQuery(key, catalogItem.parameters[key]);
          }
        }
      }

      if (
        defined(catalogItem.colorScaleMinimum) &&
        defined(catalogItem.colorScaleMaximum) &&
        !defined(catalogItem.parameters.colorscalerange)
      ) {
        legendUri.setQuery(
          "colorscalerange",
          [catalogItem.colorScaleMinimum, catalogItem.colorScaleMaximum].join(
            ","
          )
        );
      }
    }

    return new LegendUrl(
      addToken(
        legendUri.toString(),
        catalogItem.tokenParameterName,
        catalogItem._lastToken
      ),
      legendMimeType
    );
  }

  return undefined;
}

module.exports = WebMapServiceCatalogItem;