/*
 * Copyright © 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.enterprise.cloudsearch.sdk.indexing;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.api.client.util.DateTime;
import com.google.api.services.cloudsearch.v1.model.Item;
import com.google.api.services.cloudsearch.v1.model.ItemMetadata;
import com.google.api.services.cloudsearch.v1.model.ItemStructuredData;
import com.google.api.services.cloudsearch.v1.model.SearchQualityMetadata;
import com.google.common.base.Converter;
import com.google.common.base.Strings;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import com.google.enterprise.cloudsearch.sdk.InvalidConfigurationException;
import com.google.enterprise.cloudsearch.sdk.config.Configuration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

/**
 * Helper object to build an {@link Item}.
 *
 * <p>Use the setters to provide all desired attributes of an {@link Item} including the name, ACL,
 * metadata fields, queue, version, and so on. To set the attribute’s value explicitly at build time
 * (value) or derive it from the repository’s map of key/values (field), define the metadata fields
 * as {@link FieldOrValue} objects.
 *
 * <p>Sample usage:
 * <pre>{@code
 *   // within Repository method fetching a document
 *   Multimap<String, Object> multiMapValues = ... // populate the map with repository data
 *   String documentName = ... // create the specific document name (unique id)
 *   String documentTitle = ... // set title to a "field" in multiMapValues
 *   ...
 *   IndexingItemBuilder indexingItemBuilder =
 *       IndexingItemBuilder.fromConfiguration(documentName).setValues(multiMapValues);
 *   ...
 *   // the title is now set to the value of the title field during the build()
 *   indexingItemBuilder.setTitle(FieldOrValue.withField(documentTitle));
 *   ...
 *   // the URL is now set to the hard-coded URL string
 *   indexingItemBuilder.setSourceRepositoryUrl(FieldOrValue.withValue("https://www.mycompany.com");
 *   ...
 *   // generate the fully built document
 *   Item documentItem = indexingItemBuilder.build();
 *   ...
 *   }
 * </pre>
 */
public class IndexingItemBuilder {
  private static final Logger logger = Logger.getLogger(IndexingItemBuilder.class.getName());

  public enum ItemType {
    CONTENT_ITEM,
    CONTAINER_ITEM,
    VIRTUAL_CONTAINER_ITEM
  }

  private static final String MIME_TYPE = "itemMetadata.mimeType";
  private static final String TITLE = "itemMetadata.title";
  private static final String SOURCE_REPOSITORY_URL = "itemMetadata.sourceRepositoryUrl";
  private static final String UPDATE_TIME = "itemMetadata.updateTime";
  private static final String CREATE_TIME = "itemMetadata.createTime";
  private static final String CONTENT_LANGUAGE = "itemMetadata.contentLanguage";
  private static final String HASH = "itemMetadata.hash";
  private static final String CONTAINER_NAME = "itemMetadata.containerName";
  private static final String SEARCH_QUALITY_METADATA_QUALITY =
      "itemMetadata.searchQualityMetadata.quality";
  public static final String OBJECT_TYPE = "itemMetadata.objectType";

  public static final String MIME_TYPE_FIELD = MIME_TYPE + ".field";
  public static final String TITLE_FIELD = TITLE + ".field";
  public static final String SOURCE_REPOSITORY_URL_FIELD = SOURCE_REPOSITORY_URL + ".field";
  public static final String UPDATE_TIME_FIELD = UPDATE_TIME + ".field";
  public static final String CREATE_TIME_FIELD = CREATE_TIME + ".field";
  public static final String CONTENT_LANGUAGE_FIELD = CONTENT_LANGUAGE + ".field";
  public static final String HASH_FIELD = HASH + ".field";
  public static final String CONTAINER_NAME_FIELD = CONTAINER_NAME + ".field";
  public static final String SEARCH_QUALITY_METADATA_QUALITY_FIELD =
      SEARCH_QUALITY_METADATA_QUALITY + ".field";
  public static final String OBJECT_TYPE_FIELD = OBJECT_TYPE + ".field";

  public static final String MIME_TYPE_VALUE = MIME_TYPE + ".defaultValue";
  public static final String TITLE_VALUE = TITLE + ".defaultValue";
  public static final String SOURCE_REPOSITORY_URL_VALUE = SOURCE_REPOSITORY_URL + ".defaultValue";
  public static final String UPDATE_TIME_VALUE = UPDATE_TIME + ".defaultValue";
  public static final String CREATE_TIME_VALUE = CREATE_TIME + ".defaultValue";
  public static final String CONTENT_LANGUAGE_VALUE = CONTENT_LANGUAGE + ".defaultValue";
  public static final String HASH_VALUE = HASH + ".defaultValue";
  public static final String CONTAINER_NAME_VALUE = CONTAINER_NAME + ".defaultValue";
  public static final String SEARCH_QUALITY_METADATA_QUALITY_VALUE =
      SEARCH_QUALITY_METADATA_QUALITY + ".defaultValue";
  public static final String OBJECT_TYPE_VALUE = OBJECT_TYPE + ".defaultValue";

  // These methods are not used above so that the constants will be detected as such by javadoc.
  private static String dotField(String configKey) {
    return configKey + ".field";
  }

  private static String dotValue(String configKey) {
    return configKey + ".defaultValue";
  }

  private final String name;
  private Acl acl;
  private Multimap<String, Object> values;

  private FieldOrValue<String> mimeType;
  private FieldOrValue<String> title;
  private FieldOrValue<String> url;
  private FieldOrValue<DateTime> updateTime;
  private FieldOrValue<DateTime> createTime;
  private FieldOrValue<String> language;
  private FieldOrValue<String> hash;
  private FieldOrValue<String> containerName;
  private FieldOrValue<Double> searchQualityMetadataQuality;
  private FieldOrValue<String> objectType;

  private final Optional<FieldOrValue<String>> configMimeType;
  private final Optional<FieldOrValue<String>> configTitle;
  private final Optional<FieldOrValue<String>> configUrl;
  private final Optional<FieldOrValue<DateTime>> configUpdateTime;
  private final Optional<FieldOrValue<DateTime>> configCreateTime;
  private final Optional<FieldOrValue<String>> configLanguage;
  private final Optional<FieldOrValue<String>> configHash;
  private final Optional<FieldOrValue<String>> configContainerName;
  private final Optional<FieldOrValue<Double>> configSearchQualityMetadataQuality;
  private final Optional<FieldOrValue<String>> configObjectType;

  private SearchQualityMetadata searchQuality;
  private String queue;
  private byte[] payload;
  private byte[] version;
  private ItemType itemType;

  /**
   * Constructs an {@code IndexingItemBuilder} from the {@link Configuration}.
   *
   * <p>Optional configuration parameters for {@code ItemMetadata}:
   *
   * <ul>
   *   <li>{@code itemMetadata.mimeType.field} - The key for the mimeType field in the values map.
   *   <li>{@code itemMetadata.title.field} - The key for the title field in the values map.
   *   <li>{@code itemMetadata.sourceRepositoryUrl.field} - The key for the URL field in the
   *       values map.
   *   <li>{@code itemMetadata.updateTime.field} - The key for the update time field in the
   *       values map.
   *   <li>{@code itemMetadata.createTime.field} - The key for the create time field in the
   *       values map.
   *   <li>{@code itemMetadata.contentLanguage.field} - The key for the content language field
   *       in the values map.
   *   <li>{@code itemMetadata.hash.field} - The key for the hash field in the values map.
   *   <li>{@code itemMetadata.containerName.field} - The key for the container name field
   *       in the values map.
   *   <li>{@code itemMetadata.searchQualityMetadata.quality.field} - The key for the
   *       SearchQualityMetadata quality field in the values map.
   *   <li>{@code itemMetadata.mimeType.defaultValue} - The value for the media type.
   *   <li>{@code itemMetadata.title.defaultValue} - The value for the title.
   *   <li>{@code itemMetadata.sourceRepositoryUrl.defaultValue} - The value for the URL.
   *   <li>{@code itemMetadata.updateTime.defaultValue} - The value for the update time in
   *       RFC 3339 format.
   *   <li>{@code itemMetadata.createTime.defaultValue} - The value for the create time in
   *       RFC 3339 format.
   *   <li>{@code itemMetadata.contentLanguage.defaultValue} - The value for the content language.
   *   <li>{@code itemMetadata.hash.defaultValue} - The value for the hash.
   *   <li>{@code itemMetadata.containerName.defaultValue} - The value for the container name.
   *   <li>{@code itemMetadata.searchQualityMetadata.quality.defaultValue} - The value for
   *       the SearchQualityMetadata quality.
   * </ul>
   *
   * <p>Optional configuration parameters for {@code ItemMetadata} and {@code StructuredData}:
   *
   * <ul>
   *   <li>{@code itemMetadata.objectType.field} - The key for the object type field in the
   *       values map.
   *   <li>{@code itemMetadata.objectType.defaultValue} - The value for the object type.
   * </ul>
   *
   * Note: For each {@code ItemMetadata} field, check the following in order for a non-empty value:
   * <ol>
   *   <li> A call to the correponding setter method on the returned
   *       instance of {@code IndexingItemBuilder}.
   *   <li> A config property with a suffix of {@code .field}, used as
   *       a key into the the {@link #setValues values map}.
   *   <li> A config property with a suffix of {@code .defaultValue}.
   * </ol>
   */
  public static IndexingItemBuilder fromConfiguration(String name) {
    String objectType = Configuration.getString(OBJECT_TYPE, "").get();
    if (!objectType.isEmpty()) {
      logger.log(Level.WARNING, "{0} is deprecated, use {1}={2}.",
          new Object[] { OBJECT_TYPE, OBJECT_TYPE_VALUE, objectType });
    }

    ConfigDefaults config = new ConfigDefaults()
        .setMimeType(fieldOrValue(MIME_TYPE, Configuration.STRING_PARSER))
        .setTitle(fieldOrValue(TITLE, Configuration.STRING_PARSER))
        .setUrl(fieldOrValue(SOURCE_REPOSITORY_URL, Configuration.STRING_PARSER))
        .setUpdateTime(fieldOrValue(UPDATE_TIME, DATE_PARSER))
        .setCreateTime(fieldOrValue(CREATE_TIME, DATE_PARSER))
        .setLanguage(fieldOrValue(CONTENT_LANGUAGE, Configuration.STRING_PARSER))
        .setHash(fieldOrValue(HASH, Configuration.STRING_PARSER))
        .setContainerName(fieldOrValue(CONTAINER_NAME, Configuration.STRING_PARSER))
        .setSearchQualityMetadataQuality(fieldOrValue(SEARCH_QUALITY_METADATA_QUALITY,
                Configuration.DOUBLE_PARSER))
        .setObjectType(
            fieldOrValue(
                OBJECT_TYPE,
                Configuration.getString(OBJECT_TYPE_FIELD, "").get(),
                Configuration.getOverriden(
                    OBJECT_TYPE_VALUE, Configuration.getString(OBJECT_TYPE, ""))
                    .get(),
                Configuration.STRING_PARSER));
    return new IndexingItemBuilder(name, config);
  }

  /**
   * Constructs an empty {@code IndexingItemBuilder}.
   */
  public IndexingItemBuilder(String name) {
    this(name, new ConfigDefaults());
  }

  private IndexingItemBuilder(String name, ConfigDefaults config) {
    this.name = name;
    this.values = ArrayListMultimap.create();

    this.configMimeType = config.mimeType;
    this.configTitle = config.title;
    this.configUrl = config.url;
    this.configUpdateTime = config.updateTime;
    this.configCreateTime = config.createTime;
    this.configLanguage = config.language;
    this.configHash = config.hash;
    this.configContainerName = config.containerName;
    this.configSearchQualityMetadataQuality = config.searchQualityMetadataQuality;
    this.configObjectType = config.objectType;
  }

  /**
   * Sets the {@link Acl} instance, which is used to construct the {@code ItemAcl}.
   *
   * @param acl the {@code Acl} instance
   * @return this instance
   */
  public IndexingItemBuilder setAcl(Acl acl) {
    this.acl = acl;
    return this;
  }

  /**
   * Sets the name of the object definition from the schema to use when
   * constructing the {@code ItemStructuredData}, either from
   * the given field (or key) in the {@code values} multimap, or a literal value.
   *
   * @param objectType the source of the object definition name
   * @return this instance
   */
  public IndexingItemBuilder setObjectType(FieldOrValue<String> objectType) {
    this.objectType = objectType;
    return this;
  }

  /**
   * Sets the name of the object definition from the schema to use when
   * constructing the {@code ItemStructuredData}
   *
   * @param objectType the object definition name
   * @return this instance
   * @deprecated Use {@link #setObjectType(FieldOrValue)} with {@link FieldOrValue#withValue}
   */
  @Deprecated
  public IndexingItemBuilder setObjectType(String objectType) {
    return setObjectType(FieldOrValue.withValue(objectType));
  }

  /**
   * Sets the {@code mimeType} field value for the {@code ItemMetadata}, either from
   * the given field (or key) in the {@code values} multimap, or a literal value.
   *
   * @param mimeType the source of the media type
   * @return this instance
   */
  public IndexingItemBuilder setMimeType(FieldOrValue<String> mimeType) {
    this.mimeType = mimeType;
    return this;
  }

  /**
   * Sets the {@code mimeType} field value for the {@code ItemMetadata}.
   *
   * @param mimeType a media type, such as "application/pdf"
   * @return this instance
   * @deprecated Use {@link #setMimeType(FieldOrValue)} with {@link FieldOrValue#withValue}
   */
  @Deprecated
  public IndexingItemBuilder setMimeType(String mimeType) {
    return setMimeType(FieldOrValue.withValue(mimeType));
  }

  /**
   * Sets the repository attributes that may be used for the {@code ItemMetadata}
   * or {@code StructuredDataObject} fields, depending on the {@code FieldOrValue}
   * setters called by the connector as well as the configuration. The map may
   * have repeated values for a key.
   *
   * @param values the repository attribute values
   * @return this instance
   */
  public IndexingItemBuilder setValues(Multimap<String, Object> values) {
    this.values = values;
    return this;
  }

  /**
   * Sets the {@code title} field value for the {@code ItemMetadata}, either from
   * the given field (or key) in the {@code values} multimap, or a literal value.
   *
   * @param title the source of the {@code title} field value
   * @return this instance
   */
  public IndexingItemBuilder setTitle(FieldOrValue<String> title) {
    this.title = title;
    return this;
  }

  /**
   * Sets the {@code sourceRepositoryUrl} field value for the {@code ItemMetadata},
   * either from the given field (or key) in the {@code values} multimap, or a
   * literal value.
   *
   * @param url the source of the {@code url} field value
   * @return this instance
   */
  public IndexingItemBuilder setSourceRepositoryUrl(FieldOrValue<String> url) {
    this.url = url;
    return this;
  }

  /**
   * Sets the {@code updateTime} field value for the {@code ItemMetadata},
   * either from the given field (or key) in the {@code values} multimap, or a
   * literal value.
   *
   * @param updateTime the source of the {@code updateTime} field value
   * @return this instance
   */
  public IndexingItemBuilder setUpdateTime(FieldOrValue<DateTime> updateTime) {
    this.updateTime = updateTime;
    return this;
  }

  /**
   * Sets the {@code createTime} field value for the {@code ItemMetadata},
   * either from the given field (or key) in the {@code values} multimap, or a
   * literal value.
   *
   * @param createTime the source of the {@code createTime} field value
   * @return this instance
   */
  public IndexingItemBuilder setCreateTime(FieldOrValue<DateTime> createTime) {
    this.createTime = createTime;
    return this;
  }

  /**
   * Sets the {@code contentLanguage} field value for the {@code ItemMetadata},
   * either from the given field (or key) in the {@code values} multimap, or a
   * literal value.
   *
   * @param language the source of the {@code contentLanguage} field value
   * @return this instance
   */
  public IndexingItemBuilder setContentLanguage(FieldOrValue<String> language) {
    this.language = language;
    return this;
  }

  /**
   * Sets the {@code searchQualityMetadata} field value for the {@code ItemMetadata}.
   * <p>
   * Using this setter will override any value previously set using {@link
   * #setSearchQualityMetadataQuality(FieldOrValue)}.
   *
   * @param searchQuality the {@code SearchQualityMetadata} instance
   * @return this instance
   */
  public IndexingItemBuilder setSearchQualityMetadata(SearchQualityMetadata searchQuality) {
    this.searchQuality = searchQuality;
    this.searchQualityMetadataQuality = null;
    return this;
  }

  /**
   * Sets the {@code searchQualityMetadata.quality} field value for the {@code ItemMetadata}.
   * <p>
   * Using this setter will override any value previously set using {@link
   * #setSearchQualityMetadata(SearchQualityMetadata)}.
   *
   * @param quality the source of the {@code searchQualityMetadata.quality} value
   * @return this instance
   */
  public IndexingItemBuilder setSearchQualityMetadataQuality(FieldOrValue<Double> quality) {
    this.searchQualityMetadataQuality = quality;
    this.searchQuality = null;
    return this;
  }

  /**
   * Sets the {@code hash} field value for the {@code ItemMetadata}, either from
   * the given field (or key) in the {@code values} multimap, or a literal value.
   *
   * @param hash the source of the {@code hash} field value
   * @return this instance
   */
  public IndexingItemBuilder setHash(FieldOrValue<String> hash) {
    this.hash = hash;
    return this;
  }

  /**
   * Sets the {@code hash} field value for the {@code ItemMetadata}.
   *
   * @param hash the {@code hash} field value
   * @return this instance
   * @deprecated Use {@link #setHash(FieldOrValue)} with {@link FieldOrValue#withValue}
   */
  @Deprecated
  public IndexingItemBuilder setHash(String hash) {
    return setHash(FieldOrValue.withValue(hash));
  }

  /**
   * Sets the {@code containerName} field value for the {@code ItemMetadata}, either from
   * the given field (or key) in the {@code values} multimap, or a literal value.
   *
   * @param containerName the source of the {@code containerName} field value
   * @return this instance
   */
  public IndexingItemBuilder setContainerName(FieldOrValue<String> containerName) {
    this.containerName = containerName;
    return this;
  }

  /**
   * Sets the {@code containerName} field value for the {@code ItemMetadata}.
   *
   * @param containerName the {@code containerName} field value
   * @return this instance
   * @deprecated Use {@link #setContainerName(FieldOrValue)} with {@link FieldOrValue#withValue}
   */
  @Deprecated
  public IndexingItemBuilder setContainerName(String containerName) {
    return setContainerName(FieldOrValue.withValue(containerName));
  }

  /**
   * Sets the {@code queue} field value for the {@code Item}.
   *
   * @param queue the {@code queue} field value
   * @return this instance
   */
  public IndexingItemBuilder setQueue(String queue) {
    this.queue = queue;
    return this;
  }

  /**
   * Sets the {@code payload} field value for the {@code Item}.
   *
   * @param payload the {@code payload} field value
   * @return this instance
   */
  public IndexingItemBuilder setPayload(byte[] payload) {
    this.payload = payload;
    return this;
  }

  /**
   * Sets the {@code version} field value for the {@code Item}.
   *
   * @param version the {@code version} field value
   * @return this instance
   */
  public IndexingItemBuilder setVersion(byte[] version) {
    this.version = version;
    return this;
  }

  /**
   * Sets the {@code itemType} field value for the {@code Item}.
   *
   * @param itemType the {@code itemType} field value
   * @return this instance
   */
  public IndexingItemBuilder setItemType(ItemType itemType) {
    this.itemType = itemType;
    return this;
  }

  /**
   * Builds the {@link Item} using all of the previously set attributes.
   *
   * <p>Aside from the {@code name} and {@code values} map, all of the attributes are optional.
   * The metadata attributes
   * ({@code mimeType, title, sourceRepositoryUrl, updateTime, createTime, contentLanguage,
   * hash, containerName})
   * can be set explicitly in the setter, from the {@code values} map, or using the
   * {@link #fromConfiguration configuration properties}.
   *
   * @return fully built {@link Item} object
   */
  public Item build() {
    checkArgument(!Strings.isNullOrEmpty(name), "item name can not be null");
    checkNotNull(values, "values can not be null");
    Item item = new Item();
    item.setName(name);
    if (acl != null) {
      acl.applyTo(item);
    }
    ItemMetadata metadata = new ItemMetadata();
    setFromFieldOrValues(objectType, configObjectType, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(),
        itemObjectType -> {
          metadata.setObjectType(itemObjectType);
          item.setStructuredData(
              new ItemStructuredData().setObject(
                  StructuredData.getStructuredData(itemObjectType, values)));
        });
    setFromFieldOrValues(mimeType, configMimeType, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(), metadata::setMimeType);
    setFromFieldOrValues(title, configTitle, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(), metadata::setTitle);
    setFromFieldOrValues(url, configUrl, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(), metadata::setSourceRepositoryUrl);
    setFromFieldOrValues(language, configLanguage, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(), metadata::setContentLanguage);
    setFromFieldOrValues(updateTime, configUpdateTime, values, StructuredData.DATETIME_CONVERTER,
        Objects::isNull, DateTime::toStringRfc3339, metadata::setUpdateTime);
    setFromFieldOrValues(createTime, configCreateTime, values, StructuredData.DATETIME_CONVERTER,
        Objects::isNull, DateTime::toStringRfc3339, metadata::setCreateTime);
    setFromFieldOrValues(hash, configHash, values, StructuredData.STRING_CONVERTER,
        Strings::isNullOrEmpty, Function.identity(), metadata::setHash);
    setFromFieldOrValues(containerName, configContainerName, values,
        StructuredData.STRING_CONVERTER, Strings::isNullOrEmpty, Function.identity(),
        metadata::setContainerName);
    if (searchQuality != null) {
      metadata.setSearchQualityMetadata(searchQuality);
    } else {
      SearchQualityMetadata searchQualityMetadata = new SearchQualityMetadata();
      setFromFieldOrValues(searchQualityMetadataQuality, configSearchQualityMetadataQuality,
          values, StructuredData.DOUBLE_CONVERTER, Objects::isNull, Function.identity(),
          searchQualityMetadata::setQuality);
      if (searchQualityMetadata.getQuality() != null) {
        metadata.setSearchQualityMetadata(searchQualityMetadata);
      }
    }
    if (version != null) {
      item.encodeVersion(version);
    }
    if (payload != null) {
      item.encodePayload(payload);
    }
    if (!Strings.isNullOrEmpty(queue)) {
      item.setQueue(queue);
    }
    if (itemType != null) {
      item.setItemType(itemType.name());
    }
    item.setMetadata(metadata);
    return item;
  }

  /**
   * Gets the first value from the primary (nullable) or backup (optional) FieldOrValue
   * instances, extracts non-empty values, and passes them to the given ItemMetadata setter.
   *
   * @param <T> the field type in this class
   * @param <M> the field type in the API model, usually String
   * @param primary the FieldOrValue from the setter
   * @param backup the FieldOrValue from the configuration
   * @param values the multimap used to resolve field name references
   * @param converter the converter for multimap values
   * @param isEmpty a predicate to check whether a value is present or missing
   */
  private static <T, M> void setFromFieldOrValues(FieldOrValue<T> primary,
      Optional<FieldOrValue<T>> backup,
      Multimap<String, Object> values,
      Converter<Object, T> converter,
      Predicate<T> isEmpty,
      Function<T, M> extractor,
      Consumer<M> setter) {
    T value = getSingleValue(primary, values, converter);
    if (isEmpty.test(value) && backup.isPresent()) {
      value = getSingleValue(backup.get(), values, converter);
    }
    if (!isEmpty.test(value)) {
      setter.accept(extractor.apply(value));
    }
  }

  private static <T> T getSingleValue(
      FieldOrValue<T> field,
      Multimap<String, Object> values,
      Converter<Object, T> converter) {
    if (field == null) {
      return null;
    }
    if (field.fieldName == null) {
      return field.defaultValue;
    }
    List<Object> fieldValues =
        values.get(field.fieldName).stream().filter(Objects::nonNull).collect(Collectors.toList());
    if (fieldValues.isEmpty()) {
      return field.defaultValue;
    }
    return converter.convert(fieldValues.get(0));
  }

  /**
   * Construct to specify an actual field value or pointer to a key within the key/values map.
   */
  public static class FieldOrValue<T> {
    private final String fieldName;
    private final T defaultValue;

    FieldOrValue(String fieldName, T defaultValue) {
      this.fieldName = fieldName;
      this.defaultValue = defaultValue;
    }

    /** Gets a string suitable for unit tests. */
    @Override
    public String toString() {
      return "{field:" + fieldName + "; value:" + defaultValue + '}';
    }

    @Override
    public int hashCode() {
      return Objects.hash(fieldName, defaultValue);
    }

    @Override
    public boolean equals(Object other) {
      if (other == null) {
        return false;
      }
      if (!(other instanceof FieldOrValue)) {
        return false;
      }
      FieldOrValue<?> that = (FieldOrValue) other;
      return Objects.equals(this.fieldName, that.fieldName)
          && Objects.equals(this.defaultValue, that.defaultValue);
    }

    /**
     * Looks up value for the property from {@link IndexingItemBuilder#setValues}
     * for the given field.
     *
     * @param field the key to lookup from {@link IndexingItemBuilder#setValues}
     * @return {@link FieldOrValue} instance pointing to field
     */
    public static <T> FieldOrValue<T> withField(String field) {
      checkArgument(!Strings.isNullOrEmpty(field), "field name can not be null or empty");
      return new FieldOrValue<T>(field, null);
    }

    /**
     * Uses the provided value for the property.
     *
     * @param value the value for the property
     * @return {@link FieldOrValue} instance pointing to value
     */
    public static <T> FieldOrValue<T> withValue(T value) {
      return new FieldOrValue<T>(null, value);
    }
  }

  /**
   * Constructs a {@link FieldOrValue} instance from the given {@code configKey},
   * trying first a suffix of {@code .field} and if that is not does not exist,
   * or has an empty value, a suffix of {@code .defaultValue}.
   *
   * @param configKey a configuration key prefix, for example, {@code "itemMetadata.title"}
   * @param parser a configuration {@link Parser} of the appropriate type, for example,
   *     {@code String} or {@code DateTime}
   */
  private static <T> Optional<FieldOrValue<T>> fieldOrValue(String configKey,
      Configuration.Parser<T> parser) {
    String field = Configuration.getString(dotField(configKey), "").get();
    String value = Configuration.getString(dotValue(configKey), "").get();
    return fieldOrValue(configKey, field, value, parser);
  }

  /**
   * Constructs a {@link FieldOrValue} instance for the given {@code configKey},
   * trying first the given field and if that is not does not exist,
   * or has an empty value, the given default value.
   *
   * @param configKey a configuration key prefix, for example, {@code "itemMetadata.title"}
   * @param field the key to lookup from {@link IndexingItemBuilder#setValues}
   * @param value the default value
   * @param parser a configuration {@link Parser} of the appropriate type, for example,
   *     {@code String} or {@code DateTime}
   */
  private static <T> Optional<FieldOrValue<T>> fieldOrValue(String configKey, String field,
      String value, Configuration.Parser<T> parser) {
    if (field.isEmpty()) {
      if (value.isEmpty()) {
        return Optional.empty();
      } else {
        logger.log(Level.CONFIG, "{0} = {1}", new Object[] { dotValue(configKey), value });
        return Optional.of(FieldOrValue.withValue(parser.parse(value)));
      }
    } else {
      logger.log(Level.CONFIG, "{0} = {1}", new Object[] { dotField(configKey), field });
      if (value.isEmpty()) {
        return Optional.of(FieldOrValue.withField(field));
      } else {
        logger.log(Level.CONFIG, "{0} = {1}", new Object[] { dotValue(configKey), value });
        return Optional.of(new FieldOrValue<>(field, parser.parse(value)));
      }
    }
  }

  // TODO(jlacey): This could be moved to Configuration to support date properties.
  private static final Configuration.Parser<DateTime> DATE_PARSER =
      value -> {
        checkArgument(!Strings.isNullOrEmpty(value), "value to parse cannot be null or empty.");
        try {
          return new DateTime(value);
        } catch (NumberFormatException e) {
          throw new InvalidConfigurationException(e);
        }
      };

  /** Mutable holder of configurable defaults. */
  static class ConfigDefaults {
    private Optional<FieldOrValue<String>> mimeType = Optional.empty();
    private Optional<FieldOrValue<String>> title = Optional.empty();
    private Optional<FieldOrValue<String>> url = Optional.empty();
    private Optional<FieldOrValue<DateTime>> updateTime = Optional.empty();
    private Optional<FieldOrValue<DateTime>> createTime = Optional.empty();
    private Optional<FieldOrValue<String>> language = Optional.empty();
    private Optional<FieldOrValue<String>> hash = Optional.empty();
    private Optional<FieldOrValue<String>> containerName = Optional.empty();
    private Optional<FieldOrValue<Double>> searchQualityMetadataQuality = Optional.empty();
    private Optional<FieldOrValue<String>> objectType = Optional.empty();

    public ConfigDefaults setMimeType(Optional<FieldOrValue<String>> mimeType) {
      this.mimeType = mimeType;
      return this;
    }

    public ConfigDefaults setTitle(Optional<FieldOrValue<String>> title) {
      this.title = title;
      return this;
    }

    public ConfigDefaults setUrl(Optional<FieldOrValue<String>> url) {
      this.url = url;
      return this;
    }

    public ConfigDefaults setUpdateTime(Optional<FieldOrValue<DateTime>> updateTime) {
      this.updateTime = updateTime;
      return this;
    }

    public ConfigDefaults setCreateTime(Optional<FieldOrValue<DateTime>> createTime) {
      this.createTime = createTime;
      return this;
    }

    public ConfigDefaults setLanguage(Optional<FieldOrValue<String>> language) {
      this.language = language;
      return this;
    }

    public ConfigDefaults setHash(Optional<FieldOrValue<String>> hash) {
      this.hash = hash;
      return this;
    }

    public ConfigDefaults setContainerName(Optional<FieldOrValue<String>> containerName) {
      this.containerName = containerName;
      return this;
    }

    public ConfigDefaults setSearchQualityMetadataQuality(Optional<FieldOrValue<Double>> quality) {
      this.searchQualityMetadataQuality = quality;
      return this;
    }

    public ConfigDefaults setObjectType(Optional<FieldOrValue<String>> objectType) {
      this.objectType = objectType;
      return this;
    }
  }
}