/*
 * 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 org.apache.gobblin.couchbase.writer;

import com.google.gson.JsonObject;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.avro.Schema;
import org.apache.avro.SchemaBuilder;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.commons.math3.util.Pair;
import org.apache.gobblin.writer.GenericWriteResponse;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.testng.Assert;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;
import org.testng.annotations.Test;

import com.couchbase.client.deps.io.netty.buffer.ByteBuf;
import com.couchbase.client.java.Bucket;
import com.couchbase.client.java.document.AbstractDocument;
import com.couchbase.client.java.document.RawJsonDocument;
import com.couchbase.client.java.env.CouchbaseEnvironment;
import com.couchbase.client.java.env.DefaultCouchbaseEnvironment;
import com.google.gson.Gson;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;

import lombok.extern.slf4j.Slf4j;

import org.apache.gobblin.converter.Converter;
import org.apache.gobblin.converter.DataConversionException;
import org.apache.gobblin.couchbase.CouchbaseTestServer;
import org.apache.gobblin.couchbase.common.TupleDocument;
import org.apache.gobblin.couchbase.converter.AnyToCouchbaseJsonConverter;
import org.apache.gobblin.couchbase.converter.AvroToCouchbaseTupleConverter;
import org.apache.gobblin.metrics.RootMetricContext;
import org.apache.gobblin.metrics.reporter.OutputStreamReporter;
import org.apache.gobblin.test.TestUtils;
import org.apache.gobblin.writer.AsyncWriterManager;
import org.apache.gobblin.writer.WriteCallback;
import org.apache.gobblin.writer.WriteResponse;

@Slf4j
public class CouchbaseWriterTest {

  private CouchbaseTestServer _couchbaseTestServer;
  private CouchbaseEnvironment _couchbaseEnvironment;

  @BeforeSuite
  public void startServers() {
    _couchbaseTestServer = new CouchbaseTestServer(TestUtils.findFreePort());
    _couchbaseTestServer.start();
    _couchbaseEnvironment = DefaultCouchbaseEnvironment.builder().bootstrapHttpEnabled(true)
        .bootstrapHttpDirectPort(_couchbaseTestServer.getPort())
        .bootstrapCarrierDirectPort(_couchbaseTestServer.getServerPort()).bootstrapCarrierEnabled(false)
        .kvTimeout(10000).build();
  }


  /**
   * Implement the equivalent of:
   * curl -XPOST -u Administrator:password localhost:httpPort/pools/default/buckets \ -d bucketType=couchbase \
   * -d name={@param bucketName} -d authType=sasl -d ramQuotaMB=200
   **/
  private boolean createBucket(String bucketName) {
    CloseableHttpClient httpClient = HttpClientBuilder.create().build();
    try {
      HttpPost httpPost = new HttpPost("http://localhost:" + _couchbaseTestServer.getPort() + "/pools/default/buckets");
      List<NameValuePair> params = new ArrayList<>(2);
      params.add(new BasicNameValuePair("bucketType", "couchbase"));
      params.add(new BasicNameValuePair("name", bucketName));
      params.add(new BasicNameValuePair("authType", "sasl"));
      params.add(new BasicNameValuePair("ramQuotaMB", "200"));
      httpPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
      //Execute and get the response.
      HttpResponse response = httpClient.execute(httpPost);
      log.info(String.valueOf(response.getStatusLine().getStatusCode()));
      return true;
    }
    catch (Exception e) {
      log.error("Failed to create bucket {}", bucketName, e);
      return false;
    }
  }

  @AfterSuite
  public void stopServers() {
    _couchbaseTestServer.stop();
  }

  /**
   * Test that a single tuple document can be written successfully.
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test
  public void testTupleDocumentWrite()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    Config config = getConfig("default", Optional.empty(), Optional.empty(), Optional.empty());

    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {
      Schema dataRecordSchema =
          SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
              .noDefault().endRecord();

      Schema schema = SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
          .type(dataRecordSchema).noDefault().endRecord();

      GenericData.Record testRecord = new GenericData.Record(schema);

      String testContent = "hello world";

      GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
      dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
      dataRecord.put("flags", 0);

      testRecord.put("key", "hello");
      testRecord.put("data", dataRecord);

      Converter<Schema, String, GenericRecord, TupleDocument> recordConverter = new AvroToCouchbaseTupleConverter();

      TupleDocument doc = recordConverter.convertRecord("", testRecord, null).iterator().next();
      writer.write(doc, null).get();
      TupleDocument returnDoc = writer.getBucket().get("hello", TupleDocument.class);

      byte[] returnedBytes = new byte[returnDoc.content().value1().readableBytes()];
      returnDoc.content().value1().readBytes(returnedBytes);
      Assert.assertEquals(returnedBytes, testContent.getBytes(Charset.forName("UTF-8")));

      int returnedFlags = returnDoc.content().value2();
      Assert.assertEquals(returnedFlags, 0);

    } finally {
      writer.close();
    }

  }

  private Config getConfig(String bucket, Optional<Integer> ttl, Optional<TimeUnit> ttlTimeUnit, Optional<String> ttlOriginField) {
    Properties props = new Properties();
    props.setProperty(CouchbaseWriterConfigurationKeys.BUCKET, bucket);
    ttl.ifPresent(x -> props.setProperty(CouchbaseWriterConfigurationKeys.DOCUMENT_TTL, "" + x));
    ttlTimeUnit.ifPresent(x ->  props.setProperty(CouchbaseWriterConfigurationKeys.DOCUMENT_TTL_UNIT, "" + x));
    ttlOriginField.ifPresent(x ->  props.setProperty(CouchbaseWriterConfigurationKeys.DOCUMENT_TTL_ORIGIN_FIELD, "" + x));
    return ConfigFactory.parseProperties(props);
  }
  /**
   * Test that a single tuple document can be written successfully.
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test
  public void testTupleDocumentWriteWithTtl()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    int ttl = 10000;
    long expiry = Math.toIntExact(System.currentTimeMillis() / 1000) + ttl;
    Config config = getConfig("default", Optional.of(ttl), Optional.empty(), Optional.empty());

    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {
      Schema dataRecordSchema =
          SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
              .noDefault().endRecord();

      Schema schema = SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
          .type(dataRecordSchema).noDefault().endRecord();

      GenericData.Record testRecord = new GenericData.Record(schema);

      String testContent = "hello world";

      GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
      dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
      dataRecord.put("flags", 0);

      testRecord.put("key", "hello");
      testRecord.put("data", dataRecord);

      Converter<Schema, String, GenericRecord, TupleDocument> recordConverter = new AvroToCouchbaseTupleConverter();

      TupleDocument doc = recordConverter.convertRecord("", testRecord, null).iterator().next();
      AbstractDocument storedDoc = ((GenericWriteResponse<AbstractDocument>) writer.write(doc, null).get()).getRawResponse();
      TupleDocument returnDoc = writer.getBucket().get("hello", TupleDocument.class);

      byte[] returnedBytes = new byte[returnDoc.content().value1().readableBytes()];
      returnDoc.content().value1().readBytes(returnedBytes);
      Assert.assertEquals(returnedBytes, testContent.getBytes(Charset.forName("UTF-8")));

      int returnedFlags = returnDoc.content().value2();
      Assert.assertEquals(returnedFlags, 0);

      // Since get operations do not set the expiry meta, we need to rely
      Assert.assertEquals(storedDoc.expiry() - expiry , 0, 50 );

    } finally {
      writer.close();
    }

  }
  /**
   * Test that a single Json document can be written successfully
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test(groups={"timeout"})
  public void testJsonDocumentWrite()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, ConfigFactory.empty());
    try {

      String key = "hello";
      String testContent = "hello world";
      HashMap<String, String> contentMap = new HashMap<>();
      contentMap.put("value", testContent);
      Gson gson = new Gson();
      String jsonString = gson.toJson(contentMap);
      RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonString);
      writer.write(jsonDocument, null).get();
      RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);

      Map<String, String> returnedMap = gson.fromJson(returnDoc.content(), Map.class);
      Assert.assertEquals(testContent, returnedMap.get("value"));
    } finally {
      writer.close();
    }
  }

  /**
   * Test that a single Json document can be written successfully with TTL
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test(groups={"timeout"})
  public void testJsonDocumentWriteTTL()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    int ttl = 1000;
    int expiry = Math.toIntExact(System.currentTimeMillis() / 1000) + ttl;
    Config config = getConfig("default", Optional.of(ttl), Optional.empty(), Optional.empty());
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {

      String key = "hello";
      String testContent = "hello world";
      HashMap<String, String> contentMap = new HashMap<>();
      contentMap.put("value", testContent);
      Gson gson = new Gson();
      String jsonString = gson.toJson(contentMap);
      RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonString);
      AbstractDocument storedDoc = ((GenericWriteResponse<AbstractDocument>) writer.write(jsonDocument, null).get()).getRawResponse();
      RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);

      Map<String, String> returnedMap = gson.fromJson(returnDoc.content(), Map.class);
      Assert.assertEquals(testContent, returnedMap.get("value"));
      Assert.assertEquals(storedDoc.expiry(), expiry, 50);
    } finally {
      writer.close();
    }
  }


  /**
   * Test that a single Json document can be written successfully with TTL and timeunits
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test(groups={"timeout"})
  public void testJsonDocumentWriteTTLWithTimeUnits()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    int ttl = 1;
    TimeUnit timeUnit = TimeUnit.DAYS;
    int expiry = Math.toIntExact(System.currentTimeMillis() / 1000) + (int) TimeUnit.SECONDS.convert(ttl, timeUnit);
    Config config = getConfig("default", Optional.of(ttl), Optional.of(timeUnit), Optional.empty());
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {

      String key = "hello";
      String testContent = "hello world";
      HashMap<String, String> contentMap = new HashMap<>();
      contentMap.put("value", testContent);
      Gson gson = new Gson();
      String jsonString = gson.toJson(contentMap);
      RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonString);
      AbstractDocument storedDoc = ((GenericWriteResponse<AbstractDocument>) writer.write(jsonDocument, null).get()).getRawResponse();
      RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);

      Map<String, String> returnedMap = gson.fromJson(returnDoc.content(), Map.class);
      Assert.assertEquals(testContent, returnedMap.get("value"));
      Assert.assertEquals(storedDoc.expiry() - expiry, 0, 50);
    } finally {
      writer.close();
    }
  }


  /**
   * Test that a single Json document can be written successfully with TTL and timeunits
   * @throws IOException
   * @throws DataConversionException
   * @throws ExecutionException
   * @throws InterruptedException
   */
  @Test(groups={"timeout"})
  public void testJsonDocumentWriteTtlWithField()
      throws ExecutionException, InterruptedException {
    int ttl = 30;
    int originDiffFromNow = 5;
    TimeUnit timeUnit = TimeUnit.DAYS;
    String ttlOriginField = "time";
    long now = System.currentTimeMillis();
    long originDelta =  TimeUnit.MILLISECONDS.convert(originDiffFromNow, TimeUnit.DAYS);
    long origin = now - originDelta;
    long expiry = TimeUnit.SECONDS.convert(now, TimeUnit.MILLISECONDS) + TimeUnit.SECONDS.convert(ttl, timeUnit) - TimeUnit.SECONDS.convert(originDiffFromNow, timeUnit) ;

    Config config = getConfig("default", Optional.of(ttl), Optional.of(timeUnit), Optional.of(ttlOriginField));
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {

      String key = "hello";
      String testContent = "hello world";
      HashMap<String, String> contentMap = new HashMap<>();
      contentMap.put("value", testContent);
      contentMap.put(ttlOriginField, "" + origin);
      Gson gson = new Gson();
      String jsonString = gson.toJson(contentMap);
      RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonString);
      AbstractDocument storedDoc = ((GenericWriteResponse<AbstractDocument>) writer.write(jsonDocument, null).get()).getRawResponse();
      RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);

      Map<String, String> returnedMap = gson.fromJson(returnDoc.content(), Map.class);
      Assert.assertEquals(testContent, returnedMap.get("value"));
      Assert.assertEquals(storedDoc.expiry() , expiry, 50);
    } finally {
      writer.close();
    }
  }

  @Test(groups={"timeout"})
  public void testJsonDocumentWriteTtlWithNestedField()
      throws ExecutionException, InterruptedException {
    int ttl = 30;
    int originDiffFromNow = 5;
    TimeUnit timeUnit = TimeUnit.DAYS;
    String ttlOriginField = "a.b.time";
    long now = System.currentTimeMillis();
    long originDelta =  TimeUnit.MILLISECONDS.convert(originDiffFromNow, timeUnit);
    long origin = now - originDelta;
    long expiry = TimeUnit.SECONDS.convert(now, TimeUnit.MILLISECONDS) + TimeUnit.SECONDS.convert(ttl, timeUnit) - TimeUnit.SECONDS.convert(originDiffFromNow, timeUnit) ;

    Config config = getConfig("default", Optional.of(ttl), Optional.of(timeUnit), Optional.of(ttlOriginField));
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);
    try {
      JsonObject jsonRoot = new JsonObject();
      String key = "keyValue";
      String testContent = "hello world";
      String valueKey = "value";
      jsonRoot.addProperty(valueKey, testContent);
      jsonRoot.addProperty(ttlOriginField, origin);

      RawJsonDocument jsonDocument = RawJsonDocument.create(key, jsonRoot.toString());
      AbstractDocument storedDoc = ((GenericWriteResponse<AbstractDocument>) writer.write(jsonDocument, null).get()).getRawResponse();
      RawJsonDocument returnDoc = writer.getBucket().get(key, RawJsonDocument.class);

      Map<String, String> returnedMap = new Gson().fromJson(returnDoc.content(), Map.class);
      Assert.assertEquals(returnedMap.get(valueKey), testContent);
      Assert.assertEquals(storedDoc.expiry() , expiry, 50);
    } finally {
      writer.close();
    }
  }

  private void drainQueue(BlockingQueue<Pair<AbstractDocument, Future>> queue, int threshold, long sleepTime,
      TimeUnit sleepUnit, List<Pair<AbstractDocument, Future>> failedFutures) {
    while (queue.remainingCapacity() < threshold) {
      if (sleepTime > 0) {
        Pair<AbstractDocument, Future> topElement = queue.peek();
        if (topElement != null) {
          try {
            topElement.getSecond().get(sleepTime, sleepUnit);
          } catch (Exception te) {
            failedFutures.add(topElement);
          }
          queue.poll();
        }
      }
    }
  }

  /**
   * An iterator that applies the {@link AnyToCouchbaseJsonConverter} converter to Objects
   */
  class JsonDocumentIterator implements Iterator<AbstractDocument> {
    private final int _maxRecords;
    private int _currRecord;
    private Iterator<Object> _objectIterator;
    private final Converter<String, String, Object, RawJsonDocument> _recordConverter =
        new AnyToCouchbaseJsonConverter();

    JsonDocumentIterator(Iterator<Object> genericRecordIterator) {
      this(genericRecordIterator, -1);
    }

    JsonDocumentIterator(Iterator<Object> genericRecordIterator, int maxRecords) {
      _objectIterator = genericRecordIterator;
      _maxRecords = maxRecords;
      _currRecord = 0;
    }

    @Override
    public boolean hasNext() {
      if (_maxRecords < 0) {
        return _objectIterator.hasNext();
      } else {
        return _objectIterator.hasNext() && (_currRecord < _maxRecords);
      }
    }

    @Override
    public AbstractDocument next() {
      _currRecord++;
      Object record = _objectIterator.next();
      try {
        return _recordConverter.convertRecord("", record, null).iterator().next();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    @Override
    public void remove() {
    }

  }

  /**
   * An iterator that applies the {@link AvroToCouchbaseTupleConverter} converter to GenericRecords
   */
  class TupleDocumentIterator implements Iterator<AbstractDocument> {
    private final int _maxRecords;
    private int _currRecord;
    private Iterator<GenericRecord> _genericRecordIterator;
    private final Converter<Schema, String, GenericRecord, TupleDocument> _recordConverter =
        new AvroToCouchbaseTupleConverter();

    TupleDocumentIterator(Iterator<GenericRecord> genericRecordIterator) {
      this(genericRecordIterator, -1);
    }

    TupleDocumentIterator(Iterator<GenericRecord> genericRecordIterator, int maxRecords) {
      _genericRecordIterator = genericRecordIterator;
      _maxRecords = maxRecords;
      _currRecord = 0;
    }

    @Override
    public boolean hasNext() {
      if (_maxRecords < 0) {
        return _genericRecordIterator.hasNext();
      } else {
        return _genericRecordIterator.hasNext() && (_currRecord < _maxRecords);
      }
    }

    @Override
    public TupleDocument next() {
      _currRecord++;
      GenericRecord record = _genericRecordIterator.next();
      try {
        return _recordConverter.convertRecord("", record, null).iterator().next();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    @Override
    public void remove() {
    }

  }

  class Verifier {
    private final Map<String, byte[]> verificationCache = new HashMap<>(1000);
    private Class recordClass;

    void onWrite(AbstractDocument doc)
        throws UnsupportedEncodingException {
      recordClass = doc.getClass();
      if (doc instanceof TupleDocument) {
        ByteBuf outgoingBuf = (((TupleDocument) doc).content()).value1();
        byte[] outgoingBytes = new byte[outgoingBuf.readableBytes()];
        outgoingBuf.getBytes(0, outgoingBytes);
        verificationCache.put(doc.id(), outgoingBytes);
      } else if (doc instanceof RawJsonDocument) {
        verificationCache.put(doc.id(), ((RawJsonDocument) doc).content().getBytes("UTF-8"));
      } else {
        throw new UnsupportedOperationException("Can only support TupleDocument or RawJsonDocument at this time");
      }
    }

    void verify(Bucket bucket)
        throws UnsupportedEncodingException {
      // verify
      System.out.println("Starting verification procedure");
      for (Map.Entry<String, byte[]> cacheEntry : verificationCache.entrySet()) {
        Object doc = bucket.get(cacheEntry.getKey(), recordClass);
        if (doc instanceof TupleDocument) {
          ByteBuf returnedBuf = (((TupleDocument) doc).content()).value1();
          byte[] returnedBytes = new byte[returnedBuf.readableBytes()];
          returnedBuf.getBytes(0, returnedBytes);
          Assert.assertEquals(returnedBytes, cacheEntry.getValue(), "Returned content for TupleDoc should be equal");
        } else if (doc instanceof RawJsonDocument) {
          byte[] returnedBytes = ((RawJsonDocument) doc).content().getBytes("UTF-8");
          Assert.assertEquals(returnedBytes, cacheEntry.getValue(), "Returned content for JsonDoc should be equal");
        } else {
          Assert.fail("Returned type was neither TupleDocument nor RawJsonDocument");
        }
      }
      System.out.println("Verification success!");
    }
  }

  /**
   * Uses the {@link AsyncWriterManager} to write records through a couchbase writer
   * It keeps a copy of the key, value combinations written and checks after all the writes have gone through.
   * @param recordIterator
   * @throws IOException
   */
  private void writeRecordsWithAsyncWriter(Iterator<AbstractDocument> recordIterator)
      throws IOException {
    boolean verbose = false;
    Config config = getConfig("default", Optional.empty(), Optional.empty(), Optional.empty());
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, config);

    try {
      AsyncWriterManager asyncWriterManager =
          AsyncWriterManager.builder().asyncDataWriter(writer).maxOutstandingWrites(100000).retriesEnabled(true)
              .numRetries(5).build();

      if (verbose) {
        // Create a reporter for metrics. This reporter will write metrics to STDOUT.
        OutputStreamReporter.Factory.newBuilder().build(new Properties());
        // Start all metric reporters.
        RootMetricContext.get().startReporting();
      }

      Verifier verifier = new Verifier();
      while (recordIterator.hasNext()) {
        AbstractDocument doc = recordIterator.next();
        verifier.onWrite(doc);
        asyncWriterManager.write(doc);
      }
      asyncWriterManager.commit();
      verifier.verify(writer.getBucket());
    } finally {
      writer.close();
    }
  }

  private List<Pair<AbstractDocument, Future>> writeRecords(Iterator<AbstractDocument> recordIterator,
      CouchbaseWriter writer, int outstandingRequests, long kvTimeout, TimeUnit kvTimeoutUnit)
      throws DataConversionException, UnsupportedEncodingException {
    final BlockingQueue<Pair<AbstractDocument, Future>> outstandingCallQueue =
        new LinkedBlockingDeque<>(outstandingRequests);
    final List<Pair<AbstractDocument, Future>> failedFutures = new ArrayList<>(outstandingRequests);

    int index = 0;
    long runTime = 0;
    final AtomicInteger callbackSuccesses = new AtomicInteger(0);
    final AtomicInteger callbackFailures = new AtomicInteger(0);
    final ConcurrentLinkedDeque<Throwable> callbackExceptions = new ConcurrentLinkedDeque<>();
    Verifier verifier = new Verifier();
    while (recordIterator.hasNext()) {
      AbstractDocument doc = recordIterator.next();
      index++;
      verifier.onWrite(doc);
      final long startTime = System.nanoTime();
      Future callFuture = writer.write(doc, new WriteCallback<TupleDocument>() {
        @Override
        public void onSuccess(WriteResponse<TupleDocument> writeResponse) {
          callbackSuccesses.incrementAndGet();
        }

        @Override
        public void onFailure(Throwable throwable) {
          callbackFailures.incrementAndGet();
          callbackExceptions.add(throwable);
        }
      });
      drainQueue(outstandingCallQueue, 1, kvTimeout, kvTimeoutUnit, failedFutures);
      outstandingCallQueue.add(new Pair<>(doc, callFuture));
      runTime += System.nanoTime() - startTime;
    }
    int failedWrites = 0;
    long responseStartTime = System.nanoTime();
    drainQueue(outstandingCallQueue, outstandingRequests, kvTimeout, kvTimeoutUnit, failedFutures);
    runTime += System.nanoTime() - responseStartTime;

    for (Throwable failure : callbackExceptions) {
      System.out.println(failure.getClass() + " : " + failure.getMessage());
    }
    failedWrites += failedFutures.size();

    System.out.println(
        "Total time to send " + index + " records = " + runTime / 1000000.0 + "ms, " + "Failed writes = " + failedWrites
            + " Callback Successes = " + callbackSuccesses.get() + "Callback Failures = " + callbackFailures.get());

    verifier.verify(writer.getBucket());
    return failedFutures;
  }

  @Test
  public void testMultiTupleDocumentWrite()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    CouchbaseWriter writer = new CouchbaseWriter(_couchbaseEnvironment, ConfigFactory.empty());
    try {

      final Schema dataRecordSchema =
          SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
              .noDefault().endRecord();

      final Schema schema =
          SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
              .type(dataRecordSchema).noDefault().endRecord();

      final int numRecords = 1000;
      int outstandingRequests = 99;

      Iterator<GenericRecord> recordIterator = new Iterator<GenericRecord>() {
        private int currentIndex;

        @Override
        public void remove() {
        }

        @Override
        public boolean hasNext() {
          return (currentIndex < numRecords);
        }

        @Override
        public GenericRecord next() {
          GenericData.Record testRecord = new GenericData.Record(schema);

          String testContent = "hello world" + currentIndex;
          GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
          dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
          dataRecord.put("flags", 0);

          testRecord.put("key", "hello" + currentIndex);
          testRecord.put("data", dataRecord);
          currentIndex++;
          return testRecord;
        }
      };

      long kvTimeout = 10000;
      TimeUnit kvTimeoutUnit = TimeUnit.MILLISECONDS;
      writeRecords(new TupleDocumentIterator(recordIterator), writer, outstandingRequests, kvTimeout, kvTimeoutUnit);
    } finally {
      writer.close();
    }
  }

  @Test
  public void testMultiJsonDocumentWriteWithAsyncWriter()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {

    final int numRecords = 1000;

    Iterator<Object> recordIterator = new Iterator<Object>() {
      private int currentIndex;

      @Override
      public boolean hasNext() {
        return (currentIndex < numRecords);
      }

      @Override
      public Object next() {

        String testContent = "hello world" + currentIndex;
        String key = "hello" + currentIndex;
        HashMap<String, String> contentMap = new HashMap<>();
        contentMap.put("key", key);
        contentMap.put("value", testContent);
        currentIndex++;
        return contentMap;
      }

      @Override
      public void remove() {
      }

    };
    writeRecordsWithAsyncWriter(new JsonDocumentIterator(recordIterator));
  }

  @Test
  public void testMultiTupleDocumentWriteWithAsyncWriter()
      throws IOException, DataConversionException, ExecutionException, InterruptedException {
    final Schema dataRecordSchema =
        SchemaBuilder.record("Data").fields().name("data").type().bytesType().noDefault().name("flags").type().intType()
            .noDefault().endRecord();

    final Schema schema =
        SchemaBuilder.record("TestRecord").fields().name("key").type().stringType().noDefault().name("data")
            .type(dataRecordSchema).noDefault().endRecord();

    final int numRecords = 1000;

    Iterator<GenericRecord> recordIterator = new Iterator<GenericRecord>() {
      private int currentIndex;

      @Override
      public void remove() {
      }

      @Override
      public boolean hasNext() {
        return (currentIndex < numRecords);
      }

      @Override
      public GenericRecord next() {
        GenericData.Record testRecord = new GenericData.Record(schema);

        String testContent = "hello world" + currentIndex;
        GenericData.Record dataRecord = new GenericData.Record(dataRecordSchema);
        dataRecord.put("data", ByteBuffer.wrap(testContent.getBytes(Charset.forName("UTF-8"))));
        dataRecord.put("flags", 0);

        testRecord.put("key", "hello" + currentIndex);
        testRecord.put("data", dataRecord);
        currentIndex++;
        return testRecord;
      }
    };
    writeRecordsWithAsyncWriter(new TupleDocumentIterator(recordIterator));
  }
}
