/**
 * 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.waveprotocol.box.server.waveserver;

import com.google.common.base.Preconditions;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.SettableFuture;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.*;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.util.Version;
import org.waveprotocol.box.server.CoreSettingsNames;
import org.waveprotocol.box.server.executor.ExecutorAnnotations.IndexExecutor;
import org.waveprotocol.box.server.persistence.lucene.IndexDirectory;
import org.waveprotocol.wave.model.id.WaveId;
import org.waveprotocol.wave.model.id.WaveletId;
import org.waveprotocol.wave.model.id.WaveletName;
import org.waveprotocol.wave.model.wave.ParticipantId;
import org.waveprotocol.wave.model.wave.ParticipantIdUtil;
import org.waveprotocol.wave.model.wave.data.ReadableWaveletData;

import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.logging.Level;
import java.util.logging.Logger;

import static org.waveprotocol.box.server.waveserver.IndexFieldType.*;

/**
 * Lucene based implementation of {@link PerUserWaveViewHandler}.
 *
 * @author [email protected] (Yuri Zelikov)
 */
@Singleton
public class LucenePerUserWaveViewHandlerImpl implements PerUserWaveViewHandler, Closeable {

  private static class WaveSearchWarmer implements SearcherWarmer {

    private final ParticipantId sharedDomainParticipantId;

    WaveSearchWarmer(String waveDomain) {
      sharedDomainParticipantId = ParticipantIdUtil.makeUnsafeSharedDomainParticipantId(waveDomain);
    }

    @Override
    public void warm(IndexSearcher searcher) throws IOException {
      // TODO (Yuri Z): Run some diverse searches, searching against all
      // fields.

      BooleanQuery participantQuery = new BooleanQuery();
      participantQuery.add(
          new TermQuery(new Term(WITH.toString(), sharedDomainParticipantId.getAddress())),
          Occur.SHOULD);
      searcher.search(participantQuery, MAX_WAVES);
    }
  }

  private static final Logger LOG = Logger.getLogger(LucenePerUserWaveViewHandlerImpl.class
      .getName());

  private static final Version LUCENE_VERSION = Version.LUCENE_35;

  /** The results will be returned in the ascending order according to last modified time. */
  private static Sort LMT_ASC_SORT = new Sort(new SortField("title", SortField.LONG));

  /** Minimum time until a new reader can be opened. */
  private static final double MIN_STALE_SEC = 0.025;

  /** Maximum time until a new reader must be opened. */
  private static final double MAX_STALE_SEC = 1.0;

  /** Defines the maximum number of waves returned by the search. */
  private static final int MAX_WAVES = 10000;

  private final StandardAnalyzer analyzer;
  private final IndexWriter indexWriter;
  private final NRTManager nrtManager;
  private final NRTManagerReopenThread nrtManagerReopenThread;
  private final ReadableWaveletDataProvider waveletProvider;
  private final Executor executor;
  private boolean isClosed = false;

  @Inject
  public LucenePerUserWaveViewHandlerImpl(IndexDirectory directory,
                                          ReadableWaveletDataProvider waveletProvider,
                                          @Named(CoreSettingsNames.WAVE_SERVER_DOMAIN) String domain,
                                          @IndexExecutor Executor executor) {
    this.waveletProvider = waveletProvider;
    this.executor = executor;
    analyzer = new StandardAnalyzer(LUCENE_VERSION);
    try {
      IndexWriterConfig indexConfig = new IndexWriterConfig(LUCENE_VERSION, analyzer);
      indexConfig.setOpenMode(OpenMode.CREATE_OR_APPEND);
      indexWriter = new IndexWriter(directory.getDirectory(), indexConfig);
      nrtManager = new NRTManager(indexWriter, new WaveSearchWarmer(domain));
    } catch (IOException ex) {
      throw new IndexException(ex);
    }

    nrtManagerReopenThread = new NRTManagerReopenThread(nrtManager, MAX_STALE_SEC, MIN_STALE_SEC);
    nrtManagerReopenThread.start();
  }

  /**
   * Closes the handler, releases resources and flushes the recent index changes
   * to persistent storage.
   */
  @Override
  public synchronized void close() {
    if (isClosed) {
      throw new AlreadyClosedException("Already closed");
    }
    isClosed = true;
    try {
      nrtManager.close();
      if (analyzer != null) {
        analyzer.close();
      }
      nrtManagerReopenThread.close();
      indexWriter.close();
    } catch (IOException ex) {
      LOG.log(Level.SEVERE, "Failed to close the Lucene index", ex);
    }
    LOG.info("Successfully closed the Lucene index...");
  }

  /**
   * Ensures that the index is up to date. Exits quickly if no changes were done
   * to the index.
   *
   * @throws IOException if something goes wrong.
   */
  public void forceReopen() throws IOException {
    nrtManager.maybeReopen(true);
  }

  @Override
  public ListenableFuture<Void> onParticipantAdded(final WaveletName waveletName,
      ParticipantId participant) {
    Preconditions.checkNotNull(waveletName);
    Preconditions.checkNotNull(participant);

    ListenableFutureTask<Void> task = ListenableFutureTask.create(new Callable<Void>() {

      @Override
      public Void call() throws Exception {
        ReadableWaveletData waveletData;
        try {
          waveletData = waveletProvider.getReadableWaveletData(waveletName);
          updateIndex(waveletData);
        } catch (WaveServerException e) {
          LOG.log(Level.SEVERE, "Failed to update index for " + waveletName, e);
          throw e;
        }
        return null;
      }
    });
    executor.execute(task);
    return task;
  }

  @Override
  public ListenableFuture<Void> onParticipantRemoved(final WaveletName waveletName,
      final ParticipantId participant) {
    Preconditions.checkNotNull(waveletName);
    Preconditions.checkNotNull(participant);

    ListenableFutureTask<Void> task = ListenableFutureTask.create(new Callable<Void>() {

      @Override
      public Void call() throws Exception {
        ReadableWaveletData waveletData;
        try {
          waveletData = waveletProvider.getReadableWaveletData(waveletName);
          try {
            removeParticipantfromIndex(waveletData, participant, nrtManager);
          } catch (IOException e) {
            LOG.log(Level.SEVERE, "Failed to update index for " + waveletName, e);
            throw e;
          }
        } catch (WaveServerException e) {
          LOG.log(Level.SEVERE, "Failed to update index for " + waveletName, e);
          throw e;
        }
        return null;
      }
    });
    executor.execute(task);
    return task;
  }

  @Override
  public ListenableFuture<Void> onWaveInit(final WaveletName waveletName) {
    Preconditions.checkNotNull(waveletName);

    ListenableFutureTask<Void> task = ListenableFutureTask.create(new Callable<Void>() {

      @Override
      public Void call() throws Exception {
        ReadableWaveletData waveletData;
        try {
          waveletData = waveletProvider.getReadableWaveletData(waveletName);
          updateIndex(waveletData);
        } catch (WaveServerException e) {
          LOG.log(Level.SEVERE, "Failed to initialize index for " + waveletName, e);
          throw e;
        }
        return null;
      }
    });
    executor.execute(task);
    return task;
  }

  @Override
  public ListenableFuture<Void> onWaveUpdated(final ReadableWaveletData waveletData) {
    // No op.
    SettableFuture<Void> task = SettableFuture.create();
    task.set(null);
    return task;
  }

  private void updateIndex(ReadableWaveletData wavelet) throws IndexException {
    Preconditions.checkNotNull(wavelet);
    try {
      // TODO (Yuri Z): Update documents instead of totally removing and adding.
      removeIndex(wavelet, nrtManager);
      addIndex(wavelet, nrtManager);
      indexWriter.commit();
    } catch (IOException e) {
      throw new IndexException(String.valueOf(wavelet.getWaveletId()), e);
    }
  }

  private static void addIndex(ReadableWaveletData wavelet,
      NRTManager nrtManager) throws IOException {
    Document doc = new Document();
    addWaveletFieldsToIndex(wavelet, doc);
    nrtManager.addDocument(doc);
  }

  private static void addWaveletFieldsToIndex(ReadableWaveletData wavelet, Document doc) {
    doc.add(new Field(WAVEID.toString(), wavelet.getWaveId().serialise(), Field.Store.YES,
        Field.Index.NOT_ANALYZED));
    doc.add(new Field(WAVELETID.toString(), wavelet.getWaveletId().serialise(), Field.Store.YES,
        Field.Index.NOT_ANALYZED));
    doc.add(new Field(LMT.toString(), Long.toString(wavelet.getLastModifiedTime()), Field.Store.NO,
        Field.Index.NOT_ANALYZED));
    for (ParticipantId participant : wavelet.getParticipants()) {
      doc.add(new Field(WITH.toString(), participant.toString(), Field.Store.YES,
          Field.Index.NOT_ANALYZED));
    }
  }

  private static void removeIndex(ReadableWaveletData wavelet, NRTManager nrtManager)
      throws IOException {
    BooleanQuery query = new BooleanQuery();
    query.add(new TermQuery(new Term(WAVEID.toString(), wavelet.getWaveId().serialise())),
        BooleanClause.Occur.MUST);
    query.add(new TermQuery(new Term(WAVELETID.toString(), wavelet.getWaveletId().serialise())),
        BooleanClause.Occur.MUST);
    nrtManager.deleteDocuments(query);
  }

  private static void removeParticipantfromIndex(ReadableWaveletData wavelet,
      ParticipantId participant, NRTManager nrtManager) throws IOException {
    BooleanQuery query = new BooleanQuery();
    Term waveIdTerm = new Term(WAVEID.toString(), wavelet.getWaveId().serialise());
    query.add(new TermQuery(waveIdTerm), BooleanClause.Occur.MUST);
    query.add(new TermQuery(new Term(WAVELETID.toString(), wavelet.getWaveletId().serialise())),
        BooleanClause.Occur.MUST);
    SearcherManager searcherManager = nrtManager.getSearcherManager(true);
    IndexSearcher indexSearcher = searcherManager.acquire();
    try {
      TopDocs hints = indexSearcher.search(query, MAX_WAVES);
      for (ScoreDoc hint : hints.scoreDocs) {
        Document document = indexSearcher.doc(hint.doc);
        String[] participantValues = document.getValues(WITH.toString());
        document.removeFields(WITH.toString());
        for (String address : participantValues) {
          if (address.equals(participant.getAddress())) {
            continue;
          }
          document.add(new Field(WITH.toString(), address, Field.Store.YES,
              Field.Index.NOT_ANALYZED));
        }
        nrtManager.updateDocument(waveIdTerm, document);
      }
    } catch (IOException e) {
      LOG.log(Level.WARNING, "Failed to fetch from index " + wavelet.toString(), e);
    } finally {
      try {
        searcherManager.release(indexSearcher);
      } catch (IOException e) {
        LOG.log(Level.WARNING, "Failed to close searcher. ", e);
      }
    }
  }


  @Override
  public Multimap<WaveId, WaveletId> retrievePerUserWaveView(ParticipantId user) {
    Preconditions.checkNotNull(user);

    Multimap<WaveId, WaveletId> userWavesViewMap = HashMultimap.create();
    BooleanQuery participantQuery = new BooleanQuery();
    participantQuery.add(new TermQuery(new Term(WITH.toString(), user.getAddress())), Occur.SHOULD);
    SearcherManager searcherManager = nrtManager.getSearcherManager(true);
    IndexSearcher indexSearcher = searcherManager.acquire();
    try {
      TopDocs hints = indexSearcher.search(participantQuery, MAX_WAVES, LMT_ASC_SORT);
      for (ScoreDoc hint : hints.scoreDocs) {
        Document document = indexSearcher.doc(hint.doc);
        WaveId waveId = WaveId.deserialise(document.get(WAVEID.toString()));
        WaveletId waveletId = WaveletId.deserialise(document.get(WAVELETID.toString()));
        userWavesViewMap.put(waveId, waveletId);
      }
    } catch (IOException e) {
      LOG.log(Level.WARNING, "Search failed: " + user, e);
    } finally {
      try {
        searcherManager.release(indexSearcher);
      } catch (IOException e) {
        LOG.log(Level.WARNING, "Failed to close searcher. " + user, e);
      }
    }
    return userWavesViewMap;
  }
}