Source: Models/WebMapServiceCatalogItem.js

'use strict';

/*global require*/
var URI = require('urijs');
var moment = require('moment');

var clone = require('terriajs-cesium/Source/Core/clone');
var combine = require('terriajs-cesium/Source/Core/combine');
var defaultValue = require('terriajs-cesium/Source/Core/defaultValue');
var defined = require('terriajs-cesium/Source/Core/defined');
var defineProperties = require('terriajs-cesium/Source/Core/defineProperties');
var Ellipsoid = require('terriajs-cesium/Source/Core/Ellipsoid');
var freezeObject = require('terriajs-cesium/Source/Core/freezeObject');
var GeographicTilingScheme = require('terriajs-cesium/Source/Core/GeographicTilingScheme');
var GetFeatureInfoFormat = require('terriajs-cesium/Source/Scene/GetFeatureInfoFormat');
var getToken = require('./getToken');
var JulianDate = require('terriajs-cesium/Source/Core/JulianDate');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var loadXML = require('../Core/loadXML');
var Rectangle = require('terriajs-cesium/Source/Core/Rectangle');
var TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
var TimeIntervalCollection = require('terriajs-cesium/Source/Core/TimeIntervalCollection');
var UrlTemplateImageryProvider = require('terriajs-cesium/Source/Scene/UrlTemplateImageryProvider');
var WebMapServiceImageryProvider = require('terriajs-cesium/Source/Scene/WebMapServiceImageryProvider');
var WebMercatorTilingScheme = require('terriajs-cesium/Source/Core/WebMercatorTilingScheme');
var when = require('terriajs-cesium/Source/ThirdParty/when');

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 overrideProperty = require('../Core/overrideProperty');
var proxyCatalogItemUrl = require('./proxyCatalogItemUrl');
var unionRectangleArray = require('../Map/unionRectangleArray');
var xml2json = require('../ThirdParty/xml2json');
var LegendUrl = require('../Map/LegendUrl');

/**
 * 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 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 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];

    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']);

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

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 'Web Map Service (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;
};

freezeObject(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() {
};

freezeObject(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');

freezeObject(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: 'No layer found',
                            message: 'The WMS dataset "' + this.name + '" has no layers matching "' + this.layers + '".' + suggested +
                            '\n\nEither the catalog file has been set up incorrectly, or the WMS server has changed.' +
                            '\n\nPlease report this error by sending an email to <a href="mailto:' + this.terria.supportEmail + '">' + this.terria.supportEmail + '</a>.'
                        });
                    }
                } 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, 'Data Description', 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, 'Service Description', 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, 'Metadata Links', 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, 'Access Constraints', service.AccessConstraints);
    }

    updateInfoSection(wmsItem, overwrite, 'Service Contact', getServiceContactInformation(capabilities));
    updateInfoSection(wmsItem, overwrite, 'GetCapabilities URL', 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);
                                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);
    });
};

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;

    if (defined(this.minScaleDenominator)) {
        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 / (this.minScaleDenominator - 1e-6);
        var levelAtMinScaleDenominator = Math.log(ratio) / Math.log(2);
        maximumLevel = levelAtMinScaleDenominator | 0;
    }

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

    return new WebMapServiceImageryProvider({
        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
    });
};

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 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: 'Badly formatted periodicity',
                message: 'The "' + wmsItem.name + '" dataset has a badly formed periodicity, "' + isoSegments[2] +
                         '".  Click the dataset\'s Info button for more information about the dataset and the data custodian.'
            }));
        }
    }
}

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 time, so treat this layer as if it is not time-varying.
        return undefined;
    }
    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: 'Missing data',
            message: 'The WMS dataset "' + item.name + '" did not return any data.' +
            '\n\nEither the catalog file has been set up incorrectly, or the server address has changed.' +
            '\n\nPlease report this error by emailing <a href="mailto:' + item.terria.supportEmail + '">' + item.terria.supportEmail + '</a>.'
        });
    }
    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));
        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;