/*
 * 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.lucene.search;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.index.RandomIndexWriter;
import org.apache.lucene.tests.search.DummyTotalHitCountCollector;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.junit.Test;

public class TestMultiCollector extends LuceneTestCase {

  private static class TerminateAfterCollector extends FilterCollector {

    private int count = 0;
    private final int terminateAfter;

    public TerminateAfterCollector(Collector in, int terminateAfter) {
      super(in);
      this.terminateAfter = terminateAfter;
    }

    @Override
    public LeafCollector getLeafCollector(LeafReaderContext context) throws IOException {
      if (count >= terminateAfter) {
        throw new CollectionTerminatedException();
      }
      final LeafCollector in = super.getLeafCollector(context);
      return new FilterLeafCollector(in) {
        @Override
        public void collect(int doc) throws IOException {
          if (count >= terminateAfter) {
            throw new CollectionTerminatedException();
          }
          super.collect(doc);
          count++;
        }
      };
    }
  }

  private static class SetScorerCollector extends FilterCollector {

    private final AtomicBoolean setScorerCalled;

    public SetScorerCollector(Collector in, AtomicBoolean setScorerCalled) {
      super(in);
      this.setScorerCalled = setScorerCalled;
    }

    @Override
    public LeafCollector getLeafCollector(LeafReaderContext context) throws IOException {
      return new FilterLeafCollector(super.getLeafCollector(context)) {
        @Override
        public void setScorer(Scorable scorer) throws IOException {
          super.setScorer(scorer);
          setScorerCalled.set(true);
        }
      };
    }
  }

  public void testCollectionTerminatedExceptionHandling() throws IOException {
    final int iters = atLeast(3);
    for (int iter = 0; iter < iters; ++iter) {
      Directory dir = newDirectory();
      RandomIndexWriter w = new RandomIndexWriter(random(), dir);
      final int numDocs = TestUtil.nextInt(random(), 100, 1000);
      final Document doc = new Document();
      for (int i = 0; i < numDocs; ++i) {
        w.addDocument(doc);
      }
      final IndexReader reader = w.getReader();
      w.close();
      final IndexSearcher searcher = newSearcher(reader, true, true, false);
      Map<DummyTotalHitCountCollector, Integer> expectedCounts = new HashMap<>();
      List<Collector> collectors = new ArrayList<>();
      final int numCollectors = TestUtil.nextInt(random(), 1, 5);
      for (int i = 0; i < numCollectors; ++i) {
        final int terminateAfter = random().nextInt(numDocs + 10);
        final int expectedCount = terminateAfter > numDocs ? numDocs : terminateAfter;
        DummyTotalHitCountCollector collector = new DummyTotalHitCountCollector();
        expectedCounts.put(collector, expectedCount);
        collectors.add(new TerminateAfterCollector(collector, terminateAfter));
      }
      searcher.search(
          new MatchAllDocsQuery(),
          new CollectorManager<Collector, Void>() {
            @Override
            public Collector newCollector() {
              return MultiCollector.wrap(collectors);
            }

            @Override
            public Void reduce(Collection<Collector> collectors) {
              return null;
            }
          });
      for (Map.Entry<DummyTotalHitCountCollector, Integer> expectedCount :
          expectedCounts.entrySet()) {
        assertEquals(expectedCount.getValue().intValue(), expectedCount.getKey().getTotalHits());
      }
      reader.close();
      dir.close();
    }
  }

  public void testSetScorerAfterCollectionTerminated() throws IOException {
    Collector collector1 = new DummyTotalHitCountCollector();
    Collector collector2 = new DummyTotalHitCountCollector();

    AtomicBoolean setScorerCalled1 = new AtomicBoolean();
    collector1 = new SetScorerCollector(collector1, setScorerCalled1);

    AtomicBoolean setScorerCalled2 = new AtomicBoolean();
    collector2 = new SetScorerCollector(collector2, setScorerCalled2);

    collector1 = new TerminateAfterCollector(collector1, 1);
    collector2 = new TerminateAfterCollector(collector2, 2);

    Scorable scorer = new ScoreAndDoc();

    List<Collector> collectors = Arrays.asList(collector1, collector2);
    Collections.shuffle(collectors, random());
    Collector collector = MultiCollector.wrap(collectors);

    LeafCollector leafCollector = collector.getLeafCollector(null);
    leafCollector.setScorer(scorer);
    assertTrue(setScorerCalled1.get());
    assertTrue(setScorerCalled2.get());

    leafCollector.collect(0);
    leafCollector.collect(1);

    setScorerCalled1.set(false);
    setScorerCalled2.set(false);
    leafCollector.setScorer(scorer);
    assertFalse(setScorerCalled1.get());
    assertTrue(setScorerCalled2.get());

    expectThrows(
        CollectionTerminatedException.class,
        () -> {
          leafCollector.collect(1);
        });

    setScorerCalled1.set(false);
    setScorerCalled2.set(false);
    leafCollector.setScorer(scorer);
    assertFalse(setScorerCalled1.get());
    assertFalse(setScorerCalled2.get());
  }

  public void testDisablesSetMinScore() throws IOException {
    Directory dir = newDirectory();
    IndexWriter w = new IndexWriter(dir, newIndexWriterConfig());
    w.addDocument(new Document());
    IndexReader reader = DirectoryReader.open(w);
    w.close();

    Scorable scorer =
        new Scorable() {
          @Override
          public int docID() {
            throw new UnsupportedOperationException();
          }

          @Override
          public float score() {
            return 0;
          }

          @Override
          public void setMinCompetitiveScore(float minScore) {
            throw new AssertionError();
          }
        };

    Collector collector =
        new SimpleCollector() {
          private Scorable scorer;
          float minScore = 0;

          @Override
          public ScoreMode scoreMode() {
            return ScoreMode.TOP_SCORES;
          }

          @Override
          public void setScorer(Scorable scorer) throws IOException {
            this.scorer = scorer;
          }

          @Override
          public void collect(int doc) throws IOException {
            minScore = Math.nextUp(minScore);
            scorer.setMinCompetitiveScore(minScore);
          }
        };
    Collector multiCollector = MultiCollector.wrap(collector, new DummyTotalHitCountCollector());
    LeafCollector leafCollector = multiCollector.getLeafCollector(reader.leaves().get(0));
    leafCollector.setScorer(scorer);
    leafCollector.collect(0); // no exception

    reader.close();
    dir.close();
  }

  public void testDisablesSetMinScoreWithEarlyTermination() throws IOException {
    Directory dir = newDirectory();
    IndexWriter w = new IndexWriter(dir, newIndexWriterConfig());
    w.addDocument(new Document());
    IndexReader reader = DirectoryReader.open(w);
    w.close();

    Scorable scorer =
        new Scorable() {
          @Override
          public int docID() {
            throw new UnsupportedOperationException();
          }

          @Override
          public float score() {
            return 0;
          }

          @Override
          public void setMinCompetitiveScore(float minScore) {
            throw new AssertionError();
          }
        };

    Collector collector =
        new SimpleCollector() {
          private Scorable scorer;
          float minScore = 0;

          @Override
          public ScoreMode scoreMode() {
            return ScoreMode.TOP_SCORES;
          }

          @Override
          public void setScorer(Scorable scorer) throws IOException {
            this.scorer = scorer;
          }

          @Override
          public void collect(int doc) throws IOException {
            minScore = Math.nextUp(minScore);
            scorer.setMinCompetitiveScore(minScore);
          }
        };
    for (int numCol = 1; numCol < 4; numCol++) {
      List<Collector> cols = new ArrayList<>();
      cols.add(collector);
      for (int col = 0; col < numCol; col++) {
        cols.add(new TerminateAfterCollector(new DummyTotalHitCountCollector(), 0));
      }
      Collections.shuffle(cols, random());
      Collector multiCollector = MultiCollector.wrap(cols);
      LeafCollector leafCollector = multiCollector.getLeafCollector(reader.leaves().get(0));
      leafCollector.setScorer(scorer);
      leafCollector.collect(0); // no exception
    }

    reader.close();
    dir.close();
  }

  private static class DummyCollector extends SimpleCollector {

    boolean collectCalled = false;
    boolean setNextReaderCalled = false;
    boolean setScorerCalled = false;

    @Override
    public void collect(int doc) throws IOException {
      collectCalled = true;
    }

    @Override
    protected void doSetNextReader(LeafReaderContext context) throws IOException {
      setNextReaderCalled = true;
    }

    @Override
    public void setScorer(Scorable scorer) throws IOException {
      setScorerCalled = true;
    }

    @Override
    public ScoreMode scoreMode() {
      return ScoreMode.COMPLETE;
    }
  }

  @Test
  public void testNullCollectors() throws Exception {
    // Tests that the collector rejects all null collectors.
    expectThrows(
        IllegalArgumentException.class,
        () -> {
          MultiCollector.wrap(null, null);
        });

    // Tests that the collector handles some null collectors well. If it
    // doesn't, an NPE would be thrown.
    Collector c = MultiCollector.wrap(new DummyCollector(), null, new DummyCollector());
    assertTrue(c instanceof MultiCollector);
    final LeafCollector ac = c.getLeafCollector(null);
    ac.collect(1);
    c.getLeafCollector(null);
    c.getLeafCollector(null).setScorer(new ScoreAndDoc());
  }

  @Test
  public void testSingleCollector() throws Exception {
    // Tests that if a single Collector is input, it is returned (and not MultiCollector).
    DummyCollector dc = new DummyCollector();
    assertSame(dc, MultiCollector.wrap(dc));
    assertSame(dc, MultiCollector.wrap(dc, null));
  }

  @Test
  public void testCollector() throws Exception {
    // Tests that the collector delegates calls to input collectors properly.

    // Tests that the collector handles some null collectors well. If it
    // doesn't, an NPE would be thrown.
    DummyCollector[] dcs = new DummyCollector[] {new DummyCollector(), new DummyCollector()};
    Collector c = MultiCollector.wrap(dcs);
    LeafCollector ac = c.getLeafCollector(null);
    ac.collect(1);
    ac = c.getLeafCollector(null);
    ac.setScorer(new ScoreAndDoc());

    for (DummyCollector dc : dcs) {
      assertTrue(dc.collectCalled);
      assertTrue(dc.setNextReaderCalled);
      assertTrue(dc.setScorerCalled);
    }
  }

  private static Collector collector(ScoreMode scoreMode, Class<?> expectedScorer) {
    return new Collector() {

      @Override
      public LeafCollector getLeafCollector(LeafReaderContext context) throws IOException {
        return new LeafCollector() {

          @Override
          public void setScorer(Scorable scorer) throws IOException {
            while (expectedScorer.equals(scorer.getClass()) == false
                && scorer instanceof FilterScorable) {
              scorer = ((FilterScorable) scorer).in;
            }
            assertEquals(expectedScorer, scorer.getClass());
          }

          @Override
          public void collect(int doc) throws IOException {}
        };
      }

      @Override
      public ScoreMode scoreMode() {
        return scoreMode;
      }
    };
  }

  public void testCacheScoresIfNecessary() throws IOException {
    Directory dir = newDirectory();
    RandomIndexWriter iw = new RandomIndexWriter(random(), dir);
    iw.addDocument(new Document());
    iw.commit();
    DirectoryReader reader = iw.getReader();
    iw.close();

    final LeafReaderContext ctx = reader.leaves().get(0);

    expectThrows(
        AssertionError.class,
        () -> {
          collector(ScoreMode.COMPLETE_NO_SCORES, ScoreCachingWrappingScorer.class)
              .getLeafCollector(ctx)
              .setScorer(new ScoreAndDoc());
        });

    // no collector needs scores => no caching
    Collector c1 = collector(ScoreMode.COMPLETE_NO_SCORES, ScoreAndDoc.class);
    Collector c2 = collector(ScoreMode.COMPLETE_NO_SCORES, ScoreAndDoc.class);
    MultiCollector.wrap(c1, c2).getLeafCollector(ctx).setScorer(new ScoreAndDoc());

    // only one collector needs scores => no caching
    c1 = collector(ScoreMode.COMPLETE, ScoreAndDoc.class);
    c2 = collector(ScoreMode.COMPLETE_NO_SCORES, ScoreAndDoc.class);
    MultiCollector.wrap(c1, c2).getLeafCollector(ctx).setScorer(new ScoreAndDoc());

    // several collectors need scores => caching
    c1 = collector(ScoreMode.COMPLETE, ScoreCachingWrappingScorer.class);
    c2 = collector(ScoreMode.COMPLETE, ScoreCachingWrappingScorer.class);
    MultiCollector.wrap(c1, c2).getLeafCollector(ctx).setScorer(new ScoreAndDoc());

    reader.close();
    dir.close();
  }

  public void testScorerWrappingForTopScores() throws IOException {
    Directory dir = newDirectory();
    RandomIndexWriter iw = new RandomIndexWriter(random(), dir);
    iw.addDocument(new Document());
    DirectoryReader reader = iw.getReader();
    iw.close();
    final LeafReaderContext ctx = reader.leaves().get(0);
    Collector c1 =
        collector(ScoreMode.TOP_SCORES, MultiCollector.MinCompetitiveScoreAwareScorable.class);
    Collector c2 =
        collector(ScoreMode.TOP_SCORES, MultiCollector.MinCompetitiveScoreAwareScorable.class);
    MultiCollector.wrap(c1, c2).getLeafCollector(ctx).setScorer(new ScoreAndDoc());

    c1 = collector(ScoreMode.TOP_SCORES, ScoreCachingWrappingScorer.class);
    c2 = collector(ScoreMode.COMPLETE, ScoreCachingWrappingScorer.class);
    MultiCollector.wrap(c1, c2).getLeafCollector(ctx).setScorer(new ScoreAndDoc());

    reader.close();
    dir.close();
  }

  public void testMinCompetitiveScore() throws IOException {
    float[] currentMinScores = new float[3];
    float[] minCompetitiveScore = new float[1];
    Scorable scorer =
        new Scorable() {

          @Override
          public float score() throws IOException {
            return 0;
          }

          @Override
          public int docID() {
            return 0;
          }

          @Override
          public void setMinCompetitiveScore(float minScore) throws IOException {
            minCompetitiveScore[0] = minScore;
          }
        };
    Scorable s0 = new MultiCollector.MinCompetitiveScoreAwareScorable(scorer, 0, currentMinScores);
    Scorable s1 = new MultiCollector.MinCompetitiveScoreAwareScorable(scorer, 1, currentMinScores);
    Scorable s2 = new MultiCollector.MinCompetitiveScoreAwareScorable(scorer, 2, currentMinScores);
    assertEquals(0f, minCompetitiveScore[0], 0);
    s0.setMinCompetitiveScore(0.5f);
    assertEquals(0f, minCompetitiveScore[0], 0);
    s1.setMinCompetitiveScore(0.8f);
    assertEquals(0f, minCompetitiveScore[0], 0);
    s2.setMinCompetitiveScore(0.3f);
    assertEquals(0.3f, minCompetitiveScore[0], 0);
    s2.setMinCompetitiveScore(0.1f);
    assertEquals(0.3f, minCompetitiveScore[0], 0);
    s1.setMinCompetitiveScore(Float.MAX_VALUE);
    assertEquals(0.3f, minCompetitiveScore[0], 0);
    s2.setMinCompetitiveScore(Float.MAX_VALUE);
    assertEquals(0.5f, minCompetitiveScore[0], 0);
    s0.setMinCompetitiveScore(Float.MAX_VALUE);
    assertEquals(Float.MAX_VALUE, minCompetitiveScore[0], 0);
  }

  public void testCollectionTermination() throws IOException {
    Directory dir = newDirectory();
    RandomIndexWriter iw = new RandomIndexWriter(random(), dir);
    iw.addDocument(new Document());
    DirectoryReader reader = iw.getReader();
    iw.close();
    final LeafReaderContext ctx = reader.leaves().get(0);
    DummyCollector c1 = new TerminatingDummyCollector(1, ScoreMode.COMPLETE);
    DummyCollector c2 = new TerminatingDummyCollector(2, ScoreMode.COMPLETE);

    Collector mc = MultiCollector.wrap(c1, c2);
    LeafCollector lc = mc.getLeafCollector(ctx);
    lc.setScorer(new ScoreAndDoc());
    lc.collect(0); // OK
    assertTrue("c1's collect should be called", c1.collectCalled);
    assertTrue("c2's collect should be called", c2.collectCalled);
    c1.collectCalled = false;
    c2.collectCalled = false;
    lc.collect(1); // OK, but c1 should terminate
    assertFalse("c1 should be removed already", c1.collectCalled);
    assertTrue("c2's collect should be called", c2.collectCalled);
    c2.collectCalled = false;

    expectThrows(
        CollectionTerminatedException.class,
        () -> {
          lc.collect(2);
        });
    assertFalse("c1 should be removed already", c1.collectCalled);
    assertFalse("c2 should be removed already", c2.collectCalled);

    reader.close();
    dir.close();
  }

  public void testSetScorerOnCollectionTerminationSkipNonCompetitive() throws IOException {
    doTestSetScorerOnCollectionTermination(true);
  }

  public void testSetScorerOnCollectionTerminationSkipNoSkips() throws IOException {
    doTestSetScorerOnCollectionTermination(false);
  }

  private void doTestSetScorerOnCollectionTermination(boolean allowSkipNonCompetitive)
      throws IOException {
    Directory dir = newDirectory();
    RandomIndexWriter iw = new RandomIndexWriter(random(), dir);
    iw.addDocument(new Document());
    DirectoryReader reader = iw.getReader();
    iw.close();
    final LeafReaderContext ctx = reader.leaves().get(0);

    DummyCollector c1 =
        new TerminatingDummyCollector(
            1, allowSkipNonCompetitive ? ScoreMode.TOP_SCORES : ScoreMode.COMPLETE);
    DummyCollector c2 =
        new TerminatingDummyCollector(
            2, allowSkipNonCompetitive ? ScoreMode.TOP_SCORES : ScoreMode.COMPLETE);

    Collector mc = MultiCollector.wrap(c1, c2);
    LeafCollector lc = mc.getLeafCollector(ctx);
    assertFalse(c1.setScorerCalled);
    assertFalse(c2.setScorerCalled);
    lc.setScorer(new ScoreAndDoc());
    assertTrue(c1.setScorerCalled);
    assertTrue(c2.setScorerCalled);
    c1.setScorerCalled = false;
    c2.setScorerCalled = false;
    lc.collect(0); // OK

    lc.setScorer(new ScoreAndDoc());
    assertTrue(c1.setScorerCalled);
    assertTrue(c2.setScorerCalled);
    c1.setScorerCalled = false;
    c2.setScorerCalled = false;

    lc.collect(1); // OK, but c1 should terminate
    lc.setScorer(new ScoreAndDoc());
    assertFalse(c1.setScorerCalled);
    assertTrue(c2.setScorerCalled);
    c2.setScorerCalled = false;

    expectThrows(
        CollectionTerminatedException.class,
        () -> {
          lc.collect(2);
        });
    lc.setScorer(new ScoreAndDoc());
    assertFalse(c1.setScorerCalled);
    assertFalse(c2.setScorerCalled);

    reader.close();
    dir.close();
  }

  public void testMergeScoreModes() {
    for (ScoreMode sm1 : ScoreMode.values()) {
      for (ScoreMode sm2 : ScoreMode.values()) {
        Collector c1 = new TerminatingDummyCollector(0, sm1);
        Collector c2 = new TerminatingDummyCollector(0, sm2);
        Collector c = MultiCollector.wrap(c1, c2);
        if (sm1 == sm2) {
          assertEquals(sm1, c.scoreMode());
        } else if (sm1.needsScores() || sm2.needsScores()) {
          assertEquals(ScoreMode.COMPLETE, c.scoreMode());
        } else {
          assertEquals(ScoreMode.COMPLETE_NO_SCORES, c.scoreMode());
        }
      }
    }
  }

  private static class TerminatingDummyCollector extends DummyCollector {

    private final int terminateOnDoc;
    private final ScoreMode scoreMode;

    public TerminatingDummyCollector(int terminateOnDoc, ScoreMode scoreMode) {
      super();
      this.terminateOnDoc = terminateOnDoc;
      this.scoreMode = scoreMode;
    }

    @Override
    public void collect(int doc) throws IOException {
      if (doc == terminateOnDoc) {
        throw new CollectionTerminatedException();
      }
      super.collect(doc);
    }

    @Override
    public ScoreMode scoreMode() {
      return scoreMode;
    }
  }
}
