/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.datastax.driver.mapping;

import static com.datastax.driver.mapping.Mapper.Option.Type.SAVE_NULL_FIELDS;
import static com.google.common.base.Preconditions.checkArgument;

import com.datastax.driver.core.AbstractSession;
import com.datastax.driver.core.BoundStatement;
import com.datastax.driver.core.ConsistencyLevel;
import com.datastax.driver.core.GuavaCompatibility;
import com.datastax.driver.core.KeyspaceMetadata;
import com.datastax.driver.core.PreparedStatement;
import com.datastax.driver.core.ProtocolVersion;
import com.datastax.driver.core.ResultSet;
import com.datastax.driver.core.ResultSetFuture;
import com.datastax.driver.core.Session;
import com.datastax.driver.core.SimpleStatement;
import com.datastax.driver.core.Statement;
import com.datastax.driver.core.TableMetadata;
import com.datastax.driver.core.TypeCodec;
import com.datastax.driver.core.querybuilder.BuiltStatement;
import com.datastax.driver.core.querybuilder.Delete;
import com.datastax.driver.core.querybuilder.Insert;
import com.datastax.driver.core.querybuilder.QueryBuilder;
import com.datastax.driver.core.utils.MoreObjects;
import com.datastax.driver.mapping.Mapper.Option.SaveNullFields;
import com.datastax.driver.mapping.annotations.Accessor;
import com.datastax.driver.mapping.annotations.Computed;
import com.google.common.base.Function;
import com.google.common.base.Functions;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.google.common.util.concurrent.Uninterruptibles;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * An object handling the mapping of a particular class.
 *
 * <p>A {@code Mapper} object is obtained from a {@code MappingManager} using the {@link
 * MappingManager#mapper} method.
 */
public class Mapper<T> {

  private static final Logger logger = LoggerFactory.getLogger(Mapper.class);
  private static final Function<Object, Void> TO_NULL = Functions.constant(null);

  private final MappingManager manager;
  private final Class<T> klass;
  private final EntityMapper<T> mapper;
  private final TableMetadata tableMetadata;

  // Cache prepared statements for each type of query we use.
  private final ConcurrentMap<MapperQueryKey, ListenableFuture<PreparedStatement>> preparedQueries =
      new ConcurrentHashMap<MapperQueryKey, ListenableFuture<PreparedStatement>>();

  private volatile EnumMap<Option.Type, Option> defaultSaveOptions;
  private volatile EnumMap<Option.Type, Option> defaultGetOptions;
  private volatile EnumMap<Option.Type, Option> defaultDeleteOptions;

  private static final EnumMap<Option.Type, Option> NO_OPTIONS =
      new EnumMap<Option.Type, Option>(Option.Type.class);

  private final Function<ResultSet, T> mapOneFunction;
  final Function<ResultSet, T> mapOneFunctionWithoutAliases;
  final Function<ResultSet, Result<T>> mapAllFunctionWithoutAliases;

  Mapper(MappingManager manager, Class<T> klass, EntityMapper<T> mapper) {
    this.manager = manager;
    this.klass = klass;
    this.mapper = mapper;

    KeyspaceMetadata keyspace = session().getCluster().getMetadata().getKeyspace(mapper.keyspace);
    this.tableMetadata = keyspace == null ? null : keyspace.getTable(mapper.table);

    this.mapOneFunction =
        new Function<ResultSet, T>() {
          @Override
          public T apply(ResultSet rs) {
            return Mapper.this.map(rs).one();
          }
        };
    this.mapOneFunctionWithoutAliases =
        new Function<ResultSet, T>() {
          @Override
          public T apply(ResultSet rs) {
            return Mapper.this.map(rs).one();
          }
        };
    this.mapAllFunctionWithoutAliases =
        new Function<ResultSet, Result<T>>() {
          @Override
          public Result<T> apply(ResultSet rs) {
            return Mapper.this.map(rs);
          }
        };

    this.defaultSaveOptions = NO_OPTIONS;
    this.defaultGetOptions = NO_OPTIONS;
    this.defaultDeleteOptions = NO_OPTIONS;
  }

  Session session() {
    return manager.getSession();
  }

  ListenableFuture<PreparedStatement> getPreparedQueryAsync(
      QueryType type, Set<AliasedMappedProperty> columns, EnumMap<Option.Type, Option> options) {

    final MapperQueryKey pqk = new MapperQueryKey(type, columns, options);
    ListenableFuture<PreparedStatement> existingFuture = preparedQueries.get(pqk);
    if (existingFuture == null) {
      final SettableFuture<PreparedStatement> future = SettableFuture.create();
      ListenableFuture<PreparedStatement> old = preparedQueries.putIfAbsent(pqk, future);
      if (old != null) {
        return old;
      } else {
        final String queryString =
            type.makePreparedQueryString(tableMetadata, mapper, manager, columns, options.values());
        logger.debug("Preparing query {}", queryString);
        SimpleStatement s = new SimpleStatement(queryString);
        // all queries generated by the mapper are idempotent
        s.setIdempotent(true);
        GuavaCompatibility.INSTANCE.addCallback(
            session().prepareAsync(s),
            new FutureCallback<PreparedStatement>() {
              @Override
              public void onSuccess(PreparedStatement stmt) {
                future.set(stmt);
              }

              @Override
              public void onFailure(Throwable t) {
                future.setException(t);
                // do not keep a failed future in the query cache
                preparedQueries.remove(pqk, future);
                logger.error("Query preparation failed: " + queryString, t);
              }
            });
        return future;
      }
    } else {
      return existingFuture;
    }
  }

  ListenableFuture<PreparedStatement> getPreparedQueryAsync(
      QueryType type, EnumMap<Option.Type, Option> options) {
    return getPreparedQueryAsync(type, Collections.<AliasedMappedProperty>emptySet(), options);
  }

  Class<T> getMappedClass() {
    return klass;
  }

  /**
   * The {@code TableMetadata} for this mapper.
   *
   * @return the {@code TableMetadata} for this mapper or {@code null} if keyspace is not set.
   */
  public TableMetadata getTableMetadata() {
    return tableMetadata;
  }

  /**
   * The {@code MappingManager} managing this mapper.
   *
   * @return the {@code MappingManager} managing this mapper.
   */
  public MappingManager getManager() {
    return manager;
  }

  /**
   * Creates a query that can be used to save the provided entity.
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually or need access to the {@code
   * ResultSet} object after execution (to get the trace, the execution info, ...), but in other
   * cases, calling {@link #save} or {@link #saveAsync} is shorter.
   *
   * <p>Note: this method might block if the query is not prepared yet.
   *
   * @param entity the entity to save.
   * @return a query that saves {@code entity} (based on its defined mapping).
   */
  public Statement saveQuery(T entity) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(saveQueryAsync(entity, this.defaultSaveOptions));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Creates a query that can be used to save the provided entity.
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually or need access to the {@code
   * ResultSet} object after execution (to get the trace, the execution info, ...), but in other
   * cases, calling {@link #save} or {@link #saveAsync} is shorter. This method allows you to
   * provide a suite of {@link Option} to include in the SAVE query. Options currently supported for
   * SAVE are :
   *
   * <ul>
   *   <li>Timestamp
   *   <li>Time-to-live (ttl)
   *   <li>Consistency level
   *   <li>Tracing
   * </ul>
   *
   * Note: this method might block if the query is not prepared yet.
   *
   * @param entity the entity to save.
   * @return a query that saves {@code entity} (based on its defined mapping).
   */
  public Statement saveQuery(T entity, Option... options) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(
          saveQueryAsync(entity, toMapWithDefaults(options, this.defaultSaveOptions)));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  private ListenableFuture<BoundStatement> saveQueryAsync(
      T entity, final EnumMap<Option.Type, Option> options) {
    final Map<AliasedMappedProperty, Object> columnToValue =
        new TreeMap<AliasedMappedProperty, Object>();
    final boolean shouldSaveNullFields = shouldSaveNullFields(options);
    final boolean useUnsetForNullValue = !shouldSaveNullFields && manager.protocolVersionAsInt >= 4;
    final boolean includeColumnsWithNullValue = shouldSaveNullFields || useUnsetForNullValue;

    for (AliasedMappedProperty col : mapper.allColumns) {
      Object value = col.mappedProperty.getValue(entity);
      if (!col.mappedProperty.isComputed() && (includeColumnsWithNullValue || value != null)) {
        columnToValue.put(col, value);
      }
    }

    return GuavaCompatibility.INSTANCE.transform(
        getPreparedQueryAsync(QueryType.SAVE, columnToValue.keySet(), options),
        new Function<PreparedStatement, BoundStatement>() {
          @Override
          public BoundStatement apply(PreparedStatement input) {
            BoundStatement bs = input.bind();
            int i = 0;
            for (Map.Entry<AliasedMappedProperty, Object> entry : columnToValue.entrySet()) {
              AliasedMappedProperty mapper = entry.getKey();
              Object value = entry.getValue();
              setObject(bs, i++, value, mapper, useUnsetForNullValue);
            }

            if (mapper.writeConsistency != null) bs.setConsistencyLevel(mapper.writeConsistency);

            for (Option option : options.values()) {
              option.validate(QueryType.SAVE, manager);
              i = option.apply(bs, i);
            }

            return bs;
          }
        });
  }

  private static boolean shouldSaveNullFields(EnumMap<Option.Type, Option> options) {
    SaveNullFields option = (SaveNullFields) options.get(SAVE_NULL_FIELDS);
    return option == null || option.saveNullFields;
  }

  private static <T> void setObject(
      BoundStatement bs,
      int i,
      T value,
      AliasedMappedProperty mapper,
      boolean saveNullFieldsAsUnset) {
    @SuppressWarnings("unchecked")
    TypeCodec<T> customCodec = (TypeCodec<T>) mapper.mappedProperty.getCustomCodec();
    if (saveNullFieldsAsUnset && value == null) bs.unset(i);
    else if (customCodec != null) bs.set(i, value, customCodec);
    else bs.set(i, value, mapper.mappedProperty.getPropertyType());
  }

  /**
   * Saves an entity mapped by this mapper.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().execute(saveQuery(entity))}.
   *
   * <p>Note: this method will block until the entity is fully saved.
   *
   * @param entity the entity to save.
   */
  public void save(T entity) {
    checkNotInEventLoop();
    try {
      Uninterruptibles.getUninterruptibly(saveAsync(entity));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Saves an entity mapped by this mapper and using special options for save. This method allows
   * you to provide a suite of {@link Option} to include in the SAVE query. Options currently
   * supported for SAVE are :
   *
   * <ul>
   *   <li>Timestamp
   *   <li>Time-to-live (ttl)
   *   <li>Consistency level
   *   <li>Tracing
   * </ul>
   *
   * Note: this method will block until the entity is fully saved.
   *
   * @param entity the entity to save.
   * @param options the options object specified defining special options when saving.
   */
  public void save(T entity, Option... options) {
    checkNotInEventLoop();
    try {
      Uninterruptibles.getUninterruptibly(saveAsync(entity, options));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Saves an entity mapped by this mapper asynchronously.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().executeAsync(saveQuery(entity))}.
   *
   * @param entity the entity to save.
   * @return a future on the completion of the save operation.
   */
  public ListenableFuture<Void> saveAsync(T entity) {
    return submitVoidQueryAsync(saveQueryAsync(entity, this.defaultSaveOptions));
  }

  /**
   * Save an entity mapped by this mapper asynchronously using special options for save.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().executeAsync(saveQuery(entity, options))}.
   *
   * @param entity the entity to save.
   * @param options the options object specified defining special options when saving.
   * @return a future on the completion of the save operation.
   */
  public ListenableFuture<Void> saveAsync(T entity, Option... options) {
    return submitVoidQueryAsync(
        saveQueryAsync(entity, toMapWithDefaults(options, this.defaultSaveOptions)));
  }

  private ListenableFuture<Void> submitVoidQueryAsync(ListenableFuture<BoundStatement> bsFuture) {
    ListenableFuture<ResultSet> rsFuture =
        GuavaCompatibility.INSTANCE.transformAsync(
            bsFuture,
            new AsyncFunction<BoundStatement, ResultSet>() {
              @Override
              public ListenableFuture<ResultSet> apply(BoundStatement bs) throws Exception {
                return session().executeAsync(bs);
              }
            });
    return GuavaCompatibility.INSTANCE.transform(rsFuture, TO_NULL);
  }

  /**
   * Creates a query to fetch entity given its PRIMARY KEY.
   *
   * <p>The values provided must correspond to the columns composing the PRIMARY KEY (in the order
   * of said primary key).
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually, but in other cases, calling {@link
   * #get} or {@link #getAsync} is shorter.
   *
   * <p>This method allows you to provide a suite of {@link Option} to include in the GET query.
   * Options currently supported for GET are :
   *
   * <ul>
   *   <li>Consistency level
   *   <li>Tracing
   * </ul>
   *
   * Note: this method might block if the query is not prepared yet.
   *
   * @param objects the primary key of the entity to fetch, or more precisely the values for the
   *     columns of said primary key in the order of the primary key. Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @return a query that fetch the entity of PRIMARY KEY {@code objects}.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public Statement getQuery(Object... objects) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(getQueryAsync(objects));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  private ListenableFuture<BoundStatement> getQueryAsync(Object... objects) {
    // Order and duplicates matter for primary keys
    List<Object> pks = new ArrayList<Object>();
    EnumMap<Option.Type, Option> options = new EnumMap<Option.Type, Option>(defaultGetOptions);
    for (Object o : objects) {
      if (o instanceof Option) {
        Option option = (Option) o;
        options.put(option.type, option);
      } else {
        pks.add(o);
      }
    }
    return getQueryAsync(pks, options);
  }

  private ListenableFuture<BoundStatement> getQueryAsync(
      final List<Object> primaryKeys, final EnumMap<Option.Type, Option> options) {
    if (primaryKeys.size() != mapper.primaryKeySize())
      throw new IllegalArgumentException(
          String.format(
              "Invalid number of PRIMARY KEY columns provided, %d expected but got %d",
              mapper.primaryKeySize(), primaryKeys.size()));

    return GuavaCompatibility.INSTANCE.transform(
        getPreparedQueryAsync(QueryType.GET, options),
        new Function<PreparedStatement, BoundStatement>() {
          @Override
          public BoundStatement apply(PreparedStatement input) {
            BoundStatement bs = new MapperBoundStatement(input);
            int i = 0;
            for (Object value : primaryKeys) {
              AliasedMappedProperty column = mapper.getPrimaryKeyColumn(i);
              if (value == null) {
                throw new IllegalArgumentException(
                    String.format(
                        "Invalid null value for PRIMARY KEY column %s (argument %d)",
                        column.mappedProperty.getMappedName(), i));
              }
              setObject(bs, i++, value, column, false);
            }

            if (mapper.readConsistency != null) bs.setConsistencyLevel(mapper.readConsistency);

            for (Option option : options.values()) {
              option.validate(QueryType.GET, manager);
              i = option.apply(bs, i);
            }
            return bs;
          }
        });
  }

  /**
   * Fetch an entity based on its primary key.
   *
   * <p>This method is basically equivalent to: {@code
   * map(getManager().getSession().execute(getQuery(objects))).one()}.
   *
   * <p>Note: this method will block until the entity is fully fetched.
   *
   * @param objects the primary key of the entity to fetch, or more precisely the values for the
   *     columns of said primary key in the order of the primary key. Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @return the entity fetched or {@code null} if it doesn't exist.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public T get(Object... objects) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(getAsync(objects));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Fetch an entity based on its primary key asynchronously.
   *
   * <p>This method is basically equivalent to mapping the result of: {@code
   * getManager().getSession().executeAsync(getQuery(objects))}.
   *
   * @param objects the primary key of the entity to fetch, or more precisely the values for the
   *     columns of said primary key in the order of the primary key. Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @return a future on the fetched entity. The return future will yield {@code null} if said
   *     entity doesn't exist.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public ListenableFuture<T> getAsync(final Object... objects) {
    ListenableFuture<BoundStatement> bsFuture = getQueryAsync(objects);
    ListenableFuture<ResultSet> rsFuture =
        GuavaCompatibility.INSTANCE.transformAsync(
            bsFuture,
            new AsyncFunction<BoundStatement, ResultSet>() {
              @Override
              public ListenableFuture<ResultSet> apply(BoundStatement bs) throws Exception {
                return session().executeAsync(bs);
              }
            });
    return GuavaCompatibility.INSTANCE.transform(rsFuture, mapOneFunction);
  }

  /**
   * Creates a query that can be used to delete the provided entity.
   *
   * <p>This method is a shortcut that extract the PRIMARY KEY from the provided entity and call
   * {@link #deleteQuery(Object...)} with it. This method allows you to provide a suite of {@link
   * Option} to include in the DELETE query. Note : currently, only {@link
   * com.datastax.driver.mapping.Mapper.Option.Timestamp} is supported for DELETE queries.
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually or need access to the {@code
   * ResultSet} object after execution (to get the trace, the execution info, ...), but in other
   * cases, calling {@link #delete} or {@link #deleteAsync} is shorter.
   *
   * <p>This method allows you to provide a suite of {@link Option} to include in the DELETE query.
   * Options currently supported for DELETE are :
   *
   * <ul>
   *   <li>Timestamp
   *   <li>Consistency level
   *   <li>Tracing
   * </ul>
   *
   * Note: this method might block if the query is not prepared yet.
   *
   * @param entity the entity to delete.
   * @param options the options to add to the DELETE query.
   * @return a query that delete {@code entity} (based on its defined mapping) with provided USING
   *     options.
   */
  public Statement deleteQuery(T entity, Option... options) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(
          deleteQueryAsync(entity, toMapWithDefaults(options, defaultDeleteOptions)));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Creates a query that can be used to delete the provided entity.
   *
   * <p>This method is a shortcut that extract the PRIMARY KEY from the provided entity and call
   * {@link #deleteQuery(Object...)} with it.
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually or need access to the {@code
   * ResultSet} object after execution (to get the trace, the execution info, ...), but in other
   * cases, calling {@link #delete} or {@link #deleteAsync} is shorter.
   *
   * <p>Note: this method might block if the query is not prepared yet.
   *
   * @param entity the entity to delete.
   * @return a query that delete {@code entity} (based on its defined mapping).
   */
  public Statement deleteQuery(T entity) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(deleteQueryAsync(entity, defaultDeleteOptions));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Creates a query that can be used to delete an entity given its PRIMARY KEY.
   *
   * <p>The values provided must correspond to the columns composing the PRIMARY KEY (in the order
   * of said primary key). The values can also contain, after specifying the primary keys columns, a
   * suite of {@link Option} to include in the DELETE query. Note : currently, only {@link
   * com.datastax.driver.mapping.Mapper.Option.Timestamp} is supported for DELETE queries.
   *
   * <p>This method is useful if you want to setup a number of options (tracing, consistency level,
   * ...) of the returned statement before executing it manually or need access to the {@code
   * ResultSet} object after execution (to get the trace, the execution info, ...), but in other
   * cases, calling {@link #delete} or {@link #deleteAsync} is shorter. This method allows you to
   * provide a suite of {@link Option} to include in the DELETE query. Options currently supported
   * for DELETE are :
   *
   * <ul>
   *   <li>Timestamp
   *   <li>Consistency level
   *   <li>Tracing
   * </ul>
   *
   * Note: this method might block if the query is not prepared yet.
   *
   * @param objects the primary key of the entity to delete, or more precisely the values for the
   *     columns of said primary key in the order of the primary key. Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @return a query that delete the entity of PRIMARY KEY {@code primaryKey}.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public Statement deleteQuery(Object... objects) {
    checkNotInEventLoop();
    try {
      return Uninterruptibles.getUninterruptibly(deleteQueryAsync(objects));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  private ListenableFuture<BoundStatement> deleteQueryAsync(
      T entity, EnumMap<Option.Type, Option> options) {
    List<Object> pks = new ArrayList<Object>();
    for (int i = 0; i < mapper.primaryKeySize(); i++) {
      pks.add(mapper.getPrimaryKeyColumn(i).mappedProperty.getValue(entity));
    }
    return deleteQueryAsync(pks, options);
  }

  private ListenableFuture<BoundStatement> deleteQueryAsync(Object... objects) {
    // Order and duplicates matter for primary keys
    List<Object> pks = new ArrayList<Object>();
    EnumMap<Option.Type, Option> options = new EnumMap<Option.Type, Option>(defaultDeleteOptions);
    for (Object o : objects) {
      if (o instanceof Option) {
        Option option = (Option) o;
        options.put(option.type, option);
      } else {
        pks.add(o);
      }
    }
    return deleteQueryAsync(pks, options);
  }

  private ListenableFuture<BoundStatement> deleteQueryAsync(
      final List<Object> primaryKey, final EnumMap<Option.Type, Option> options) {
    if (primaryKey.size() != mapper.primaryKeySize())
      throw new IllegalArgumentException(
          String.format(
              "Invalid number of PRIMARY KEY columns provided, %d expected but got %d",
              mapper.primaryKeySize(), primaryKey.size()));

    return GuavaCompatibility.INSTANCE.transform(
        getPreparedQueryAsync(QueryType.DEL, options),
        new Function<PreparedStatement, BoundStatement>() {
          @Override
          public BoundStatement apply(PreparedStatement input) {
            BoundStatement bs = input.bind();
            if (mapper.writeConsistency != null) bs.setConsistencyLevel(mapper.writeConsistency);

            int i = 0;
            for (Option option : options.values()) {
              option.validate(QueryType.DEL, manager);
              i = option.apply(bs, i);
            }

            int columnNumber = 0;
            for (Object value : primaryKey) {
              @SuppressWarnings("unchecked")
              AliasedMappedProperty column = mapper.getPrimaryKeyColumn(columnNumber);
              if (value == null) {
                throw new IllegalArgumentException(
                    String.format(
                        "Invalid null value for PRIMARY KEY column %s (argument %d)",
                        column.mappedProperty.getMappedName(), i));
              }
              setObject(bs, i++, value, column, false);
              columnNumber++;
            }
            return bs;
          }
        });
  }

  /**
   * Deletes an entity mapped by this mapper.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().execute(deleteQuery(entity))}.
   *
   * <p>Note: this method will block until the entity is fully deleted.
   *
   * @param entity the entity to delete.
   */
  public void delete(T entity) {
    checkNotInEventLoop();
    try {
      Uninterruptibles.getUninterruptibly(deleteAsync(entity));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Deletes an entity mapped by this mapper using provided options.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().execute(deleteQuery(entity, options))}.
   *
   * <p>Note: this method will block until the entity is fully deleted.
   *
   * @param entity the entity to delete.
   * @param options the options to add to the DELETE query.
   */
  public void delete(T entity, Option... options) {
    checkNotInEventLoop();
    try {
      Uninterruptibles.getUninterruptibly(deleteAsync(entity, options));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Deletes an entity mapped by this mapper asynchronously.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().executeAsync(deleteQuery(entity))}.
   *
   * @param entity the entity to delete.
   * @return a future on the completion of the deletion.
   */
  public ListenableFuture<Void> deleteAsync(T entity) {
    return submitVoidQueryAsync(deleteQueryAsync(entity, defaultDeleteOptions));
  }

  /**
   * Deletes an entity mapped by this mapper asynchronously using provided options.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().executeAsync(deleteQuery(entity, options))}.
   *
   * @param entity the entity to delete.
   * @param options the options to add to the DELETE query.
   * @return a future on the completion of the deletion.
   */
  public ListenableFuture<Void> deleteAsync(T entity, Option... options) {
    return submitVoidQueryAsync(
        deleteQueryAsync(entity, toMapWithDefaults(options, defaultDeleteOptions)));
  }

  /**
   * Deletes an entity based on its primary key.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().execute(deleteQuery(objects))}.
   *
   * <p>Note: this method will block until the entity is fully deleted.
   *
   * @param objects the primary key of the entity to delete, or more precisely the values for the
   *     columns of said primary key in the order of the primary key.Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public void delete(Object... objects) {
    checkNotInEventLoop();
    try {
      Uninterruptibles.getUninterruptibly(deleteAsync(objects));
    } catch (ExecutionException e) {
      throw DriverThrowables.propagateCause(e);
    }
  }

  /**
   * Deletes an entity based on its primary key asynchronously.
   *
   * <p>This method is basically equivalent to: {@code
   * getManager().getSession().executeAsync(deleteQuery(objects))}.
   *
   * @param objects the primary key of the entity to delete, or more precisely the values for the
   *     columns of said primary key in the order of the primary key. Can be followed by {@link
   *     Option} to include in the DELETE query.
   * @throws IllegalArgumentException if the number of value provided differ from the number of
   *     columns composing the PRIMARY KEY of the mapped class, or if at least one of those values
   *     is {@code null}.
   */
  public ListenableFuture<Void> deleteAsync(Object... objects) {
    return submitVoidQueryAsync(deleteQueryAsync(objects));
  }

  /**
   * Maps the rows from a {@code ResultSet} into the class this is a mapper of.
   *
   * <p>If the query was user-generated (that is, passed directly to {@code session.execute()} or
   * configured with {@link com.datastax.driver.mapping.annotations.Query @Query} in an {@link
   * com.datastax.driver.mapping.annotations.Accessor @Accessor}-annotated interface), then only the
   * columns present in the result set will be mapped to the corresponding fields, and {@link
   * Computed} fields will not be populated.
   *
   * <p>If the query was generated by the mapper (for example with {@link #getQuery(Object...)}),
   * all fields will be mapped, as if the object came from a direct {@link #get(Object...)} call.
   *
   * @param resultSet the {@code ResultSet} to map.
   * @return the mapped result set. Note that the returned mapped result set will encapsulate {@code
   *     resultSet} and so consuming results from this returned mapped result set will consume
   *     results from {@code resultSet} and vice-versa.
   */
  public Result<T> map(ResultSet resultSet) {
    boolean useAlias = (manager.protocolVersionAsInt > 1) && isFromMapperQuery(resultSet);
    return new Result<T>(resultSet, mapper, useAlias);
  }

  /**
   * Asynchronously maps the rows from a {@link ResultSetFuture} into the class this is a mapper of.
   *
   * <p>Use this method to map a {@link ResultSetFuture} that was not generated by the mapper (e.g.
   * a {@link ResultSetFuture} coming from a manual query or an {@link Accessor} method). It expects
   * that the result set contains all column mapped in the target class, and that they are not
   * aliased. {@link Computed} fields will not be filled in mapped objects.
   *
   * @param resultSetFuture the {@link ResultSetFuture} to map.
   * @return the mapped result set future. Note that the returned mapped result set will encapsulate
   *     the result set returned by {@code resultSetFuture} and so consuming results from this
   *     returned mapped result set will consume results from that result set and vice-versa.
   */
  public ListenableFuture<Result<T>> mapAsync(ResultSetFuture resultSetFuture) {
    return GuavaCompatibility.INSTANCE.transform(
        resultSetFuture,
        new Function<ResultSet, Result<T>>() {
          @Override
          public Result<T> apply(ResultSet rs) {
            return map(rs);
          }
        });
  }

  private boolean isFromMapperQuery(ResultSet resultSet) {
    return resultSet.getExecutionInfo().getStatement() instanceof MapperBoundStatement;
  }

  /**
   * @deprecated you no longer need to specify whether a result set is aliased, it will be detected
   *     automatically. Use {@link #map(ResultSet)} instead of this method.
   */
  @Deprecated
  public Result<T> mapAliased(ResultSet resultSet) {
    return (manager.protocolVersionAsInt == 1)
        ? map(resultSet) // no aliases
        : new Result<T>(resultSet, mapper, true);
  }

  /**
   * Set the default save {@link Option} for this object mapper, that will be used in all save
   * operations unless overridden. Refer to {@link Mapper#save(Object, Option...)})} to check
   * available save options.
   *
   * @param options the options to set. To reset, use {@link Mapper#resetDefaultSaveOptions}.
   */
  public void setDefaultSaveOptions(Option... options) {
    this.defaultSaveOptions = toMap(options);
  }

  /** Reset the default save options for this object mapper. */
  public void resetDefaultSaveOptions() {
    this.defaultSaveOptions = NO_OPTIONS;
  }

  /**
   * Set the default get {@link Option} for this object mapper, that will be used in all get
   * operations unless overridden. Refer to {@link Mapper#get(Object...)} )} to check available get
   * options.
   *
   * @param options the options to set. To reset, use {@link Mapper#resetDefaultGetOptions}.
   */
  public void setDefaultGetOptions(Option... options) {
    this.defaultGetOptions = toMap(options);
  }

  /** Reset the default save options for this object mapper. */
  public void resetDefaultGetOptions() {
    this.defaultGetOptions = NO_OPTIONS;
  }

  /**
   * Set the default delete {@link Option} for this object mapper, that will be used in all delete
   * operations unless overridden. Refer to {@link Mapper#delete(Object...)} )} to check available
   * delete options.
   *
   * @param options the options to set. To reset, use {@link Mapper#resetDefaultDeleteOptions}.
   */
  public void setDefaultDeleteOptions(Option... options) {
    this.defaultDeleteOptions = toMap(options);
  }

  /** Reset the default delete options for this object mapper. */
  public void resetDefaultDeleteOptions() {
    this.defaultDeleteOptions = NO_OPTIONS;
  }

  private static EnumMap<Option.Type, Option> toMap(Option[] options) {
    EnumMap<Option.Type, Option> result = new EnumMap<Option.Type, Option>(Option.Type.class);
    for (Option option : options) {
      result.put(option.type, option);
    }
    return result;
  }

  private static EnumMap<Option.Type, Option> toMapWithDefaults(
      Option[] options, EnumMap<Option.Type, Option> defaults) {
    EnumMap<Option.Type, Option> result = new EnumMap<Option.Type, Option>(defaults);
    for (Option option : options) {
      result.put(option.type, option);
    }
    return result;
  }

  private void checkNotInEventLoop() {
    Session session = manager.getSession();
    if (session instanceof AbstractSession) {
      ((AbstractSession) session).checkNotInEventLoop();
    }
  }

  /**
   * An option for a mapper operation.
   *
   * <p>Options can be passed to individual operations:
   *
   * <pre>
   *     mapper.save(myObject, Option.ttl(3600));
   * </pre>
   *
   * <p>The mapper can also have defaults, that will apply to all operations that do not override
   * these particular option:
   *
   * <pre>
   *     mapper.setDefaultSaveOptions(Option.ttl(3600));
   *     mapper.save(myObject);
   * </pre>
   *
   * <p>
   *
   * <p>See the static methods in this class for available options.
   */
  public abstract static class Option {

    enum Type {
      TTL,
      TIMESTAMP,
      CL,
      TRACING,
      SAVE_NULL_FIELDS,
      IF_NOT_EXISTS
    }

    /**
     * Creates a new Option object to add time-to-live to a mapper operation. This is only valid for
     * save operations.
     *
     * <p>Note that this option is only available if using {@link ProtocolVersion#V2} or above.
     *
     * @param ttl the TTL (in seconds).
     * @return the option.
     */
    public static Option ttl(int ttl) {
      return new Ttl(ttl);
    }

    /**
     * Creates a new Option object to add a timestamp to a mapper operation. This is only valid for
     * save and delete operations.
     *
     * <p>Note that this option is only available if using {@link ProtocolVersion#V2} or above.
     *
     * @param timestamp the timestamp (in microseconds).
     * @return the option.
     */
    public static Option timestamp(long timestamp) {
      return new Timestamp(timestamp);
    }

    /**
     * Creates a new Option object to add a consistency level value to a mapper operation. This is
     * valid for save, delete and get operations.
     *
     * <p>Note that the consistency level can also be defined at the mapper level, as a parameter of
     * the {@link com.datastax.driver.mapping.annotations.Table} annotation (this is redundant for
     * backward compatibility). This option, whether defined on a specific call or as the default,
     * will always take precedence over the annotation.
     *
     * @param cl the {@link com.datastax.driver.core.ConsistencyLevel} to use for the operation.
     * @return the option.
     */
    public static Option consistencyLevel(ConsistencyLevel cl) {
      return new ConsistencyLevelOption(cl);
    }

    /**
     * Creates a new Option object to enable query tracing for a mapper operation. This is valid for
     * save, delete and get operations.
     *
     * @param enabled whether to enable tracing.
     * @return the option.
     */
    public static Option tracing(boolean enabled) {
      return new Tracing(enabled);
    }

    /**
     * Creates a new Option object to specify whether null entity fields should be included in
     * insert queries. This option is valid only for save operations.
     *
     * <p>If this option is not specified, it defaults to {@code true} (null fields are saved).
     *
     * @param enabled whether to include null fields in queries.
     * @return the option.
     */
    public static Option saveNullFields(boolean enabled) {
      return new SaveNullFields(enabled);
    }

    /**
     * Creates a new Option object to specify whether an IF NOT EXISTS clause should be included in
     * insert queries. This option is valid only for save operations.
     *
     * <p>If this option is not specified, it defaults to {@code false} (IF NOT EXISTS statements
     * are not used).
     *
     * @param enabled whether to include an IF NOT EXISTS clause in queries.
     * @return the option.
     */
    public static Option ifNotExists(boolean enabled) {
      return new IfNotExists(enabled);
    }

    final Type type;

    protected Option(Type type) {
      this.type = type;
    }

    /**
     * @deprecated This method is public for backward compatibility only. It should not be
     *     accessible since it leaks a package-private type.
     */
    @Deprecated
    public Type getType() {
      return type;
    }

    /**
     * Checks that the option is allowed for a given query type, in a given manager.
     *
     * @throws IllegalArgumentException if the option is not valid
     */
    abstract void validate(QueryType qt, MappingManager manager);

    /** Whether the option has any impact on the prepared query string. */
    abstract boolean modifiesQueryString();

    /**
     * Appends the option to the prepared statement associated to the mapper operation (might be a
     * no-op, not all options affect the query string).
     */
    abstract void modifyQueryString(BuiltStatement query);

    /**
     * Applies the option to the bound statement before the request gets executed (might be a
     * no-op).
     *
     * <p>If the option's value is set as a bound variable, then this must be done at the given
     * index, and the method should return the new position.
     *
     * <p>Options may also affect the statement itself, not a bound variable; in that case, the
     * method must return the index unchanged.
     */
    abstract int apply(BoundStatement bs, int currentIndex);

    /**
     * A key to represent the option in the prepared statement cache.
     *
     * <p>If the same option with different values produces different query strings, this should be
     * the option itself. Otherwise this should be the option type.
     */
    abstract Object asCacheKey();

    static class Ttl extends Option {

      private final int ttlValue;

      Ttl(int value) {
        super(Type.TTL);
        this.ttlValue = value;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {
        checkArgument(
            !(manager.protocolVersionAsInt < 2), "TTL option requires native protocol v2 or above");
        checkArgument(qt == QueryType.SAVE, "TTL option is only allowed in save queries");
      }

      @Override
      boolean modifiesQueryString() {
        return true;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        ((Insert) query).using().and(QueryBuilder.ttl(QueryBuilder.bindMarker()));
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        bs.setInt(currentIndex, this.ttlValue);
        return currentIndex + 1;
      }

      @Override
      Object asCacheKey() {
        // ttl is set as a bound variable "TTL(?)", so different values yield the same prepared
        // query
        return Type.TTL;
      }

      @Override
      public boolean equals(Object other) {
        return other == this || (other instanceof Ttl && this.ttlValue == ((Ttl) other).ttlValue);
      }

      @Override
      public int hashCode() {
        return ttlValue;
      }
    }

    static class Timestamp extends Option {

      private final long tsValue;

      Timestamp(long value) {
        super(Type.TIMESTAMP);
        this.tsValue = value;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {
        checkArgument(
            !(manager.protocolVersionAsInt < 2),
            "Timestamp option requires native protocol v2 or above");
        checkArgument(
            qt == QueryType.SAVE || qt == QueryType.DEL,
            "Timestamp option is only allowed in save and delete queries");
      }

      @Override
      boolean modifiesQueryString() {
        return true;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        if (query instanceof Insert) {
          ((Insert) query).using().and(QueryBuilder.timestamp(QueryBuilder.bindMarker()));
        } else if (query instanceof Delete) {
          ((Delete) query).using().and(QueryBuilder.timestamp(QueryBuilder.bindMarker()));
        } else {
          throw new AssertionError("Unexpected query type: " + query.getClass());
        }
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        bs.setLong(currentIndex, this.tsValue);
        return currentIndex + 1;
      }

      @Override
      Object asCacheKey() {
        // timestamp is set as a bound variable "USING TIMESTAMP ?", so different values
        // yield the same prepared query
        return Type.TIMESTAMP;
      }

      @Override
      public boolean equals(Object other) {
        return other == this
            || (other instanceof Timestamp && this.tsValue == ((Timestamp) other).tsValue);
      }

      @Override
      public int hashCode() {
        return (int) (tsValue ^ (tsValue >>> 32));
      }
    }

    static class ConsistencyLevelOption extends Option {

      private final ConsistencyLevel cl;

      ConsistencyLevelOption(ConsistencyLevel cl) {
        super(Type.CL);
        this.cl = cl;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {}

      @Override
      boolean modifiesQueryString() {
        return false;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        // nothing to do, CL is set on the bound statement
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        bs.setConsistencyLevel(cl);
        return currentIndex;
      }

      @Override
      Object asCacheKey() {
        // does not modify the query string
        return Type.CL;
      }

      @Override
      public boolean equals(Object other) {
        return other == this
            || (other instanceof ConsistencyLevelOption
                && this.cl == ((ConsistencyLevelOption) other).cl);
      }

      @Override
      public int hashCode() {
        return cl.hashCode();
      }
    }

    static class Tracing extends Option {

      private final boolean tracing;

      Tracing(boolean tracing) {
        super(Type.TRACING);
        this.tracing = tracing;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {}

      @Override
      boolean modifiesQueryString() {
        return false;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        // nothing to do, tracing is enabled on the prepared statement
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        if (this.tracing) {
          bs.enableTracing();
        }
        return currentIndex;
      }

      @Override
      Object asCacheKey() {
        // does not modify the query string
        return Type.TRACING;
      }

      @Override
      public boolean equals(Object other) {
        return other == this
            || (other instanceof Tracing && this.tracing == ((Tracing) other).tracing);
      }

      @Override
      public int hashCode() {
        return tracing ? 1231 : 1237;
      }
    }

    static class SaveNullFields extends Option {

      private final boolean saveNullFields;

      SaveNullFields(boolean saveNullFields) {
        super(SAVE_NULL_FIELDS);
        this.saveNullFields = saveNullFields;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {
        checkArgument(
            qt == QueryType.SAVE, "SaveNullFields option is only allowed in save queries");
      }

      @Override
      boolean modifiesQueryString() {
        return false;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        // nothing to do
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        return currentIndex;
      }

      @Override
      Object asCacheKey() {
        // does not modify the query string
        return Type.SAVE_NULL_FIELDS;
      }

      @Override
      public boolean equals(Object other) {
        return other == this
            || (other instanceof SaveNullFields
                && this.saveNullFields == ((SaveNullFields) other).saveNullFields);
      }

      @Override
      public int hashCode() {
        return saveNullFields ? 1231 : 1237;
      }
    }

    static class IfNotExists extends Option {
      boolean ifNotExists;

      IfNotExists(boolean ifNotExists) {
        super(Type.IF_NOT_EXISTS);
        this.ifNotExists = ifNotExists;
      }

      @Override
      void validate(QueryType qt, MappingManager manager) {
        checkArgument(qt == QueryType.SAVE, "IfNotExists option is only allowed in save queries");
      }

      @Override
      boolean modifiesQueryString() {
        return true;
      }

      @Override
      void modifyQueryString(BuiltStatement query) {
        if (ifNotExists) {
          ((Insert) query).ifNotExists();
        }
      }

      @Override
      int apply(BoundStatement bs, int currentIndex) {
        return currentIndex;
      }

      @Override
      Object asCacheKey() {
        // Different values do change the query string ("IF NOT EXIST" clause present or not)
        return this;
      }

      @Override
      public boolean equals(Object other) {
        return other == this
            || (other instanceof IfNotExists
                && this.ifNotExists == ((IfNotExists) other).ifNotExists);
      }

      @Override
      public int hashCode() {
        return (ifNotExists) ? 1231 : 1237;
      }
    }
  }

  private static class MapperQueryKey {
    private final QueryType queryType;
    private final Set<Object> optionKeys;
    private final Set<AliasedMappedProperty> columns;

    MapperQueryKey(
        QueryType queryType,
        Set<AliasedMappedProperty> aliasedMappedProperties,
        EnumMap<Option.Type, Option> allOptions) {
      Preconditions.checkNotNull(queryType);
      Preconditions.checkNotNull(allOptions);
      Preconditions.checkNotNull(aliasedMappedProperties);
      this.queryType = queryType;
      this.columns = aliasedMappedProperties;
      ImmutableSet.Builder<Object> optionKeysBuilder = ImmutableSet.builder();
      for (Option option : allOptions.values()) {
        if (option.modifiesQueryString()) {
          optionKeysBuilder.add(option.asCacheKey());
        }
      }
      this.optionKeys = optionKeysBuilder.build();
    }

    @Override
    public boolean equals(Object other) {
      if (this == other) return true;
      if (other instanceof MapperQueryKey) {
        MapperQueryKey that = (MapperQueryKey) other;
        return this.queryType.equals(that.queryType)
            && this.optionKeys.equals(that.optionKeys)
            && this.columns.equals(that.columns);
      }
      return false;
    }

    @Override
    public int hashCode() {
      return MoreObjects.hashCode(queryType, optionKeys, columns);
    }
  }
}
