Event.java

/*
 * Event
 */
package gov.usgs.earthquake.indexer;

import gov.usgs.earthquake.product.ProductId;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * An event is a group of products that are nearby in space and time.
 *
 * Which products appear in an event depend primarily on the
 * ProductIndexQuery.ResultType that is used when retrieving an event from the
 * index. Unless CURRENT is used, you may not get what you expect.
 */
public class Event implements Comparable<Event> {

  /** Origin product type */
  public static final String ORIGIN_PRODUCT_TYPE = "origin";
  /** Associate product type */
  public static final String ASSOCIATE_PRODUCT_TYPE = "associate";
  /** Disassociate product type */
  public static final String DISASSOCIATE_PRODUCT_TYPE = "disassociate";
  /** Property for othereventsource */
  public static final String OTHEREVENTSOURCE_PROPERTY = "othereventsource";
  /** Property for othereventsourcecode */
  public static final String OTHEREVENTSOURCECODE_PROPERTY = "othereventsourcecode";

  /** An ID used by the ProductIndex. */
  private Long indexId = null;

  /** Products nearby in space and time. Keyed by type. */
  private Map<String, List<ProductSummary>> products = new HashMap<String, List<ProductSummary>>();

  /** Cached summary. */
  private EventSummary eventSummary = null;

  /**
   * Default constructor.
   *
   * All fields are set to null, and the list of products is empty.
   */
  public Event() {
  }

  /**
   * Construct an event with only an indexId. The products map will be empty.
   *
   * @param indexId the indexId to set.
   */
  public Event(final Long indexId) {
    this.setIndexId(indexId);
  }

  /**
   * Construct and event with an indexId and a list of products.
   *
   * @param indexId  the product index id.
   * @param products the list of products.
   */
  public Event(final Long indexId, final Map<String, List<ProductSummary>> products) {
    this.setIndexId(indexId);
    this.setProducts(products);
  }

  /**
   * Copy constructor for event.
   *
   * The products associated with this event are not cloned, but the list of
   * products is.
   *
   * @param copy the event to clone.
   */
  public Event(final Event copy) {
    this(copy.getIndexId(), copy.getAllProducts());
  }

  /**
   * Get the index id.
   *
   * @return the indexId or null if one hasn't been assigned.
   */
  public Long getIndexId() {
    return indexId;
  }

  /**
   * Set the index id.
   *
   * @param indexId the indexId to set.
   */
  public void setIndexId(Long indexId) {
    this.indexId = indexId;
  }

  /**
   * Get all products associated with event, even if they are deleted.
   *
   * @return all products associated with event.
   */
  public Map<String, List<ProductSummary>> getAllProducts() {
    return products;
  }

  /**
   * Get the event products.
   *
   * Only returns products that have not been deleted or superseded. This method
   * returns a copy of the underlying product map that has been filtered to remove
   * deleted products.
   *
   * @return a map of event products.
   * @see #getAllProducts()
   */
  public Map<String, List<ProductSummary>> getProducts() {
    Map<String, List<ProductSummary>> notDeleted = new HashMap<String, List<ProductSummary>>();
    Iterator<String> types = products.keySet().iterator();
    while (types.hasNext()) {
      String type = types.next();
      List<ProductSummary> notDeletedProducts = getProducts(type);
      if (notDeletedProducts.size() > 0) {
        notDeleted.put(type, notDeletedProducts);
      }
    }
    return notDeleted;
  }

  /**
   * Set products.
   *
   * ProductSummaries are not cloned, but lists are.
   *
   * @param newProducts the products to set.
   */
  public void setProducts(final Map<String, List<ProductSummary>> newProducts) {
    this.products.clear();
    Iterator<String> iter = new TreeSet<String>(newProducts.keySet()).iterator();
    while (iter.hasNext()) {
      String type = iter.next();
      this.products.put(type, new ArrayList<ProductSummary>(newProducts.get(type)));
    }
    eventSummary = null;
  }

  /**
   * A convenience method for adding a product summary to an event object.
   *
   * Note: this method does not update any associated product index.
   *
   * @param summary the summary to add to this event.
   */
  public void addProduct(final ProductSummary summary) {
    String type = summary.getId().getType();
    List<ProductSummary> list = products.get(type);
    if (list == null) {
      list = new ArrayList<ProductSummary>();
      products.put(type, list);
    }
    if (!list.contains(summary)) {
      list.add(summary);
    }
    eventSummary = null;
  }

  /**
   * A convenience method for removing a product summary from an event object.
   *
   * Note: this method does not update any associated product index.
   *
   * @param summary the summary to remove from this event.
   */
  public void removeProduct(final ProductSummary summary) {
    String type = summary.getId().getType();
    // find the list of products of this type
    List<ProductSummary> list = products.get(type);
    if (list != null) {
      // remove the product from the list
      list.remove(summary);
      if (list.size() == 0) {
        // if the list is now empty, remove the list
        products.remove(type);
      }
    }
    eventSummary = null;
  }

  /**
   * Convenience method to get products of a given type.
   *
   * This method always returns a copy of the internal list, and may be empty.
   * Only returns products that have not been deleted or superseded.
   *
   * @param type the product type.
   * @return a list of products of that type, which may be empty.
   */
  public List<ProductSummary> getProducts(final String type) {
    ArrayList<ProductSummary> typeProducts = new ArrayList<ProductSummary>();

    if (products.containsKey(type)) {
      // only return products that haven't been deleted
      typeProducts.addAll(getWithoutDeleted(getWithoutSuperseded(products.get(type))));
    }

    return typeProducts;
  }

  /**
   * Get all event products (including those that are deleted or superseded).
   *
   * @return a list of event products.
   */
  public List<ProductSummary> getAllProductList() {
    List<ProductSummary> allProductList = new ArrayList<ProductSummary>();
    Map<String, List<ProductSummary>> allProducts = getAllProducts();
    Iterator<String> iter = allProducts.keySet().iterator();
    while (iter.hasNext()) {
      allProductList.addAll(allProducts.get(iter.next()));
    }
    return allProductList;
  }

  /**
   * Get all event products that have not been deleted or superseded as a list.
   *
   * @return a list of event products.
   */
  public List<ProductSummary> getProductList() {
    List<ProductSummary> productList = new ArrayList<ProductSummary>();
    Map<String, List<ProductSummary>> notDeletedProducts = getProducts();
    Iterator<String> iter = notDeletedProducts.keySet().iterator();
    while (iter.hasNext()) {
      productList.addAll(notDeletedProducts.get(iter.next()));
    }
    return productList;
  }

  /**
   * Get preferred products of all types.
   *
   * This map will contain one product of each type, chosen by preferred weight.
   *
   * @return a map from product type to the preferred product of that type.
   */
  public Map<String, ProductSummary> getPreferredProducts() {
    Map<String, ProductSummary> preferredProducts = new HashMap<String, ProductSummary>();

    Map<String, List<ProductSummary>> notDeletedProducts = getProducts();
    Iterator<String> types = notDeletedProducts.keySet().iterator();
    while (types.hasNext()) {
      String type = types.next();
      preferredProducts.put(type, getPreferredProduct(notDeletedProducts.get(type)));
    }

    return preferredProducts;
  }

  /**
   * Get the preferred product of a specific type.
   *
   * @param type type of product to get.
   * @return most preferred product of that type, or null if no product of that
   *         type is associated.
   */
  public ProductSummary getPreferredProduct(final String type) {
    return getPreferredProduct(getProducts(type));
  }

  /**
   * Get a map of all event ids associated with this event.
   *
   * Same as Event.getEventCodes(this.getAllProductList());
   *
   * @deprecated use {@link #getAllEventCodes(boolean)} instead.
   * @return map of all event ids associated with this event.
   */
  @Deprecated
  public Map<String, String> getEventCodes() {
    return getEventCodes(this.getAllProductList());
  }

  /**
   * Get a map of all event ids associated with this event.
   *
   * Map key is eventSource, Map value is eventSourceCode.
   *
   * @deprecated use {@link #getAllEventCodes(boolean)} instead.
   * @param summaries the summaries list to extract event codes from.
   * @return map of all event ids associated with this event.
   */
  @Deprecated
  public static Map<String, String> getEventCodes(final List<ProductSummary> summaries) {
    Map<String, String> eventIds = new HashMap<String, String>();
    // order most preferred last,
    // to minimize impact of multiple codes from same source
    List<ProductSummary> sorted = getSortedMostPreferredFirst(getWithoutSuperseded(summaries));
    Collections.reverse(sorted);
    // done ordering
    Iterator<ProductSummary> iter = sorted.iterator();
    while (iter.hasNext()) {
      ProductSummary product = iter.next();
      String source = product.getEventSource();
      String code = product.getEventSourceCode();
      if (source != null && code != null) {
        eventIds.put(source.toLowerCase(), code.toLowerCase());
      }
    }
    return eventIds;
  }

  /**
   * Get a map of all event ids associated with this event, recognizing that one
   * source may have multiple codes (they broke the rules, but it happens).
   *
   * @param includeDeleted whether to include ids for sub events whose products
   *                       have all been deleted.
   * @return Map from source to a list of codes from that source.
   */
  public Map<String, List<String>> getAllEventCodes(final boolean includeDeleted) {
    Map<String, List<String>> allEventCodes = new HashMap<String, List<String>>();

    Map<String, Event> subEvents = getSubEvents();
    Iterator<String> iter = subEvents.keySet().iterator();
    while (iter.hasNext()) {
      Event subEvent = subEvents.get(iter.next());
      if (!includeDeleted && subEvent.isDeleted()) {
        // check for non-deleted products that should
        // keep the event code alive
        List<ProductSummary> nonDeletedProducts = getWithoutDeleted(getWithoutSuperseded(subEvent.getAllProductList()));
        if (nonDeletedProducts.size() == 0) {
          // filter deleted events
          continue;
        }
        // otherwise, event has active products;
        // prevent same source associations
      }

      // add code to list for source
      String source = subEvent.getSource();
      String sourceCode = subEvent.getSourceCode();
      List<String> sourceEventCodes = allEventCodes.get(source);
      if (sourceEventCodes == null) {
        // create list for source
        sourceEventCodes = new ArrayList<String>();
        allEventCodes.put(source, sourceEventCodes);
      }
      // keep list distinct
      if (!sourceEventCodes.contains(sourceCode)) {
        sourceEventCodes.add(sourceCode);
      }
    }

    return allEventCodes;
  }

  /**
   * Get a list of all the preferred products sorted based on their authoritative
   * weights
   *
   * @return sorted list of ProductSummary objects
   */
  public List<ProductSummary> getPreferredProductsSorted() {
    Map<String, ProductSummary> preferred = getPreferredProducts();

    // Transform the preferred HashMap into a List so we can sort based on
    // preferred weight
    List<ProductSummary> productList = new ArrayList<ProductSummary>(preferred.values());

    // Sort the list, then iterate through it until we find the specified
    // property
    Collections.sort(productList, new MostPreferredFirstComparator());
    return productList;
  }

  /**
   * Get the event id.
   *
   * The event id is the combination of event source and event source code.
   *
   * @return the event id, or null if either event source or event source code is
   *         null.
   * @see #getSource()
   * @see #getSourceCode()
   */
  public String getEventId() {
    ProductSummary product = getEventIdProduct();
    if (product != null) {
      return product.getEventId();
    }
    return null;
  }

  /**
   * Get the preferred source for this event. If an origin product exists, it's
   * value is used.
   *
   * @return Source from preferred product or null
   */
  public String getSource() {
    ProductSummary product = getEventIdProduct();
    if (product != null) {
      return product.getEventSource();
    }
    return null;
  }

  /**
   * Get the preferred source code for this event. If an origin product exists,
   * it's value is used.
   *
   * @return Source code from preferred product or null
   */
  public String getSourceCode() {
    ProductSummary product = getEventIdProduct();
    if (product != null) {
      return product.getEventSourceCode();
    }
    return null;
  }

  /**
   * Get the product used for eventsource and eventsourcecode.
   *
   * Event ID comes from the preferred origin product.
   *
   * @return The most preferred product summary. This summary is used to determine
   *         the eventsouce and eventsourcecode.
   * @see #getPreferredOriginProduct()
   */
  protected ProductSummary getEventIdProduct() {
    ProductSummary product = getPreferredOriginProduct();
    if (product == null) {
      product = getProductWithOriginProperties();
    }
    return product;
  }

  /**
   * Get the most recent product with origin properties (id, lat, lon, time).
   *
   * <strong>NOTE</strong>: this product may have been superseded by a delete.
   * When an event has not been deleted, this method should be consistent with
   * {@link #getPreferredOriginProduct()}.
   *
   * Products are checked in the following order, sorted most preferred first
   * within each group. The first matching product is returned:
   * <ol>
   * <li>"origin" products not superseded or deleted, that have origin
   * properties</li>
   * <li>"origin" products superseded by a delete, that have origin
   * properties</li>
   * <li>products not superseded or deleted, that have origin properties</li>
   * <li>products superseded by a delete, that have origin properties</li>
   * </ol>
   *
   * @return the most recent product with origin properties.
   * @see #productHasOriginProperties(ProductSummary)
   */
  public ProductSummary getProductWithOriginProperties() {
    Map<String, List<ProductSummary>> allProducts = getAllProducts();
    List<ProductSummary> productsList = null;
    ProductSummary preferredProduct = null;
    Iterator<ProductSummary> iter = null;

    productsList = allProducts.get(ORIGIN_PRODUCT_TYPE);
    if (productsList != null) {
      // "origin" products not superseded or deleted
      productsList = getSortedMostPreferredFirst(
          getWithoutDeleted(getWithoutSuperseded(allProducts.get(ORIGIN_PRODUCT_TYPE))));
      iter = productsList.iterator();
      while (iter.hasNext()) {
        preferredProduct = iter.next();
        if (productHasOriginProperties(preferredProduct)) {
          return preferredProduct;
        }
      }

      // "origin" products superseded by a delete
      productsList = getSortedMostPreferredFirst(
          getWithoutSuperseded(getWithoutDeleted(allProducts.get(ORIGIN_PRODUCT_TYPE))));
      iter = productsList.iterator();
      while (iter.hasNext()) {
        preferredProduct = iter.next();
        if (productHasOriginProperties(preferredProduct)) {
          return preferredProduct;
        }
      }
    }

    // products not superseded or deleted
    productsList = getSortedMostPreferredFirst(
        getWithoutDeleted(getWithoutSuperseded(productTypeMapToList(allProducts))));
    iter = productsList.iterator();
    while (iter.hasNext()) {
      preferredProduct = iter.next();
      if (productHasOriginProperties(preferredProduct)) {
        return preferredProduct;
      }
    }

    // products superseded by a delete
    productsList = getSortedMostPreferredFirst(
        getWithoutSuperseded(getWithoutDeleted(productTypeMapToList(allProducts))));
    iter = productsList.iterator();
    while (iter.hasNext()) {
      preferredProduct = iter.next();
      if (productHasOriginProperties(preferredProduct)) {
        return preferredProduct;
      }
    }

    return null;
  }

  /**
   * Get the most preferred origin-like product for this event.
   *
   * The event is considered deleted if the returned product is null, deleted, or
   * does not have origin properties. Information about the event may still be
   * available using {@link #getProductWithOriginProperties()}.
   *
   * Products are checked in the following order, sorted most preferred first
   * within each group. The first matching product is returned:
   * <ul>
   * <li>If any "origin" products exist:
   * <ol>
   * <li>"origin" products not superseded or deleted, that have origin
   * properties.</li>
   * <li>"origin" products not superseded, that have an event id.</li>
   * </ol>
   * </li>
   * <li>If no "origin" products exist:
   * <ol>
   * <li>products not superseded or deleted, that have origin properties.</li>
   * <li>products not superseded, that have an event id.</li>
   * </ol>
   * </li>
   * </ul>
   *
   * @return the most recent product with origin properties.
   * @see #productHasOriginProperties(ProductSummary)
   */
  public ProductSummary getPreferredOriginProduct() {
    Map<String, List<ProductSummary>> allProducts = getAllProducts();
    List<ProductSummary> productsList = null;
    ProductSummary preferredProduct = null;
    Iterator<ProductSummary> iter = null;

    productsList = allProducts.get(ORIGIN_PRODUCT_TYPE);
    if (productsList != null) {
      // "origin" products not superseded or deleted,
      // that have origin properties
      productsList = getSortedMostPreferredFirst(
          getWithoutDeleted(getWithoutSuperseded(allProducts.get(ORIGIN_PRODUCT_TYPE))));
      iter = productsList.iterator();
      while (iter.hasNext()) {
        preferredProduct = iter.next();
        if (productHasOriginProperties(preferredProduct)) {
          return preferredProduct;
        }
      }

      // "origin" products not superseded,
      // that have event id
      productsList = getSortedMostPreferredFirst(getWithoutSuperseded(allProducts.get(ORIGIN_PRODUCT_TYPE)));
      iter = productsList.iterator();
      while (iter.hasNext()) {
        preferredProduct = iter.next();
        if (preferredProduct.getEventSource() != null && preferredProduct.getEventSourceCode() != null) {
          return preferredProduct;
        }
      }

      return null;
    }

    // products not superseded or deleted,
    // that have origin properties
    productsList = getSortedMostPreferredFirst(
        getWithoutDeleted(getWithoutSuperseded(productTypeMapToList(allProducts))));
    iter = productsList.iterator();
    while (iter.hasNext()) {
      preferredProduct = iter.next();
      if (productHasOriginProperties(preferredProduct)) {
        return preferredProduct;
      }
    }

    // products not superseded,
    // that have event id
    productsList = getSortedMostPreferredFirst(getWithoutSuperseded(productTypeMapToList(allProducts)));
    iter = productsList.iterator();
    while (iter.hasNext()) {
      preferredProduct = iter.next();
      if (preferredProduct.getEventSource() != null && preferredProduct.getEventSourceCode() != null) {
        return preferredProduct;
      }
    }

    return null;
  }

  /**
   * Check if a product can define an event (id, lat, lon, time).
   *
   * @param product product to check.
   * @return true if product has id, lat, lon, and time properties.
   */
  public static boolean productHasOriginProperties(final ProductSummary product) {
    return (product.getEventSource() != null && product.getEventSourceCode() != null
        && product.getEventLatitude() != null && product.getEventLongitude() != null && product.getEventTime() != null);
  }

  /**
   * Get the most preferred magnitude product for event.
   *
   * Currently calls {@link #getPreferredOriginProduct()}.
   *
   * @return the most preferred magnitude product for event.
   */
  public ProductSummary getPreferredMagnitudeProduct() {
    return getPreferredOriginProduct();
  }

  /**
   * Get the preferred time for this event. If an origin product exists, it's
   * value is used.
   *
   * @return Time from preferred product or null
   */
  public Date getTime() {
    ProductSummary preferred = getProductWithOriginProperties();
    if (preferred != null) {
      return preferred.getEventTime();
    }
    return null;
  }

  /**
   * Get the preferred latitude for this event. If an origin product exists, it's
   * value is used.
   *
   * @return Latitude from preferred product or null
   */
  public BigDecimal getLatitude() {
    ProductSummary preferred = getProductWithOriginProperties();
    if (preferred != null) {
      return preferred.getEventLatitude();
    }
    return null;

  }

  /**
   * Get the preferred longitude for this event. If an origin product exists, it's
   * value is used.
   *
   * @return Longitude from preferred product or null
   */
  public BigDecimal getLongitude() {
    ProductSummary preferred = getProductWithOriginProperties();
    if (preferred != null) {
      return preferred.getEventLongitude();
    }
    return null;

  }

  /**
   * Event update time is most recent product update time.
   *
   * @return the most recent product update time.
   */
  public Date getUpdateTime() {
    Date updateTime = null;
    Date time = null;
    Iterator<ProductSummary> iter = getAllProductList().iterator();
    while (iter.hasNext()) {
      time = iter.next().getId().getUpdateTime();
      if (updateTime == null || time.after(updateTime)) {
        time = updateTime;
      }
    }
    return updateTime;
  }

  /**
   * Get the preferred depth for this event. If an origin product exists, it's
   * value is used.
   *
   * @return Depth from preferred product or null
   */
  public BigDecimal getDepth() {
    ProductSummary preferred = getProductWithOriginProperties();
    if (preferred != null) {
      return preferred.getEventDepth();
    }
    return null;
  }

  /**
   * Get the preferred magntitude for this event. If an origin product exists,
   * it's value is used.
   *
   * @return magnitude from preferred product or null
   */
  public BigDecimal getMagnitude() {
    ProductSummary preferred = getPreferredMagnitudeProduct();
    if (preferred != null) {
      return preferred.getEventMagnitude();
    }
    return null;
  }

  /**
   * @return boolean if the preferred event is deleted
   */
  public boolean isDeleted() {
    ProductSummary preferred = getPreferredOriginProduct();
    if (preferred != null && !preferred.isDeleted() && Event.productHasOriginProperties(preferred)) {
      // have "origin" type product, that isn't deleted,
      // and has origin properties
      return false;
    }
    // otherwise, deleted
    return true;
  }

  /**
   * Get the most preferred product from a list of products.
   *
   * @param all a list of products containing only one type of product.
   * @return the product with the highest preferred weight, and if tied the most
   *         recent update time wins.
   */
  public static ProductSummary getPreferredProduct(final List<ProductSummary> all) {
    ProductSummary preferred = null;

    Iterator<ProductSummary> iter = all.iterator();
    while (iter.hasNext()) {
      ProductSummary summary = iter.next();
      if (preferred == null) {
        preferred = summary;
      } else {
        long summaryWeight = summary.getPreferredWeight();
        long preferredWeight = preferred.getPreferredWeight();
        if (summaryWeight > preferredWeight || (summaryWeight == preferredWeight
            && summary.getId().getUpdateTime().after(preferred.getId().getUpdateTime()))) {
          preferred = summary;
        }
      }
    }
    return preferred;
  }

  /**
   * Summarize this event into preferred values.
   *
   * NOTE: the event summary may include information from an origin product, even
   * when the preferred origin for the event has been deleted. Use
   * getPreferredOriginProduct() to check the preferred origin of the event.
   *
   * @return an event summary.
   */
  public EventSummary getEventSummary() {
    if (eventSummary != null) {
      return eventSummary;
    }

    EventSummary summary = new EventSummary();
    summary.setIndexId(this.getIndexId());
    summary.setDeleted(this.isDeleted());

    ProductSummary eventIdProduct = this.getEventIdProduct();
    if (eventIdProduct != null) {
      summary.setSource(eventIdProduct.getEventSource());
      summary.setSourceCode(eventIdProduct.getEventSourceCode());
    }

    ProductSummary originProduct = this.getProductWithOriginProperties();
    if (originProduct != null) {
      summary.setLatitude(originProduct.getEventLatitude());
      summary.setLongitude(originProduct.getEventLongitude());
      summary.setTime(originProduct.getEventTime());
      summary.setDepth(originProduct.getEventDepth());
    }

    ProductSummary magnitudeProduct = this.getPreferredMagnitudeProduct();
    if (magnitudeProduct != null) {
      summary.setMagnitude(magnitudeProduct.getEventMagnitude());
    }

    // we may be able to avoid implementing this here, since the mapping
    // interface will be driven by the PHP product index.
    summary.getEventCodes().putAll(this.getEventCodes());

    // cache summary
    eventSummary = summary;

    return summary;
  }

  /**
   * Comparison class that compares two ProductSummary objects based on their
   * preferred weight and update time.
   *
   */
  static class MostPreferredFirstComparator implements Comparator<ProductSummary> {

    @Override
    public int compare(ProductSummary p1, ProductSummary p2) {
      if (p1.getPreferredWeight() > p2.getPreferredWeight()) {
        return -1;
      } else if (p1.getPreferredWeight() < p2.getPreferredWeight()) {
        return 1;
      } else {
        Date p1Update = p1.getUpdateTime();
        Date p2Update = p2.getUpdateTime();
        if (p1Update.after(p2Update)) {
          return -1;
        } else if (p2Update.after(p1Update)) {
          return 1;
        } else {
          return 0;
        }
      }
    }
  }

  @Override
  public int compareTo(Event that) {
    int r;

    List<ProductSummary> thisProducts = this.getProductList();
    List<ProductSummary> thatProducts = that.getProductList();
    if ((r = (thatProducts.size() - thisProducts.size())) != 0) {
      return r;
    }

    Iterator<ProductSummary> thisIter = thisProducts.iterator();
    Iterator<ProductSummary> thatIter = thatProducts.iterator();
    while (thisIter.hasNext() && thatIter.hasNext()) {
      // just compare product ids for now
      r = thisIter.next().getId().compareTo(thatIter.next().getId());
      if (r != 0) {
        return r;
      }
    }

    return 0;
  }

  /**
   * Find the most preferred product.
   *
   * If preferredType is not null, products of this type are favored over those
   * not of this type.
   *
   * If preferredNotNullProperty is not null, products that have this property set
   * are favored over those without this property set.
   *
   * @param products                 the list of products to search.
   * @param preferredType            the preferred product type, if available.
   * @param preferredNotNullProperty the preferred property name, if available.
   * @return The most preferred product summary of the given type.
   */
  public static ProductSummary getMostPreferred(final List<ProductSummary> products, final String preferredType,
      final String preferredNotNullProperty) {
    ProductSummary mostPreferred = null;

    Iterator<ProductSummary> iter = products.iterator();
    while (iter.hasNext()) {
      ProductSummary next = iter.next();

      // ignore products that don't have the preferredNotNullProperty
      if (preferredNotNullProperty != null && next.getProperties().get(preferredNotNullProperty) == null) {
        continue;
      }

      if (mostPreferred == null) {
        // first product is most preferred so far
        mostPreferred = next;
        continue;
      }

      if (preferredType != null) {
        if (next.getType().equals(preferredType)) {
          if (!mostPreferred.getType().equals(preferredType)) {
            // prefer products of this type
            mostPreferred = next;
          }
        } else if (mostPreferred.getType().equals(preferredType)) {
          // already have preferred product of preferred type
          continue;
        }
      }

      if (next.getPreferredWeight() > mostPreferred.getPreferredWeight()) {
        // higher preferred weight
        mostPreferred = next;
      } else if (next.getPreferredWeight() == mostPreferred.getPreferredWeight()
          && next.getUpdateTime().after(mostPreferred.getUpdateTime())) {
        // same preferred weight, newer update
        mostPreferred = next;
      }
    }

    return mostPreferred;
  }

  /**
   * Remove deleted products from the list.
   *
   * @param products list of products to filter.
   * @return copy of the products list with deleted products removed.
   */
  public static List<ProductSummary> getWithoutDeleted(final List<ProductSummary> products) {
    List<ProductSummary> withoutDeleted = new ArrayList<ProductSummary>();

    Iterator<ProductSummary> iter = products.iterator();
    while (iter.hasNext()) {
      ProductSummary next = iter.next();
      if (!next.isDeleted()) {
        withoutDeleted.add(next);
      }
    }

    return withoutDeleted;
  }

  /**
   * Remove deleted products from the list.
   *
   * @param products list of products to filter.
   * @return copy of the products list with deleted products removed.
   */
  public static List<ProductSummary> getWithEventId(final List<ProductSummary> products) {
    List<ProductSummary> withEventId = new ArrayList<ProductSummary>();

    Iterator<ProductSummary> iter = products.iterator();
    while (iter.hasNext()) {
      ProductSummary next = iter.next();
      if (next.getEventId() != null) {
        withEventId.add(next);
      }
    }

    return withEventId;
  }

  /**
   * Remove old versions of products from the list.
   *
   * @param products list of products to filter.
   * @return a copy of the products list with products of the same
   *         source+type+code but with older updateTimes (superseded) removed.
   */
  public static List<ProductSummary> getWithoutSuperseded(final List<ProductSummary> products) {
    // place product into latest, keyed by source+type+code,
    // keeping only most recent update for each key
    Map<String, ProductSummary> latest = new HashMap<String, ProductSummary>();
    Iterator<ProductSummary> iter = products.iterator();
    while (iter.hasNext()) {
      ProductSummary summary = iter.next();
      ProductId id = summary.getId();

      // key is combination of source, type, and code
      // since none of these may contain ":", it is used as a delimiter to
      // prevent collisions.
      String key = new StringBuffer(id.getSource()).append(":").append(id.getType()).append(":").append(id.getCode())
          .toString();
      if (!latest.containsKey(key)) {
        // first product
        latest.put(key, summary);
      } else {
        // keep latest product
        ProductSummary other = latest.get(key);
        if (other.getId().getUpdateTime().before(id.getUpdateTime())) {
          latest.put(key, summary);
        }
      }
    }

    // those that are in the latest map have not been superseded
    return new ArrayList<ProductSummary>(latest.values());
  }

  /**
   * Sort a list of products, most preferred first.
   *
   * @param products the list of products to sort.
   * @return a copy of the list sorted with most preferred first.
   */
  public static List<ProductSummary> getSortedMostPreferredFirst(final List<ProductSummary> products) {
    List<ProductSummary> mostPreferredFirst = new ArrayList<ProductSummary>(products);
    Collections.sort(mostPreferredFirst, new MostPreferredFirstComparator());
    return mostPreferredFirst;
  }

  static List<ProductSummary> productTypeMapToList(final Map<String, List<ProductSummary>> products) {
    List<ProductSummary> list = new ArrayList<ProductSummary>();

    Iterator<String> iter = products.keySet().iterator();
    while (iter.hasNext()) {
      list.addAll(products.get(iter.next()));
    }

    return list;
  }

  static Map<String, List<ProductSummary>> productListToTypeMap(final List<ProductSummary> products) {
    Map<String, List<ProductSummary>> typeMap = new HashMap<String, List<ProductSummary>>();

    Iterator<ProductSummary> iter = products.iterator();
    while (iter.hasNext()) {
      ProductSummary product = iter.next();
      List<ProductSummary> typeProducts = typeMap.get(product.getType());
      if (typeProducts == null) {
        typeProducts = new ArrayList<ProductSummary>();
        typeMap.put(product.getType(), typeProducts);
      }
      typeProducts.add(product);
    }

    return typeMap;
  }

  /**
   * Return a list of sub-events that make up this event.
   *
   * Event lines are drawn by eventid. Products that have no eventid are included
   * with the sub event whose id is considered preferred.
   *
   * @return map from eventid to event object with products for that eventid.
   */
  public Map<String, Event> getSubEvents() {
    // Map of sub-events keyed by product "eventId"
    Map<String, Event> subEvents = new HashMap<String, Event>();

    // Map of events by source_type_code
    Map<String, Event> productEvents = new HashMap<String, Event>();

    // this is the event that will have products without event id...
    String preferredEventId = this.getEventId();
    Event preferredSubEvent = new Event();
    // put a placeholder with no products into the map for this purpose.
    subEvents.put(preferredEventId, preferredSubEvent);

    // List of all products associated to the current event
    List<ProductSummary> allProducts = this.getAllProductList();

    // handle products with a current version
    HashSet<ProductSummary> withoutSuperseded = new HashSet<ProductSummary>(getWithoutSuperseded(allProducts));
    Iterator<ProductSummary> products = withoutSuperseded.iterator();
    while (products.hasNext()) {
      ProductSummary product = products.next();
      Event subEvent = null;

      String subEventId = product.getEventId();
      if (subEventId == null) {
        // maybe try to find another version of product with id?
        subEvent = preferredSubEvent;
      } else {
        subEvent = subEvents.get(subEventId);
        if (subEvent == null) {
          // first product for this sub event
          subEvent = new Event();
          subEvents.put(subEventId, subEvent);
        }
      }
      subEvent.addProduct(product);

      ProductId id = product.getId();
      String key = id.getSource() + "_" + id.getType() + "_" + id.getCode();
      productEvents.put(key, subEvent);
    }

    // handle superseded products
    HashSet<ProductSummary> superseded = new HashSet<ProductSummary>(allProducts);
    superseded.removeAll(withoutSuperseded);
    products = superseded.iterator();
    while (products.hasNext()) {
      ProductSummary next = products.next();
      ProductId id = next.getId();
      String key = id.getSource() + "_" + id.getType() + "_" + id.getCode();
      Event subEvent = productEvents.get(key);
      subEvent.addProduct(next);
    }

    return subEvents;
  }

  /**
   * Check if this event has an associate product for another given Event.
   *
   * @param otherEvent the other event.
   * @return true if there is an associate product, false otherwise.
   */
  public boolean hasAssociateProduct(final Event otherEvent) {
    if (otherEvent == null) {
      // cannot have an association to a null event...
      return false;
    }

    String otherEventSource = otherEvent.getSource();
    String otherEventSourceCode = otherEvent.getSourceCode();
    if (otherEventSource == null || otherEventSourceCode == null) {
      // same without source+code
      return false;
    }

    // search associate products
    Iterator<ProductSummary> iter = getProducts(ASSOCIATE_PRODUCT_TYPE).iterator();
    while (iter.hasNext()) {
      ProductSummary associate = iter.next();

      if (otherEventSource.equalsIgnoreCase(associate.getProperties().get(OTHEREVENTSOURCE_PROPERTY))
          && otherEventSourceCode.equalsIgnoreCase(associate.getProperties().get(OTHEREVENTSOURCECODE_PROPERTY))) {
        // associated
        return true;
      }
    }

    return false;
  }

  /**
   * Check if this event has an disassociate product for another given Event.
   *
   * @param otherEvent the other event.
   * @return true if there is an disassociate product, false otherwise.
   */
  public boolean hasDisassociateProduct(final Event otherEvent) {
    if (otherEvent == null) {
      // cannot have an disassociation to a null event...
      return false;
    }

    String otherEventSource = otherEvent.getSource();
    String otherEventSourceCode = otherEvent.getSourceCode();
    if (otherEventSource == null || otherEventSourceCode == null) {
      // same without source+code
      return false;
    }

    // search disassociate products
    Iterator<ProductSummary> iter = getProducts(DISASSOCIATE_PRODUCT_TYPE).iterator();
    while (iter.hasNext()) {
      ProductSummary associate = iter.next();

      if (otherEventSource.equalsIgnoreCase(associate.getProperties().get(OTHEREVENTSOURCE_PROPERTY))
          && otherEventSourceCode.equalsIgnoreCase(associate.getProperties().get(OTHEREVENTSOURCECODE_PROPERTY))) {
        // disassociated
        return true;
      }
    }

    return false;
  }

  /**
   * Same as isAssociated(that, new DefaultAssociator());
   *
   * @param that an event to test
   * @return boolean true if associated, false otherwise
   */
  public boolean isAssociated(final Event that) {
    return this.isAssociated(that, new DefaultAssociator());
  }

  /**
   * Check if an event is associated to this event.
   *
   * Reasons events may be considered disassociated:
   * <ol>
   * <li>Share a common EVENTSOURCE with different EVENTSOURCECODE.</li>
   * <li>Either has a disassociate product for the other.</li>
   * <li>Preferred location in space and time is NOT nearby, and no other reason
   * to associate.</li>
   * </ol>
   *
   * Reasons events may be considered associated:
   * <ol>
   * <li>Share a common EVENTID</li>
   * <li>Either has an associate product for the other.</li>
   * <li>Their preferred location in space and time is nearby.</li>
   * </ol>
   *
   * @param that       candidate event to test.
   * @param associator An associator to compare two events
   * @return true if associated, false otherwise.
   */
  public boolean isAssociated(final Event that, final Associator associator) {
    return associator.eventsAssociated(this, that);
  }

  /**
   * Depending on logger level, takes in summary data and appends to buffer
   *
   * @param logger logger object
   */
  public void log(final Logger logger) {
    if (logger.isLoggable(Level.FINE)) {
      EventSummary summary = this.getEventSummary();
      logger.fine(new StringBuffer("Event").append("indexid=").append(summary.getIndexId()).append(", eventid=")
          .append(summary.getId()).append(", latitude=").append(summary.getLatitude()).append(", longitude=")
          .append(summary.getLongitude()).append(", time=").append(summary.getTime()).append(", deleted=")
          .append(summary.isDeleted()).toString());

      if (logger.isLoggable(Level.FINER)) {
        StringBuffer buf = new StringBuffer("Products in event");
        List<ProductSummary> products = this.getAllProductList();
        Iterator<ProductSummary> iter = products.iterator();
        while (iter.hasNext()) {
          ProductSummary next = iter.next();
          buf.append("\n\tstatus=").append(next.getStatus()).append(", id=").append(next.getId().toString())
              .append(", eventid=").append(next.getEventId()).append(", latitude=").append(next.getEventLatitude())
              .append(", longitude=").append(next.getEventLongitude()).append(", time=").append(next.getEventTime());
        }
        logger.finer(buf.toString());
      }
    }
  }

}