Models/SensorObservationServiceCatalogItem.js

"use strict";

/*global require*/
var i18next = require("i18next").default;
var Mustache = require("mustache");

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

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

var JulianDate = require("terriajs-cesium/Source/Core/JulianDate").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var loadWithXhr = require("../Core/loadWithXhr");
var when = require("terriajs-cesium/Source/ThirdParty/when").default;

var DisplayVariablesConcept = require("../Map/DisplayVariablesConcept");
var inherit = require("../Core/inherit");
var featureDataToGeoJson = require("../Map/featureDataToGeoJson");
var GeoJsonCatalogItem = require("./GeoJsonCatalogItem");
var overrideProperty = require("../Core/overrideProperty");
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var raiseErrorToUser = require("./raiseErrorToUser");
var TableCatalogItem = require("./TableCatalogItem");
var TableColumn = require("../Map/TableColumn");
var TableStructure = require("../Map/TableStructure");
var TerriaError = require("../Core/TerriaError");
var VariableConcept = require("../Map/VariableConcept");
var xml2json = require("../ThirdParty/xml2json");

/**
 * A {@link CatalogItem} representing data obtained from a Sensor Observation Service (SOS) 2.0 server.
 * The SOS specifications are available at http://www.opengeospatial.org/standards/sos .
 * This requires a json configuration file which specifies the procedures and observableProperties to show.
 * If more than one procedure or observableProperty is provided, the user can choose between the options.
 * Note because of this need for configuration, there is no SOS catalog "group" (yet).
 *
 * The offerings parameter is not used, and no spatial filters are provided.
 * The default soap XML request body can be overridden to handle custom requirements.
 *
 * @alias SensorObservationServiceCatalogItem
 * @constructor
 * @extends TableCatalogItem
 *
 * @param {Terria} terria The Terria instance.
 * @param {String} [url] The base URL from which to retrieve the data.
 */
var SensorObservationServiceCatalogItem = function(terria, url) {
  TableCatalogItem.call(this, terria, url);

  this._concepts = [];

  this._featureMapping = undefined;

  // A bunch of variables used to manage changing the active concepts (procedure and/or observable property),
  // so they can handle errors in the result, and so you cannot change active concepts while in the middle of loading observations.
  this._previousProcedureIdentifier = undefined;
  this._previousObservablePropertyIdentifier = undefined;
  this._loadingProcedureIdentifier = undefined;
  this._loadingObservablePropertyIdentifier = undefined;
  this._revertingConcepts = false;
  this._loadingFeatures = false;

  // Set during changedActiveItems, so tests can access the promise.
  this._observationDataPromise = undefined;

  /**
   * Gets or sets a flag. If true, the catalog item will load all features, then, if
   * number of features < requestSizeLimit * requestNumberLimit, it will load all the observation data
   * for those features, and show that.
   * If false, or there are too many features, the observation data is only loaded when the feature is clicked on
   * (via a chart in the feature info panel).
   * Defaults to true.
   * @type {Boolean}
   */
  this.tryToLoadObservationData = true;

  /**
   * Gets or sets the maximum number of timeseries to request of the server in a single GetObservation request.
   * Servers may have a Response Size Limit, eg. 250.
   * Note the number of responses may be different to the number requested,
   * eg. the BoM server can return > 1 timeseries/feature identifier, (such as ...stations/41001702),
   * so it can be sensible to set this below the response size limit.
   * @type {Integer}
   */
  this.requestSizeLimit = 200;

  /**
   * Gets or sets the maximum number of GetObservation requests that we can fire off at a time.
   * If the response size limit is 250, and this is 4, then observations for at most 1000 features will load.
   * If there are more than 1000 features, they will be shown without observation data, until they are clicked.
   * @type {Integer}
   */
  this.requestNumberLimit = 3;

  /**
   * Gets or sets the name seen by the user for the list of procedures.
   * Defaults to "Procedure", but eg. for BoM, "Frequency" would be better.
   * @type {String}
   */
  this.proceduresName = i18next.t("models.sensorObservationService.procedure");

  /**
   * Gets or sets the name seen by the user for the list of observable properties.
   * Defaults to "Property", but eg. for BoM, "Observation type" would be better.
   * @type {String}
   */
  this.observablePropertiesName = i18next.t(
    "models.sensorObservationService.property"
  );

  /**
   * Gets or sets the sensor observation service procedures that the user can choose from for this catalog item.
   * An array of objects with keys 'identifier', 'title' and (optionally) 'defaultDuration' and 'units', eg.
   *     [{
   *        identifier: 'http://bom.gov.au/waterdata/services/tstypes/Pat7_C_B_1_YearlyMean',
   *        title: 'Annual Mean',
   *        defaultDuration: '20y'  // Final character must be s, h, d or y for seconds, hours, days or years.
   *     }]
   * The identifier is used for communication with the server, and the title is used for display to the user.
   * If there is only one object, the user is not presented with a choice.
   * @type {Object[]}
   */
  this.procedures = undefined;

  /**
   * Gets or sets the sensor observation service observableProperties that the user can choose from for this catalog item.
   * An array of objects with keys 'identifier', 'title' and (optionally) 'defaultDuration' and 'units', eg.
   *     [{
   *        identifier: 'http://bom.gov.au/waterdata/services/parameters/Storage Level',
   *        title: 'Storage Level',
   *        units: 'metres'
   *     }]
   * The identifier is used for communication with the server, and the title is used for display to the user.
   * If there is only one object, the user is not presented with a choice.
   * @type {Object[]}
   */
  this.observableProperties = undefined;

  /**
   * Gets or sets the index of the initially selected procedure. Defaults to 0.
   * @type {Number}
   */
  this.initialProcedureIndex = 0;

  /**
   * Gets or sets the index of the initially selected observable property. Defaults to 0.
   * @type {Number}
   */
  this.initialObservablePropertyIndex = 0;

  /**
   * A start date in ISO8601 format. All requests filter to this start date. Set to undefined for no temporal filter.
   * @type {String}
   */
  this.startDate = undefined;

  /**
   * An end date in ISO8601 format. All requests filter to this end date. Set to undefined to use the current date.
   * @type {String}
   */
  this.endDate = undefined;

  /**
   * Gets or sets a flag for whether to display all features at all times, when tryToLoadObservationData is True.
   * This can help the UX if the server returns some features starting in 1990 and some starting in 1995,
   * so that the latter still appear (as grey points with no data) in 1990.
   * It works by adding artificial rows to the table for each feature at the start and end of the total date range,
   * if not already present.
   * Set to false (the default) to only show points when they have data (including invalid data).
   * Set to true to display points even at times that the server does not return them.
   */
  this.showFeaturesAtAllTimes = false;

  /**
   * A flag to choose between representing the underlying data as a TableStructure or as GeoJson.
   * Geojson representation is not fully implemented - eg. currently only points are supported.
   * Set to true for geojson. This can allow for non-point data (once the code is written).
   * Set to false (the default) for table structure. This allows all the TableStyle options, and a better legend.
   */
  this.representAsGeoJson = false;

  /**
   * Whether to include the list of procedures in GetFeatureOfInterest calls, so that only locations that support
   * those procedures are returned. For some servers (such as BoM's Water Data Online), this causes the request to time out.
   * @default true
   */
  this.filterByProcedures = true;

  /**
   * If set, an array of IDs. Only station IDs that match these will be included.
   */
  this.stationIdWhitelist = undefined;

  /**
   * If set, an array of IDs. Only station IDs that don't match these will be included.
   */
  this.stationIdBlacklist = undefined;

  // Which columns of the tableStructure define a unique feature.
  // Use both because sometimes identifier is not unique (!).
  this._idColumnNames = ["identifier", "id"];

  this._geoJsonItem = undefined;

  /**
   * Gets or sets the template XML string to POST to the SOS server to query for GetObservation.
   * If this property is undefined,
   * {@link SensorObservationServiceCatalogItem.defaultRequestTemplate} is used.
   * This is used as a Mustache template. See SensorObservationServiceRequestTemplate.xml for the default.
   * Be careful with newlines inside tags: Mustache can add an extra space in the front of them,
   * which causes the request to fail on the SOS server. Eg.
   * <wsa:Action>
   * http://www.opengis.net/...
   * </wsa:Action>
   * will render as <wsa:Action> http://www.opengis.net/...</wsa:Action>
   * The space before the "http" will cause the request to fail.
   * This property is observable.
   * @type {String}
   */
  this.requestTemplate = undefined;

  knockout.track(this, ["_concepts"]);

  overrideProperty(this, "concepts", {
    get: function() {
      return this._concepts;
    }
  });

  // See explanation in the comments for TableCatalogItem.
  overrideProperty(this, "dataViewId", {
    get: function() {
      // We need an id that depends on the selected concepts.
      if (defined(this.procedures) && defined(this.observableProperties)) {
        var procedure = getObjectCorrespondingToSelectedConcept(
          this,
          "procedures"
        );
        var observableProperty = getObjectCorrespondingToSelectedConcept(
          this,
          "observableProperties"
        );
        return [
          (procedure && procedure.identifier) || "",
          (observableProperty && observableProperty.identifier) || ""
        ].join("-");
      }
    }
  });

  knockout.defineProperty(this, "activeConcepts", {
    get: function() {
      return this._concepts.map(function(parent) {
        return parent.items.filter(function(concept) {
          return concept.isActive;
        });
      });
    }
  });

  knockout.getObservable(this, "activeConcepts").subscribe(function() {
    // If we are in the middle of reverting concepts back to previous values, just ignore.
    if (this._revertingConcepts) {
      return;
    }
    // If we are in the middle of loading the features themselves, a change is fine and will happen with no further intervention.
    if (this._loadingFeatures) {
      return;
    }
    // If either of these names is not available, the user is probably in the middle of a change
    // (when for a brief moment either 0 or 2 items are selected). So ignore.
    var procedure = getObjectCorrespondingToSelectedConcept(this, "procedures");
    var observableProperty = getObjectCorrespondingToSelectedConcept(
      this,
      "observableProperties"
    );
    if (!defined(procedure) || !defined(observableProperty)) {
      return;
    }
    // If we are loading data (other than the feature data), do not allow a change.
    if (this.isLoading) {
      revertConceptsToPrevious(
        this,
        this._loadingProcedureIdentifier,
        this._loadingObservablePropertyIdentifier
      );
      var error = new TerriaError({
        sender: this,
        title: i18next.t("models.sensorObservationService.alreadyLoadingTitle"),
        message: i18next.t(
          "models.sensorObservationService.alreadyLoadingMessage"
        )
      });
      raiseErrorToUser(this.terria, error);
    } else {
      changedActiveItems(this);
    }
  }, this);
};

SensorObservationServiceCatalogItem.defaultRequestTemplate = require("./SensorObservationServiceRequestTemplate.xml");

inherit(TableCatalogItem, SensorObservationServiceCatalogItem);

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

  /**
   * Gets a human-readable name for this type of data source, 'GPX'.
   * @memberOf SensorObservationServiceCatalogItem.prototype
   * @type {String}
   */
  typeName: {
    get: function() {
      return i18next.t("models.sensorObservationService.sos");
    }
  },

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

  /**
   * 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 lieral,
   * 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 SensorObservationServiceCatalogItem.prototype
   * @type {Object}
   */
  serializers: {
    get: function() {
      return SensorObservationServiceCatalogItem.defaultSerializers;
    }
  },

  /**
   * Gets the data source associated with this catalog item. Might be a TableDataSource or a GeoJsonDataSource.
   * @memberOf SensorObservationServiceCatalogItem.prototype
   * @type {DataSource}
   */
  dataSource: {
    get: function() {
      if (defined(this._geoJsonItem)) {
        return this._geoJsonItem.dataSource;
      } else if (defined(this._dataSource)) {
        return this._dataSource;
      }
      return undefined;
    }
  }
});

/**
 * Gets or sets the default set of properties that are serialized when serializing a {@link CatalogItem}-derived for a
 * share link.
 * @type {String[]}
 */
SensorObservationServiceCatalogItem.defaultPropertiesForSharing = clone(
  TableCatalogItem.defaultPropertiesForSharing
);
SensorObservationServiceCatalogItem.defaultPropertiesForSharing.push(
  "initialProcedureIndex"
);
SensorObservationServiceCatalogItem.defaultPropertiesForSharing.push(
  "initialObservablePropertyIndex"
);
Object.freeze(SensorObservationServiceCatalogItem.defaultPropertiesForSharing);

SensorObservationServiceCatalogItem.defaultSerializers = clone(
  TableCatalogItem.defaultSerializers
);
SensorObservationServiceCatalogItem.defaultSerializers.activeConcepts = function() {
  // Don't serialize.
};
Object.freeze(SensorObservationServiceCatalogItem.defaultSerializers);

// Just the items that would influence the load from the abs server or the file
SensorObservationServiceCatalogItem.prototype._getValuesThatInfluenceLoad = function() {
  return [this.url];
};

SensorObservationServiceCatalogItem.prototype._load = function() {
  var that = this;
  if (!that.url) {
    return undefined;
  }
  that._loadingFeatures = true;
  that._concepts = buildConcepts(that);
  return loadFeaturesOfInterest(that)
    .then(function() {
      that._loadingFeatures = false;
      return loadObservationData(that);
    })
    .otherwise(function(e) {
      throw e;
    });
};

function loadSoapBody(item, templateContext) {
  var postDataTemplate = defaultValue(
    item.requestTemplate,
    SensorObservationServiceCatalogItem.defaultRequestTemplate
  );
  const xml = Mustache.render(postDataTemplate, templateContext);
  return loadWithXhr({
    url: proxyCatalogItemUrl(item, item.url, "0d"),
    responseType: "document",
    method: "POST",
    overrideMimeType: "text/xml",
    data: xml,
    headers: { "Content-Type": "application/soap+xml" }
  }).then(function(xml) {
    if (!defined(xml)) {
      return;
    }
    var json = xml2json(xml);
    if (json.Exception) {
      var errorMessage = i18next.t(
        "models.sensorObservationService.unknownError"
      );
      if (json.Exception.ExceptionText) {
        errorMessage = i18next.t(
          "models.sensorObservationService.exceptionMessage",
          { exceptionText: json.Exception.ExceptionText }
        );
      }
      throw new TerriaError({
        sender: item,
        title: item.name,
        message: errorMessage
      });
    }
    if (!defined(json.Body)) {
      throw new TerriaError({
        sender: item,
        title: item.name,
        message: i18next.t("models.sensorObservationService.missingBody")
      });
    }
    return json.Body;
  });
}

/**
 * Return the Mustache template context "temporalFilters" for this item.
 * If a "defaultDuration" parameter (eg. 60d or 12h) exists on either
 * procedure or observableProperty, restrict to that duration from item.endDate.
 * @param  {SensorObservationServiceCatalogItem} item This catalog item.
 * @param  {Object} [procedure] An element from the item.procedures array.
 * @param  {Object} [observableProperty] An element from the item.observableProperties array.
 * @return {Object[]} An array of {index, startDate, endDate}, or undefined.
 */
function getTemporalFiltersContext(item, procedure, observableProperty) {
  var defaultDuration =
    (procedure && procedure.defaultDuration) ||
    (observableProperty && observableProperty.defaultDuration);
  // If the item has no endDate, use the current datetime (to nearest second).
  var endDateIso8601 =
    item.endDate || JulianDate.toIso8601(JulianDate.now(), 0);
  if (defined(defaultDuration)) {
    var startDateIso8601 = addDurationToIso8601(
      endDateIso8601,
      "-" + defaultDuration
    );
    // This is just a string-based comparison, so timezones could make it up to 1 day wrong.
    // That much error is fine here.
    if (startDateIso8601 < item.startDate) {
      startDateIso8601 = item.startDate;
    }
    return [{ index: 1, startDate: startDateIso8601, endDate: endDateIso8601 }];
  } else {
    // If there is no procedure- or property-specific duration, use the item's start and end dates, if any.
    if (item.startDate) {
      return [{ index: 1, startDate: item.startDate, endDate: endDateIso8601 }];
    }
  }
}

SensorObservationServiceCatalogItem.getObjectCorrespondingToSelectedConcept = function(
  item,
  conceptIdAndItemKey
) {
  if (item[conceptIdAndItemKey].length === 1) {
    return item[conceptIdAndItemKey][0];
  } else {
    var parentConcept = item._concepts.filter(
      concept => concept.id === conceptIdAndItemKey
    )[0];
    var activeConceptIndices = parentConcept.items.filter(
      concept => concept.isActive
    );
    if (activeConceptIndices.length === 1) {
      var identifier = activeConceptIndices[0].id;
      var matches = item[conceptIdAndItemKey].filter(
        element => element.identifier === identifier
      );
      return matches[0];
    }
  }
};

function getObjectCorrespondingToSelectedConcept(item, conceptIdAndItemKey) {
  return SensorObservationServiceCatalogItem.getObjectCorrespondingToSelectedConcept(
    item,
    conceptIdAndItemKey
  );
}

function getConceptIndexOfIdentifier(item, conceptIdAndItemKey, identifier) {
  if (item[conceptIdAndItemKey].length === 1) {
    return 0;
  } else {
    var parentConcept = item._concepts.filter(
      concept => concept.id === conceptIdAndItemKey
    )[0];
    return parentConcept.items.map(concept => concept.id).indexOf(identifier);
  }
}

function observationResponsesToTableStructure(
  item,
  procedure,
  observableProperty,
  responses
) {
  // Iterate over all the points in all the time series in all the observations in all the bodies to get individual result rows.
  function extractValues(response) {
    var observationData =
      response.GetObservationResponse &&
      response.GetObservationResponse.observationData;
    if (defined(observationData)) {
      if (!Array.isArray(observationData)) {
        observationData = [observationData];
      }
      var observations = observationData.map(o => o.OM_Observation);
      observations.forEach(observation => {
        if (!defined(observation)) {
          return;
        }
        var points = observation.result.MeasurementTimeseries.point;
        if (!defined(points)) {
          return;
        }
        if (!Array.isArray(points)) {
          points = [points];
        }
        var measurements = points.map(point => point.MeasurementTVP); // TVP = Time value pairs, I think.
        // var procedureTitle = defined(observation.procedure) ? observation.procedure['xlink:title'] : 'value';
        // var featureName = observation.featureOfInterest['xlink:title'];
        var featureIdentifier = observation.featureOfInterest["xlink:href"];
        dateValues.push(
          ...measurements.map(measurement =>
            typeof measurement.time === "object" ? null : measurement.time
          )
        );
        valueValues.push(
          ...measurements.map(measurement =>
            typeof measurement.value === "object"
              ? null
              : parseFloat(measurement.value)
          )
        );
        // These 5 arrays constitute columns in the table, some of which (like this one) have the same
        // value in each row.
        featureValues.push(...measurements.map(_ => featureIdentifier));
        procedureValues.push(...measurements.map(_ => procedure.identifier));
        observedPropertyValues.push(
          ...measurements.map(_ => observableProperty.identifier)
        );
      });
    }
  }
  var dateValues = [],
    valueValues = [],
    featureValues = [],
    procedureValues = [],
    observedPropertyValues = [];

  // extract columns from response
  responses.forEach(extractValues);
  // Now turn all the columns of dates, values etc into a single table structure
  var observationTableStructure = new TableStructure("observations");
  var columnOptions = { tableStructure: observationTableStructure };
  var timeColumn = new TableColumn("date", dateValues, columnOptions);

  var units = observableProperty.units || procedure.units;
  var valueTitle =
    observableProperty.title +
    " " +
    procedure.title +
    (defined(units) ? " (" + units + ")" : "");
  var valueColumn = new TableColumn(valueTitle, valueValues, columnOptions);
  valueColumn.id = "value";
  valueColumn.units = units;

  var featureColumn = new TableColumn(
    "identifier",
    featureValues,
    columnOptions
  ); // featureColumn.id must be 'identifier', used as an idColumn.

  var procedureColumn = new TableColumn(
    item.proceduresName,
    procedureValues,
    columnOptions
  );

  var observedPropertyColumn = new TableColumn(
    item.observablePropertiesName,
    observedPropertyValues,
    columnOptions
  );

  observationTableStructure.columns = [
    timeColumn,
    valueColumn,
    featureColumn,
    procedureColumn,
    observedPropertyColumn
  ];
  return observationTableStructure;
}

/**
 * Returns a promise to a table structure of sensor observation data, given one/multiple featureOfInterest identifiers.
 * Uses the currently active concepts to determine the procedure and observedProperty filter.
 * Then batches GetObservation requests to actually fetch the values for that procedure and property at that site(s).
 * This is required by Chart.jsx for any non-csv format (which passes the chart's source url as the sole argument.)
 * @param  {String|String[]} featureOfInterestIdentifiers The featureOfInterest identifier, or array thereof.
 * @param {Object} options Object with the following properties:
 * @param {Object} [options.procedure] An object overriding the selected procedure, for instance from chart generated items being regenerated.
 * @return {Promise} A promise which resolves to a TableStructure.
 */
SensorObservationServiceCatalogItem.prototype.loadIntoTableStructure = function(
  featureOfInterestIdentifiers,
  options = {}
) {
  var item = this;
  if (!Array.isArray(featureOfInterestIdentifiers)) {
    featureOfInterestIdentifiers = [featureOfInterestIdentifiers];
  }
  var requestNumber = 0;
  var requests = [];
  var procedure = getObjectCorrespondingToSelectedConcept(item, "procedures");
  if (defined(options.procedure)) {
    procedure = options.procedure;
  }

  var observableProperty = getObjectCorrespondingToSelectedConcept(
    item,
    "observableProperties"
  );
  // If either of these names is not available, the user is probably in the middle of a change
  // (when for a brief moment either 0 or 2 items are selected). So ignore.
  if (
    !defined(procedure.identifier) ||
    !defined(observableProperty.identifier)
  ) {
    return when();
  }
  for (
    var startFeatureNumber = 0;
    startFeatureNumber < featureOfInterestIdentifiers.length;
    startFeatureNumber += this.requestSizeLimit
  ) {
    var theseFeatureIdentifiers = featureOfInterestIdentifiers.slice(
      startFeatureNumber,
      startFeatureNumber + this.requestSizeLimit
    );
    var paramArray = convertObjectToNameValueArray({
      procedure: procedure.identifier,
      observedProperty: observableProperty.identifier,
      featureOfInterest: theseFeatureIdentifiers // eg. 'http://bom.gov.au/waterdata/services/stations/425022'
    });
    const templateContext = {
      action: "GetObservation",
      actionClass: "core",
      parameters: paramArray,
      temporalFilters: getTemporalFiltersContext(
        item,
        procedure,
        observableProperty
      )
    };
    requests.push(loadSoapBody(item, templateContext));

    requestNumber++;
    if (requestNumber >= this.requestNumberLimit) {
      break;
    }
  }
  // Could improve UX by showing features as they are returned. For now, wait until we have them all.
  return when
    .all(requests)
    .then(responses =>
      observationResponsesToTableStructure(
        item,
        procedure,
        observableProperty,
        responses
      )
    )
    .otherwise(function(e) {
      // Improve the error reporting in the case that the error response is XML like this:
      // <ExceptionReport>
      //   <Exception exceptionCode="ResponseExceedsSizeLimit">
      //     <ExceptionText>The search terms matched more than 250 timeseries in the datasource...
      if (!defined(e.message) && defined(e.response)) {
        var json = xml2json(e.response);
        throw new TerriaError({
          sender: item,
          title: json.Exception && json.Exception.exceptionCode,
          message: json.Exception && json.Exception.ExceptionText
        });
      }
      throw e;
    });
};

// It's OK to override TableCatalogItem's enable, disable, because for lat/lon tables, they don't do anything.
SensorObservationServiceCatalogItem.prototype._enable = function() {
  if (defined(this._geoJsonItem)) {
    this._geoJsonItem._enable();
  }
};

SensorObservationServiceCatalogItem.prototype._disable = function() {
  if (defined(this._geoJsonItem)) {
    this._geoJsonItem._disable();
  }
};

// However show and hide need to become a combination of both the geojson and the lat/lon table catalog item versions.
SensorObservationServiceCatalogItem.prototype._show = function() {
  if (defined(this._geoJsonItem)) {
    this._geoJsonItem._show();
  } else if (defined(this._dataSource)) {
    var dataSources = this.terria.dataSources;
    if (dataSources.contains(this._dataSource)) {
      if (console && console.log) {
        console.log(new Error("This data source is already shown."));
      }
      return;
    }
    dataSources.add(this._dataSource);
  }
};

SensorObservationServiceCatalogItem.prototype._hide = function() {
  if (defined(this._geoJsonItem)) {
    this._geoJsonItem._hide();
  } else if (defined(this._dataSource)) {
    var dataSources = this.terria.dataSources;
    if (!dataSources.contains(this._dataSource)) {
      throw new DeveloperError("This data source is not shown.");
    }
    dataSources.remove(this._dataSource, false);
  }
};

SensorObservationServiceCatalogItem.prototype.showOnSeparateMap = function(
  globeOrMap
) {
  if (defined(this._geoJsonItem)) {
    return this._geoJsonItem.showOnSeparateMap(globeOrMap);
  } else {
    return TableCatalogItem.prototype.showOnSeparateMap.bind(this)(globeOrMap);
  }
};

/*
 * Performs the GetFeatureOfInterest request to obtain the locations of sources of data that match the required
 * observed properties and procedures.
 * @param {SensorObservationServiceCatalogItem} item
 * @return Promise for the request.
 */
function loadFeaturesOfInterest(item) {
  const filter = {
    observedProperty: item.observableProperties.map(
      observable => observable.identifier
    ) // eg. 'http://bom.gov.au/waterdata/services/parameters/Storage Level'
  };
  if (item.filterByProcedures) {
    filter.procedure = item.procedures.map(procedure => procedure.identifier); // eg. 'http://bom.gov.au/waterdata/services/tstypes/Pat7_C_B_1_YearlyMean',
  }
  const templateContext = {
    action: "GetFeatureOfInterest",
    actionClass: "foiRetrieval",
    parameters: convertObjectToNameValueArray(filter)
  };
  return loadSoapBody(item, templateContext)
    .then(function(body) {
      var featuresResponse = body.GetFeatureOfInterestResponse;
      // var locations = featuresResponse.featureMember.map(x=>x.MonitoringPoint.shape.Point.pos.text);
      if (!featuresResponse) {
        throw new TerriaError({
          sender: item,
          title: item.name,
          message: i18next.t("models.sensorObservationService.noFeatures")
        });
      }
      if (!defined(featuresResponse.featureMember)) {
        throw new TerriaError({
          sender: item,
          title: item.name,
          message: i18next.t("models.sensorObservationService.unknownFormat")
        });
      }
      var featureMembers = featuresResponse.featureMember;
      if (!Array.isArray(featureMembers)) {
        featureMembers = [featureMembers];
      }
      if (item.stationIdWhitelist) {
        featureMembers = featureMembers.filter(
          m =>
            m.MonitoringPoint &&
            item.stationIdWhitelist.indexOf(
              String(m.MonitoringPoint.identifier)
            ) >= 0
        );
      }
      if (item.stationIdBlacklist) {
        featureMembers = featureMembers.filter(
          m =>
            m.MonitoringPoint &&
            !item.stationIdBlacklist.indexOf(
              String(m.MonitoringPoint.identifier)
            ) >= 0
        );
      }
      if (item.representAsGeoJson) {
        item._geoJsonItem = createGeoJsonItemFromFeatureMembers(
          item,
          featureMembers
        );
        return item._geoJsonItem.load().then(function() {
          item.rectangle = item._geoJsonItem.rectangle;
          return;
        });
      } else {
        item._featureMapping = createMappingFromFeatureMembers(featureMembers);
      }
    })
    .otherwise(function(e) {
      throw e;
    });
}

/**
 * Given the features already loaded into item._featureMap, this loads the observations according to the user-selected concepts,
 * and puts them into item._tableStructure.
 * If there are too many features, fall back to a tableStructure without the observation data.
 * @param  {SensorObservationServiceCatalogItem} item This catalog item.
 * @return {Promise} A promise which, when it resolves, sets item._tableStructure.
 * @private
 */
function loadObservationData(item) {
  if (!item._featureMapping) {
    return;
  }
  var featuresOfInterest = Object.keys(item._featureMapping);
  // Are there too many features to load observations (or we've been asked not to try)?
  if (
    !item.tryToLoadObservationData ||
    featuresOfInterest.length > item.requestSizeLimit * item.requestNumberLimit
  ) {
    // MODE 1. Do not load observation data for the features.
    // Just show where the features are, and when the feature info panel is opened, then load the feature's observation data
    // (via the 'chart' column in _tableStructure, which generates a call to item.loadIntoTableStructure).
    var tableStructure = item._tableStructure;
    if (!defined(tableStructure)) {
      tableStructure = new TableStructure(item.name);
    }
    var columns = createColumnsFromMapping(item, tableStructure);
    tableStructure.columns = columns;
    if (!defined(item._tableStructure)) {
      item._tableStyle.dataVariable = null; // Turn off the legend and give all the points a single colour.
      item.initializeFromTableStructure(tableStructure);
    } else {
      item._tableStructure.columns = tableStructure.columns;
    }
    return when();
  }
  // MODE 2. Create a big time-varying tableStructure with all the observations for all the features.
  // In this mode, the feature info panel shows a chart through as a standard time-series, like it would for any time-varying csv.
  return item
    .loadIntoTableStructure(featuresOfInterest)
    .then(function(observationTableStructure) {
      if (
        !defined(observationTableStructure) ||
        observationTableStructure.columns[0].values.length === 0
      ) {
        throw new TerriaError({
          sender: item,
          title: item.name,
          message: i18next.t(
            "models.sensorObservationService.noMatchingFeatures"
          )
        });
      }
      // Add the extra columns from the mapping into the table.
      var identifiers = observationTableStructure.getColumnWithName(
        "identifier"
      ).values;
      var newColumns = createColumnsFromMapping(
        item,
        observationTableStructure,
        identifiers
      );
      observationTableStructure.activeTimeColumnNameIdOrIndex = undefined;
      observationTableStructure.columns = observationTableStructure.columns.concat(
        newColumns
      );
      observationTableStructure.idColumnNames = item._idColumnNames;
      if (item.showFeaturesAtAllTimes) {
        // Set finalEndJulianDate so that adding new null-valued feature rows doesn't mess with the final date calculations.
        // To do this, we need to set the active time column, so that finishJulianDates is calculated.
        observationTableStructure.setActiveTimeColumn(
          item.tableStyle.timeColumn
        );
        var finishDates = observationTableStructure.finishJulianDates.map(d =>
          Number(JulianDate.toDate(d))
        );
        // I thought we'd need to unset the time column, because we're about to change the columns again, and there can be interactions
        // - but it works without unsetting it.
        // observationTableStructure.setActiveTimeColumn(undefined);
        observationTableStructure.finalEndJulianDate = JulianDate.fromDate(
          new Date(Math.max.apply(null, finishDates))
        );
        observationTableStructure.columns = observationTableStructure.getColumnsWithFeatureRowsAtStartAndEndDates(
          "date",
          "value"
        );
      }
      if (!defined(item._tableStructure)) {
        observationTableStructure.name = item.name;
        item.initializeFromTableStructure(observationTableStructure);
      } else {
        observationTableStructure.setActiveTimeColumn(
          item.tableStyle.timeColumn
        );
        // Moving this isActive statement earlier stops all points appearing on the map/globe.
        observationTableStructure.columns.filter(
          column => column.id === "value"
        )[0].isActive = true;
        item._tableStructure.columns = observationTableStructure.columns; // TODO: doesn't do anything.
        // Force the timeline (terria.clock) to update by toggling "isShown" (see CatalogItem's isShownChanged).
        if (item.isShown) {
          item.isShown = false;
          item.isShown = true;
        }
        // Changing the columns triggers a knockout change of the TableDataSource that uses this table.
      }
    });
}

/**
 * Returns an array of procedure and/or observableProperty concepts,
 * and sets item._previousProcedureIdentifier and _previousObservablePropertyIdentifier.
 * @private
 */
function buildConcepts(item) {
  var concepts = [];
  if (!defined(item.procedures) || !defined(item.observableProperties)) {
    throw new DeveloperError(
      "Both `procedures` and `observableProperties` arrays must be defined on the catalog item."
    );
  }
  if (item.procedures.length > 1) {
    var concept = new DisplayVariablesConcept(item.proceduresName);
    concept.id = "procedures"; // must match the key of item['procedures']
    concept.requireSomeActive = true;
    concept.items = item.procedures.map((value, index) => {
      return new VariableConcept(value.title || value.identifier, {
        parent: concept,
        id: value.identifier, // used in the SOS request to identify the procedure.
        active: index === item.initialProcedureIndex
      });
    });
    concepts.push(concept);
    item._previousProcedureIdentifier =
      concept.items[item.initialProcedureIndex].id;
    item._loadingProcedureIdentifier =
      concept.items[item.initialProcedureIndex].id;
  }
  if (item.observableProperties.length > 1) {
    concept = new DisplayVariablesConcept(item.observablePropertiesName);
    concept.id = "observableProperties";
    concept.requireSomeActive = true;
    concept.items = item.observableProperties.map((value, index) => {
      return new VariableConcept(value.title || value.identifier, {
        parent: concept,
        id: value.identifier, // used in the SOS request to identify the procedure.
        active: index === item.initialObservablePropertyIndex
      });
    });
    concepts.push(concept);
    item._previousObservablePropertyIdentifier =
      concept.items[item.initialObservablePropertyIndex].id;
    item._loadingObservablePropertyIdentifier =
      concept.items[item.initialObservablePropertyIndex].id;
  }
  return concepts;
}

function getChartTagFromFeatureIdentifier(identifier, chartId) {
  // Including a chart id which depends on the frequency serves an important purpose: it means that something about the chart has changed,
  // which tells the FeatureInfoSection React component to re-render.
  // The feature's definitionChanged event triggers when the feature's properties change, but if this chart tag doesn't change,
  // React does not know to re-render the chart.
  if (defined(chartId)) {
    chartId = ' id="' + encodeURIComponent(chartId) + '"';
  } else {
    chartId = "";
  }
  return (
    '<chart src="' +
    identifier +
    '" can-download="false"' +
    chartId +
    "></chart>"
  );
}

/**
 * Converts the featureMembers into a mapping from identifier to its lat/lon and other info.
 * @param  {Object[]} featureMembers An array of feature members as returned by GetFeatureOfInterest in body.GetFeatureOfInterestResponse.featuresResponse.featureMember.
 * @return {Object} Keys = identifier, values = {lat, lon, name, id, identifier, type, chart}.
 * @private
 */
function createMappingFromFeatureMembers(featureMembers) {
  var mapping = {};
  featureMembers.forEach(member => {
    var shape = member.MonitoringPoint.shape;
    if (defined(shape.Point)) {
      var posString = shape.Point.pos;
      if (defined(posString.split)) {
        // Sometimes shape.Point.pos is actually an object, eg. {srsName: "http://www.opengis.net/def/crs/EPSG/0/4326"}
        var coords = posString.split(" ");
        if (coords.length === 2) {
          var identifier = member.MonitoringPoint.identifier.toString();
          mapping[identifier] = {
            lat: coords[0],
            lon: coords[1],
            name: member.MonitoringPoint.name,
            id: member.MonitoringPoint["gml:id"],
            identifier: identifier,
            type:
              member.MonitoringPoint.type &&
              member.MonitoringPoint.type["xlink:href"]
          };
          return mapping[identifier];
        }
      }
    } else {
      throw new DeveloperError(
        "Non-point feature not shown. You may want to implement `representAsGeoJson`. " +
          JSON.stringify(shape)
      );
    }
  });
  return mapping;
}

/**
 * Converts the featureMapping output by createMappingFromFeatureMembers into columns for a TableStructure.
 * @param  {SensorObservationServiceCatalogItem} item This catalog item.
 * @param  {TableStructure} [tableStructure] Used to set the columns' tableStructure (parent). If identifiers given, output columns line up with them.
 * @param  {String[]} identifiers An array of identifier values from tableStructure. Defaults to all available identifiers.
 * @return {TableColumn[]} An array of columns to add to observationTableStructure. Only include 'identifier' and 'chart' columns if no identifiers provided.
 * @private
 */
function createColumnsFromMapping(item, tableStructure, identifiers) {
  var featureMapping = item._featureMapping;
  var addChartColumn = !defined(identifiers);
  if (!defined(identifiers)) {
    identifiers = Object.keys(featureMapping);
  }
  var rows = identifiers.map(identifier => featureMapping[identifier]);
  var columnOptions = { tableStructure: tableStructure };
  var chartColumnOptions = { tableStructure: tableStructure, id: "chart" }; // So the chart column can be referred to in the FeatureInfoTemplate as 'chart'.
  var result = [
    new TableColumn("type", rows.map(row => row.type), columnOptions),
    new TableColumn("name", rows.map(row => row.name), columnOptions),
    new TableColumn("id", rows.map(row => row.id), columnOptions),
    new TableColumn("lat", rows.map(row => row.lat), columnOptions),
    new TableColumn("lon", rows.map(row => row.lon), columnOptions)
  ];
  if (addChartColumn) {
    var procedure = getObjectCorrespondingToSelectedConcept(item, "procedures");
    var observableProperty = getObjectCorrespondingToSelectedConcept(
      item,
      "observableProperties"
    );
    var chartName = procedure.title || observableProperty.title || "chart";
    var chartId = procedure.title + "_" + observableProperty.title;
    var charts = rows.map(row =>
      getChartTagFromFeatureIdentifier(row.identifier, chartId)
    );
    result.push(
      new TableColumn(
        "identifier",
        rows.map(row => row.identifier),
        columnOptions
      ),
      new TableColumn(chartName, charts, chartColumnOptions)
    );
  }
  return result;
}

function createGeoJsonItemFromFeatureMembers(item, featureMembers) {
  var geojson = {
    type: "FeatureCollection",
    features: featureMembers
      .map(member => {
        var shape = member.MonitoringPoint.shape;
        var geometry;
        if (defined(shape.Point)) {
          var posString = shape.Point.pos;
          if (defined(posString.split)) {
            // Sometimes shape.Point.pos is actually an object, eg. {srsName: "http://www.opengis.net/def/crs/EPSG/0/4326"}
            var coords = posString.split(" ");
            if (coords.length === 2) {
              geometry = {
                type: "Point",
                coordinates: [coords[1], coords[0]]
              };
            }
          }
        } else {
          throw new DeveloperError(
            "Feature shape type not implemented. " + JSON.stringify(shape)
          );
        }
        return {
          type: "Feature",
          geometry: geometry,
          properties: {
            name: member.MonitoringPoint.name,
            id: member.MonitoringPoint["gml:id"],
            identifier: member.MonitoringPoint.identifier.toString(),
            type:
              member.MonitoringPoint.type &&
              member.MonitoringPoint.type["xlink:href"]
          }
        };
      })
      .filter(geojson => defined(geojson.geometry))
  };
  var geoJsonItem = new GeoJsonCatalogItem(item.terria);
  geoJsonItem.data = featureDataToGeoJson(geojson);
  geoJsonItem.style = item.style; // For the future...
  return geoJsonItem;
}

/*
  Load the description of a sensor. In practice, it doesn't really contain anything useful.
  http://www.bom.gov.au/waterdata/services?service=SOS&version=2.0&request=DescribeSensor&procedureDescriptionFormat=http%3A%2F%2Fwww.opengis.net%2FsensorML%2F1.0.1&procedure=http%3A%2F%2Fbom.gov.au%2Fwaterdata%2Fservices%2Ftstypes%2FPat1_C_B_1
 */
// function loadDescription(item) {
//     return querySos(item, {
//         request: 'DescribeSensor',
//         procedure: item.procedure,
//         procedureDescriptionFormat: 'http://www.opengis.net/sensorML/1.0.1'
//     }).then(function(sensorml) {
//         //var description = sensormljson.description.SensorDescription.data.SensorML.member;
//         console.log('Sensor description: ', sensorml);
//     });
// }

/*
    Want to get more information about a location?

    new urijs('http://www.bom.gov.au/waterdata/services').setQuery({service:'SOS',version:'2.0',request:'GetFeatureOfInterest',featureOfInterest:'http://bom.gov.au/waterdata/services/stations/401229'});
    http://www.bom.gov.au/waterdata/services?service=SOS&version=2.0&request=GetFeatureOfInterest&featureOfInterest=http%3A%2F%2Fbom.gov.au%2Fwaterdata%2Fservices%2Fstations%2F401229

    Point location buried in featureMember -> MonitoringPoint -> shape -> Point
    Warning: some IDs don't have locations (ie http://bom.gov.au/waterdata/services/stations/system)
*/

function revertConceptsToPrevious(
  item,
  previousProcedureIdentifier,
  previousObservablePropertyIdentifier
) {
  var parentConcept;
  item._revertingConcepts = true;
  // Use the flag above to signify that we do not want to trigger a reload.
  if (defined(previousProcedureIdentifier)) {
    parentConcept = item._concepts.filter(
      concept => concept.id === "procedures"
    )[0];
    // Toggle the old value on again (unless it is already on). This auto-toggles-off the new value.
    var old =
      parentConcept &&
      parentConcept.items.filter(
        concept =>
          !concept.isActive && concept.id === previousProcedureIdentifier
      )[0];
    if (defined(old)) {
      old.toggleActive();
    }
  }
  if (defined(previousObservablePropertyIdentifier)) {
    parentConcept = item._concepts.filter(
      concept => concept.id === "observableProperties"
    )[0];
    old =
      parentConcept &&
      parentConcept.items.filter(
        concept =>
          !concept.isActive &&
          concept.id === previousObservablePropertyIdentifier
      )[0];
    if (defined(old)) {
      old.toggleActive();
    }
  }
  item._revertingConcepts = false;
}

function changedActiveItems(item) {
  // If either of these names is not available, the user is probably in the middle of a change
  // (when for a brief moment either 0 or 2 items are selected). So ignore.
  var procedure = getObjectCorrespondingToSelectedConcept(item, "procedures");
  var observableProperty = getObjectCorrespondingToSelectedConcept(
    item,
    "observableProperties"
  );
  if (!defined(procedure) || !defined(observableProperty)) {
    return;
  }
  item.isLoading = true;
  item._loadingProcedureIdentifier = procedure.identifier;
  item._loadingObservablePropertyIdentifier = observableProperty.identifier;
  item._observationDataPromise = loadObservationData(item)
    .then(function() {
      item.isLoading = false;
      // Save the current values of these concepts so we can fall back to them if there's an error moving to a new set.
      item._previousProcedureIdentifier = procedure.identifier;
      item._previousObservablePropertyIdentifier =
        observableProperty.identifier;
      // And save them for sharing.
      item.initialProcedureIndex = getConceptIndexOfIdentifier(
        item,
        "procedures",
        procedure.identifier
      );
      item.initialObservablePropertyIndex = getConceptIndexOfIdentifier(
        item,
        "observableProperties",
        observableProperty.identifier
      );
    })
    .otherwise(function(e) {
      revertConceptsToPrevious(
        item,
        item._previousProcedureIdentifier,
        item._previousObservablePropertyIdentifier
      );
      item.isLoading = false;
      raiseErrorToUser(item.terria, e);
    });
}

/**
 * Converts parameters {x: 'y'} into an array of {name: 'x', value: 'y'} objects.
 * Converts {x: [1, 2, ...]} into multiple objects:
 *   {name: 'x', value: 1}, {name: 'x', value: 2}, ...
 * @param  {Object} parameters eg. {a: 3, b: [6, 8]}
 * @return {Object[]} eg. [{name: 'a', value: 3}, {name: 'b', value: 6}, {name: 'b', value: 8}]
 * @private
 */
function convertObjectToNameValueArray(parameters) {
  return Object.keys(parameters).reduce((result, key) => {
    var values = parameters[key];
    if (!Array.isArray(values)) {
      values = [values];
    }
    return result.concat(
      values.map(value => {
        return {
          name: key,
          value: value
        };
      })
    );
  }, []);
}

var scratchJulianDate = new JulianDate();
/**
 * Adds a period to an iso8601-formatted date.
 * Periods must be (positive or negative) numbers followed by a letter:
 * s (seconds), h (hours), d (days), y (years).
 * To avoid confusion between minutes and months, do not use m.
 * @param  {String} dateIso8601 The date in ISO8601 format.
 * @param  {String} durationString The duration string, in the format described.
 * @return {String} A date string in ISO8601 format.
 * @private
 */
function addDurationToIso8601(dateIso8601, durationString) {
  if (!defined(dateIso8601) || dateIso8601.length < 3) {
    throw new DeveloperError("Bad date " + dateIso8601);
  }
  var duration = parseFloat(durationString);
  if (isNaN(duration) || duration === 0) {
    throw new DeveloperError("Bad duration " + durationString);
  }
  var julianDate = JulianDate.fromIso8601(dateIso8601, scratchJulianDate);
  var units = durationString.slice(durationString.length - 1);
  if (units === "s") {
    julianDate = JulianDate.addSeconds(julianDate, duration, scratchJulianDate);
  } else if (units === "h") {
    julianDate = JulianDate.addHours(julianDate, duration, scratchJulianDate);
  } else if (units === "d") {
    // Use addHours on 24 * numdays - on my casual reading of addDays, it needs an integer.
    julianDate = JulianDate.addHours(
      julianDate,
      duration * 24,
      scratchJulianDate
    );
  } else if (units === "y") {
    var days = Math.round(duration * 365);
    julianDate = JulianDate.addDays(julianDate, days, scratchJulianDate);
  } else {
    throw new DeveloperError(
      'Unknown duration type "' + durationString + '" (use s, h, d or y)'
    );
  }
  return JulianDate.toIso8601(julianDate);
}

// THE COMMENTED FUNCTIONS BELOW SHOW HOW TO LOAD ALL THE AVAILABLE PROCEDURES AND OBSERVABLEPROPERTIES FOR A SERVICE.
//
// var URI = require('urijs');
// var loadText = require('terriajs-cesium/Source/Core/loadText');

// function querySos(sosGroup, options) {
//     var url = new URI(sosGroup.url)
//         .query({ service: 'SOS', version: '2.0' })
//         .addQuery(options)
//         .toString();

//     return loadText(proxyCatalogItemUrl(sosGroup, url, '0d'))
//         .then(xml2json);
// }
// /**
//  * Retrieve list of all "offerings" of this service.
//  * Eg. http://www.bom.gov.au/waterdata/services?service=SOS&version=2.0&request=GetCapabilities
//  * @private
//  */
// function loadCapabilities(item) {
//     // Possible enhancement: we could look for features like GetRequest which is a CSV output:
//     // http://sensiasoft.net:8181/sensorhub/sos?service=SOS&version=2.0&request=GetResult&offering=urn:mysos:offering03&observedProperty=http://sensorml.com/ont/swe/property/Weather&temporalFilter=phenomenonTime,2015-10-15T16:34:00Z/2015-10-15T17:34:00Z
//     //
//     // "Offerings" are various pre-defined aggregations of data along different dimensions and with different filters.
//     // We don't necessarily want to expose them all to the end user. Also, they don't have nice IDs.
//     return querySos(item, {
//         request: 'GetCapabilities'
//     }).then(function(capabilities) {
//         console.log('GetCapabilities:', capabilities);
//         var offerings = capabilities.contents.Contents.offering.map(o => o.ObservationOffering);
//         if (!Array.isArray(offerings)) {
//             offerings = [offerings];
//         }
//         var observableProperties = capabilities.contents.Contents.observableProperty;
//         if (!Array.isArray(observableProperties)) {
//             observableProperties = [observableProperties];
//         }
//         console.log('offerings', offerings);
//         console.log('observableProperties', observableProperties);
//         return {
//             offerings: offerings,
//             observableProperties: observableProperties
//         };
//     });
// }

module.exports = SensorObservationServiceCatalogItem;