/*
 * 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
 *
 *   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.streams.mongo;

import org.apache.streams.config.ComponentConfigurator;
import org.apache.streams.config.StreamsConfigurator;
import org.apache.streams.core.DatumStatusCounter;
import org.apache.streams.core.StreamsDatum;
import org.apache.streams.core.StreamsPersistReader;
import org.apache.streams.core.StreamsResultSet;
import org.apache.streams.jackson.StreamsJacksonMapper;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.collect.Queues;
import com.mongodb.DB;
import com.mongodb.DBCollection;
import com.mongodb.DBCursor;
import com.mongodb.DBObject;
import com.mongodb.MongoClient;
import com.mongodb.MongoCredential;
import com.mongodb.ServerAddress;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.math.BigInteger;
import java.util.Queue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * MongoPersistReader reads documents from mongo.
 */
public class MongoPersistReader implements StreamsPersistReader {

  public static final String STREAMS_ID = "MongoPersistReader";

  private static final Logger LOGGER = LoggerFactory.getLogger(MongoPersistReader.class);

  protected volatile Queue<StreamsDatum> persistQueue;

  private ObjectMapper mapper = StreamsJacksonMapper.getInstance();
  private volatile AtomicLong lastWrite = new AtomicLong(System.currentTimeMillis());

  private ExecutorService executor;

  private MongoConfiguration config;

  protected MongoClient client;
  protected DB db;
  protected DBCollection collection;

  protected DBCursor cursor;

  protected final ReadWriteLock lock = new ReentrantReadWriteLock();

  /**
   * KafkaPersistReader constructor - resolves KafkaConfiguration from JVM 'mongo'.
   */
  public MongoPersistReader() {
    this.config = new ComponentConfigurator<>(MongoConfiguration.class).detectConfiguration();
  }

  /**
   * KafkaPersistReader constructor - uses supplied MongoConfiguration.
   * @param config config
   */
  public MongoPersistReader(MongoConfiguration config) {
    this.config = config;
  }

  /**
   * KafkaPersistReader constructor - uses supplied persistQueue.
   * @param persistQueue persistQueue
   */
  public MongoPersistReader(Queue<StreamsDatum> persistQueue) {
    this.config = new ComponentConfigurator<>(MongoConfiguration.class).detectConfiguration();
    this.persistQueue = persistQueue;
  }

  public void setPersistQueue(Queue<StreamsDatum> persistQueue) {
    this.persistQueue = persistQueue;
  }

  public Queue<StreamsDatum> getPersistQueue() {
    return persistQueue;
  }

  public void stop() {
  }

  @Override
  public String getId() {
    return STREAMS_ID;
  }

  @Override
  public void prepare(Object configurationObject) {

    connectToMongo();

    if ( client == null
        || collection == null ) {
      throw new RuntimeException("Unable to connect!");
    }
    cursor = collection.find();

    if ( cursor == null
        || !cursor.hasNext()) {
      throw new RuntimeException("Collection not present or empty!");
    }

    persistQueue = constructQueue();

    executor = Executors.newSingleThreadExecutor();

  }

  @Override
  public void cleanUp() {
    stop();
  }

  protected StreamsDatum prepareDatum(DBObject dbObject) {

    ObjectNode objectNode;
    String id;

    try {
      objectNode = mapper.readValue(dbObject.toString(), ObjectNode.class);
      id = objectNode.get("_id").get("$oid").asText();
      objectNode.remove("_id");
    } catch (IOException ex) {
      LOGGER.warn("document isn't valid JSON.");
      return null;
    }

    return new StreamsDatum(objectNode, id);
  }

  private synchronized void connectToMongo() {

    ServerAddress serverAddress = new ServerAddress(config.getHost(), config.getPort().intValue());

    if (StringUtils.isNotEmpty(config.getUser()) && StringUtils.isNotEmpty(config.getPassword())) {
      MongoCredential credential =
          MongoCredential.createCredential(config.getUser(), config.getDb(), config.getPassword().toCharArray());
      client = new MongoClient(serverAddress, Stream.of(credential).collect(Collectors.toList()));
    } else {
      client = new MongoClient(serverAddress);
    }

    db = client.getDB(config.getDb());

    if (!db.collectionExists(config.getCollection())) {
      db.createCollection(config.getCollection(), null);
    }

    collection = db.getCollection(config.getCollection());
  }

  @Override
  public StreamsResultSet readAll() {

    try (DBCursor cursor = collection.find()) {
      while (cursor.hasNext()) {
        DBObject dbObject = cursor.next();
        StreamsDatum datum = prepareDatum(dbObject);
        write(datum);
      }
    }

    return readCurrent();
  }

  @Override
  public void startStream() {

    LOGGER.debug("startStream");
    MongoPersistReaderTask readerTask = new MongoPersistReaderTask(this);
    Thread readerTaskThread = new Thread(readerTask);
    Future future = executor.submit(readerTaskThread);

    while ( !future.isDone() && !future.isCancelled()) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException interrupt) {
        LOGGER.trace("Interrupt", interrupt);
      }
    }

    executor.shutdown();

  }

  @Override
  public StreamsResultSet readCurrent() {

    StreamsResultSet current;

    try {
      lock.writeLock().lock();
      current = new StreamsResultSet(persistQueue);
      current.setCounter(new DatumStatusCounter());
      persistQueue = constructQueue();
    } finally {
      lock.writeLock().unlock();
    }

    return current;
  }

  //The locking may appear to be counter intuitive but we really don't care if multiple threads offer to the queue
  //as it is a synchronized queue.  What we do care about is that we don't want to be offering to the current reference
  //if the queue is being replaced with a new instance
  protected void write(StreamsDatum entry) {
    boolean success;
    do {
      try {
        lock.readLock().lock();
        success = persistQueue.offer(entry);
        Thread.yield();
      } finally {
        lock.readLock().unlock();
      }
    }
    while (!success);
  }

  @Override
  public StreamsResultSet readNew(BigInteger sequence) {
    return null;
  }

  @Override
  public StreamsResultSet readRange(DateTime start, DateTime end) {
    return null;
  }

  @Override
  public boolean isRunning() {
    return !executor.isTerminated() || !executor.isShutdown();
  }

  private Queue<StreamsDatum> constructQueue() {
    return Queues.synchronizedQueue(new LinkedBlockingQueue<StreamsDatum>(10000));
  }

  public class MongoPersistReaderTask implements Runnable {

    private MongoPersistReader reader;

    public MongoPersistReaderTask(MongoPersistReader reader) {
      this.reader = reader;
    }

    @Override
    public void run() {

      try {
        while (reader.cursor.hasNext()) {
          DBObject dbObject = reader.cursor.next();
          StreamsDatum datum = reader.prepareDatum(dbObject);
          reader.write(datum);
        }
      } finally {
        reader.cursor.close();
      }

    }

  }
}
